Skip to content

fix(player): version runtime protocol#2150

Merged
jrusso1020 merged 1 commit into
mainfrom
07-10-fix_player_version_runtime_protocol
Jul 13, 2026
Merged

fix(player): version runtime protocol#2150
jrusso1020 merged 1 commit into
mainfrom
07-10-fix_player_version_runtime_protocol

Conversation

@jrusso1020

@jrusso1020 jrusso1020 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What

Version the Player/runtime protocol and pin injected core runtime code to the Player release.

Why

An unversioned CDN runtime and untyped messages allowed silent host/runtime drift and ambiguous frame timing.

How

Define RuntimeProtocolV1 with capabilities and rational fps, generate the versioned runtime URL, validate major versions, and retain a tested legacy fallback.

Test plan

  • Player, Core, and Studio protocol tests; Player build-time runtime-pin verification
  • Stack-wide lint, format, build, typecheck, and relevant integration gates

jrusso1020 commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@jrusso1020 jrusso1020 force-pushed the 07-10-test_repo_verify_packed_consumers branch from 47513d8 to c6e0a39 Compare July 11, 2026 17:38
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch 2 times, most recently from 3f5f991 to e96823e Compare July 11, 2026 18:04
@jrusso1020 jrusso1020 force-pushed the 07-10-test_repo_verify_packed_consumers branch from 0d78a2a to 5911119 Compare July 11, 2026 18:11
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch 2 times, most recently from 235d4fd to 641ca41 Compare July 11, 2026 18:16
@jrusso1020 jrusso1020 force-pushed the 07-10-test_repo_verify_packed_consumers branch from 11159b7 to 3a725e6 Compare July 11, 2026 18:21
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch 2 times, most recently from 301cff8 to e296678 Compare July 12, 2026 04:01
@jrusso1020 jrusso1020 force-pushed the 07-10-test_repo_verify_packed_consumers branch from 3a725e6 to c33f69e Compare July 12, 2026 04:01
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch 2 times, most recently from fb4b66d to 7869547 Compare July 12, 2026 04:58

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

Review — fix(player): version runtime protocol

588 additions across 30 files — significant surface, but the architectural invariants are clean.

SSOT check

Protocol definition: single canonical source in @hyperframes/core/runtime/protocol. Both Player (runtime-message-handler.ts) and Studio (runtimeProtocol.ts) import from this. No duplicated version constants, capability lists, or inspection logic. Good.

Seek time resolution: the old frame * 30frame / 30 dance at the host/runtime boundary is replaced with timeSeconds (seconds-first protocol). The runtime's resolveSeekTimeSeconds prefers timeSeconds, falls back to frame / fps for legacy callers. The Player sends timeSeconds directly — no conversion at the boundary. This eliminates rounding errors at non-30 fps. Clean.

Fps source of truth: each side of the bridge now declares its canonical fps in the protocol metadata. The runtime reads state.canonicalFps via getCanonicalFps(); the Player reads the runtime's declared fps from incoming timeline messages and stores it in _runtimeFps. No hardcoded 30 in the time-calculation paths.

Module-level state

runtimeProtocolFps in bridge.ts — set once in initSandboxRuntimeModular() via setRuntimeProtocolFps(state.canonicalFps), never mutated after init. Module-level but init-once-immutable. Acceptable for the browser runtime context (single composition per page).

CDN URL versioning

Build-time __HYPERFRAMES_RUNTIME_CDN_URL__ substitution in tsup.config.ts pins the injected runtime to @hyperframes/core@<player-version>. verify-runtime-pin.mjs post-build check ensures all three bundles contain the versioned URL and reject the unversioned one. Good — prevents host/runtime drift.

Protocol negotiation

  • Legacy messages (no protocolVersion): accepted with 30fps fallback. Correct for backward compatibility.
  • Unknown majors (protocolVersion: 2+): rejected with diagnostic event (runtimeprotocolerror). Both sides (runtime via postRuntimeMessage, Player/Studio via dispatchEvent).
  • Additive capabilities within v1: tolerated — inspectRuntimeProtocol doesn't require exact capability matches. Good for forward compatibility.

Studio outgoing fps

One cosmetic note: Studio's postRuntimeControlMessage always sends fps=30 in the protocol metadata (default param), even when the composition is non-30fps. This doesn't affect behavior because (a) Studio sends timeSeconds for seeks, so fps isn't used for time resolution, and (b) the runtime checks protocolVersion, not fps, for version negotiation. But it's technically inaccurate metadata. Non-blocking — a future enhancement could thread _runtimeFps from the Player's incoming timeline messages into Studio's outgoing control messages.

Studio accept gates

All message listeners now call acceptStudioRuntimeMessage — consistent gate. useCompositionDimensions was refactored into extracted helpers with proper type narrowing (isStageSizeMessage, readPositiveDimensions). Clean.

Tests

Comprehensive — protocol round-trip, legacy fallback, unknown-major rejection, seek seconds resolution, Player seek format change, Studio message creation, runtime-pin verification. All branches covered.

LGTM — no blocking issues.

— Miga

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

Big substantive PR done right. Introducing a versioned protocol between Player and runtime — with capabilities, rational fps, and a legacy fallback — is a real load-bearing invariant, and the shape here is what I'd want:

  • protocol.ts as SSOT — single source for RUNTIME_PROTOCOL_VERSION, RUNTIME_PROTOCOL_CAPABILITIES, the rational-fps helpers, and inspectRuntimeProtocol. Both sides (core/bridge, player/runtime-message-handler, studio/lib/runtimeProtocol) import from it. No duplication of the protocol shape.
  • Legacy fallbackinspectRuntimeProtocol returns {status: "legacy", fps: legacyFps} when protocolVersion is absent, so unversioned inbound messages still work (with the 30fps assumption that matched the previous hardcoded FPS = 30 in the Player handler — no regression).
  • timeSeconds preferred over frame in seek dispatch — the runtime bridge's resolveSeekTimeSeconds reads timeSeconds first and falls back to frame / (messageFps ?? getCanonicalFps()). getCanonicalFps() reading from state.canonicalFps closes the "what if the incoming message doesn't declare fps" gap correctly (uses the runtime's own known fps, not a hardcoded 30).
  • Runtime pin via verify-runtime-pin.mjs — build-time check that Player bundles contain @hyperframes/core@${version}/… AND that they do NOT contain the unversioned URL. Belt-and-suspenders — the negative check (.includes("@hyperframes/core/dist/hyperframe.runtime.iife.js")) doesn't false-positive on the versioned form because @hyperframes/core@${version}/dist/… has the version wedged between core and /dist/. Good.
  • runtimeCdnUrlForVersion semver guard — rejects "latest", "next", bare tags. Tested. This is exactly the shape of "prove the guard actually catches what you claim" that I've seen go wrong in other PRs.

Rational-fps handling is careful — runtimeProtocolFpsFromNumber picks denominator 1 for integer fps and 1_000_000 otherwise, then GCD-reduces. Round-trips 24_000/1_001 and 30_000/1_001 accurately.

Concerns

  • packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts:421-425 — the settle listener (onMessage in the timeline-probe path) still uses raw data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline") and does NOT route through acceptStudioRuntimeMessage. So an unsupported-protocol inbound message still triggers trySettle(), but downstream consumers (this file's own useTimelineSyncCallbacks at :226 via acceptedRuntimeMessageFps, plus useTimelinePlayer and useCompositionDimensions) will reject the same message. Result: the settle succeeds ("iframe is alive") while every downstream reader rejects the data — user sees loaded-but-empty state. This may be intentional (settle as proof-of-life signal, separate from protocol validity), but the asymmetry is worth either (a) routing this listener through the accept guard too, so unsupported protocol short-circuits with a clean error path, or (b) adding a comment naming this as intentional proof-of-life gating.

Nits

  • packages/player/src/hyperframes-player.ts:98_runtimeFps = 30 initial value means every outbound _sendControl before the first timeline message declares fps=30 in its protocol metadata, regardless of the composition's actual fps. Since seeks now use explicit timeSeconds, the fps metadata is not load-bearing on seek — it's only informational. Not a bug, just worth naming as "metadata is best-effort until first timeline lands."
  • runtimeprotocolerror dispatch surface asymmetry — Player dispatches via callbacks.dispatchEvent (Player element), Studio dispatches via window.dispatchEvent (packages/studio/src/player/lib/runtimeProtocol.ts:44). Consumers subscribing to "runtimeprotocolerror" need to know which target to listen on. Not a bug — just worth documenting or unifying.

Questions

  • External embeds hardcoding the CDN URL — the runtime pin closes the version-drift gap when Player is the entry point (Player build ↔ runtime URL are locked together). What about users who wrote their own player harness against an older HF version and hardcoded @hyperframes/core@0.7.53/… in their site? When they upgrade Player to 0.7.55, the new Player expects protocol v1 metadata from the runtime, but their pinned 0.7.53 runtime won't send it. That falls into the "legacy" path (30fps default) — which is safe but silently divergent for non-30fps compositions. Just naming this as a known trade-off, not asking for changes.

What I didn't verify

  • Cross-browser CDN URL behavior (jsdelivr's fallback for missing versions).
  • The runtime.protocol.unsupported_protocol_version diagnostic path end-to-end (i.e., does anyone actually consume the "diagnostic" message shape in the outer Studio/Player?).

LGTM once the useTimelineSyncCallbacks settle-listener asymmetry is either resolved or documented as intentional.

Review by Rames D Jusso

@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch from 7869547 to e7dc050 Compare July 12, 2026 08:54
@jrusso1020 jrusso1020 force-pushed the 07-10-test_repo_verify_packed_consumers branch from 03acdb3 to 9762677 Compare July 12, 2026 08:54
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch from e7dc050 to b92a574 Compare July 12, 2026 09:11
@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Addressed the useTimelineSyncCallbacks settle-listener asymmetry on the current head. The readiness-only listener now mirrors the main handler’s protocol acceptance gate without dispatching a duplicate diagnostic, so an unsupported runtime cannot mark the iframe settled while downstream consumers reject it. Added a regression test proving an unsupported message leaves timelineReady=false and a subsequent accepted message settles normally. Focused Studio test and typecheck pass.

@jrusso1020 jrusso1020 force-pushed the 07-10-test_repo_verify_packed_consumers branch from 9762677 to 979e9d0 Compare July 13, 2026 15:52
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch from b92a574 to 8157da2 Compare July 13, 2026 15:52

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

Additive re-review at 8157da27. The restack correctly resolves the prior settle-listener concern: unsupported runtime messages are now rejected before Studio marks the iframe ready, with a focused regression test. The protocol SSOT, rational-fps conversion, and version-pinned injected runtime remain well structured.

blocker — packages/player/src/hyperframes-player.ts:327: the new Player sends a seek with only timeSeconds. A cross-origin composition that already embeds an older/unversioned runtime is intentionally accepted as legacy, but that runtime bridge reads only frame and defaults a missing frame to 0. Because same-origin window.__player.seek is unavailable in this case, every Player seek posts this message and the old runtime jumps to zero. This is a real supported surface: serialized compositions keep embedded runtime scripts by default. The legacy fallback is therefore only inbound, not bidirectional. Please carry a legacy frame alongside timeSeconds and pin a new-Player → legacy-runtime regression test.

Focused verification on this head: core protocol/bridge 39/39, Player protocol/handler/player 145/145, and Studio protocol/settle 14/14 pass; none exercises this direction of version skew.

— Magi / deepwork

Verdict: REQUEST CHANGES
Reasoning: Protocol-v1 peers are correct, but the changed seek payload breaks cross-origin playback against an accepted legacy runtime by deterministically seeking to frame zero.

@jrusso1020 jrusso1020 changed the base branch from 07-10-test_repo_verify_packed_consumers to graphite-base/2150 July 13, 2026 17:27
@jrusso1020 jrusso1020 force-pushed the 07-10-fix_player_version_runtime_protocol branch from 8157da2 to dcefdd9 Compare July 13, 2026 17:29
@jrusso1020 jrusso1020 changed the base branch from graphite-base/2150 to main July 13, 2026 17:29
@jrusso1020

Copy link
Copy Markdown
Collaborator Author

Valid

Addressed on the current head (dcefdd98c). Player seek messages now carry both protocol-v1 timeSeconds and the legacy frame fallback, so accepted cross-origin legacy runtimes no longer default seeks to frame zero. Added a focused new-Player → legacy-runtime regression test and updated the existing fallback assertions.

Validation: hyperframes-player.test.ts 125/125 and @hyperframes/player typecheck pass. The full remaining stack was restacked on merged #2149 and resubmitted.

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

Additive re-review at dcefdd98. The blocker is resolved: seek() now carries frame: Math.round(timeSeconds * _runtimeFps) alongside the protocol-v1 timeSeconds, so accepted cross-origin legacy runtimes no longer default every seek to frame zero, while current peers retain seconds-first precision. The focused regression test explicitly exercises the new-Player → legacy-runtime direction. I also verified the update is limited to this implementation and its test, and reran hyperframes-player.test.ts: 125/125 pass. All required checks on the updated head are green.\n\n— Magi / deepwork

jrusso1020 commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Merge activity

  • Jul 13, 6:07 PM UTC: A user started a stack merge that includes this pull request via Graphite.
  • Jul 13, 6:08 PM UTC: @jrusso1020 merged this pull request with Graphite.

@jrusso1020 jrusso1020 merged commit a364f69 into main Jul 13, 2026
47 checks passed
@jrusso1020 jrusso1020 deleted the 07-10-fix_player_version_runtime_protocol branch July 13, 2026 18:08
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.

4 participants