@hyperframes/player 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,29 @@
1
+ /**
2
+ * Parent-frame media proxy subsystem.
3
+ *
4
+ * Maintains mirror copies of the iframe's timed `<audio>`/`<video>` elements
5
+ * in the parent frame so that mobile browsers — which gate `el.play()` on user
6
+ * activation in the *same* frame — can still produce audible output via proxies
7
+ * the parent controls directly.
8
+ *
9
+ * See the class-level JSDoc on `HyperframesPlayer` for the full ownership model.
10
+ */
11
+ interface ProxyEntry {
12
+ el: HTMLMediaElement;
13
+ start: number;
14
+ duration: number;
15
+ /**
16
+ * Count of consecutive steady-state samples in which the proxy's
17
+ * `currentTime` was found drifted beyond `MIRROR_DRIFT_THRESHOLD_SECONDS`.
18
+ * Reset on every in-threshold sample. A write is only issued once this
19
+ * reaches `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES`, absorbing
20
+ * single-sample jitter without thrashing.
21
+ */
22
+ driftSamples: number;
23
+ }
24
+
25
+ type ShaderLoadingMode = "composition" | "player" | "none";
26
+
1
27
  interface ControlsCallbacks {
2
28
  onPlay: () => void;
3
29
  onPause: () => void;
@@ -15,7 +41,6 @@ interface ControlsOptions {
15
41
  declare function formatSpeed(speed: number): string;
16
42
  declare function formatTime(seconds: number): string;
17
43
 
18
- type ShaderLoadingMode = "composition" | "player" | "none";
19
44
  declare class HyperframesPlayer extends HTMLElement {
20
45
  static get observedAttributes(): string[];
21
46
  private shadow;
@@ -24,124 +49,31 @@ declare class HyperframesPlayer extends HTMLElement {
24
49
  private posterEl;
25
50
  private controlsApi;
26
51
  private resizeObserver;
27
- private shaderLoaderEl;
28
- private shaderLoaderFillEl;
29
- private shaderLoaderTitleEl;
30
- private shaderLoaderDetailEl;
31
- private shaderLoaderTransitionValueEl;
32
- private shaderLoaderFrameLabelEl;
33
- private shaderLoaderFrameValueEl;
34
- private shaderLoaderFrameRowEl;
35
- private shaderLoaderHideTimeout;
52
+ private shaderLoader;
53
+ private probe;
36
54
  private _ready;
37
- private _duration;
38
55
  private _currentTime;
56
+ private _duration;
39
57
  private _paused;
58
+ private _lastUpdateMs;
40
59
  private _volume;
41
60
  private _compositionWidth;
42
61
  private _compositionHeight;
43
- private _probeInterval;
44
- private _lastUpdateMs;
45
62
  private _directTimelineAdapter;
46
- private _directTimelineRaf;
47
- /**
48
- * Parent-frame audio/video proxies, preloaded mirror copies of the iframe's
49
- * timed media. They exist as a fallback for environments that block iframe
50
- * `.play()` — mobile browsers require the user gesture to originate in the
51
- * same frame as the media element, and postMessage doesn't transfer user
52
- * activation (User Activation v2). The runtime inside the iframe signals
53
- * `media-autoplay-blocked` the first time a play() attempt rejects with
54
- * `NotAllowedError`; receiving that message flips `_audioOwner` to `parent`
55
- * and these proxies start driving audible output while the iframe keeps
56
- * advancing timed media silently for frame-accurate state.
57
- *
58
- * Preloading at iframe-load time (rather than lazily on promotion) keeps
59
- * the audible audio cut-in tight when the promotion fires mid-playback.
60
- */
61
- private _parentMedia;
62
- /**
63
- * Who owns audible playback right now.
64
- *
65
- * - `runtime` (default): the iframe's runtime drives timed media; parent
66
- * proxies stay paused and silent. This is the correct path on desktop,
67
- * in same-frame embeds, and anywhere the iframe has user activation.
68
- * - `parent`: parent-frame proxies drive audible output; the iframe keeps
69
- * syncing timed media but at `muted = true` (orthogonal to author/user
70
- * volume settings). Entered only in response to an actual autoplay
71
- * rejection from the runtime — we don't guess device class.
72
- *
73
- * The transition is one-way per session; once autoplay is known to be
74
- * gated, there's no benefit to attempting the iframe path again.
75
- */
76
- private _audioOwner;
77
- /**
78
- * Watches the iframe document for sub-composition media added after
79
- * initial setup. Disconnected on iframe reload (fresh iframe = fresh
80
- * observer against the new document).
81
- */
82
- private _mediaObserver?;
83
- /**
84
- * One-shot latch for `playbackerror`. Without it, under parent ownership
85
- * where the parent frame itself lacks activation, every paused→playing
86
- * transition in the iframe state loop would re-fire `play()` (and its
87
- * rejection) on each proxy — spamming host subscribers through a whole
88
- * playback session. Mirrors the `mediaAutoplayBlockedPosted` latch on the
89
- * runtime side. Cleared on `_onIframeLoad` alongside the owner reset, so
90
- * a fresh composition gets a fresh shot at surfacing the error.
91
- */
92
- private _playbackErrorPosted;
63
+ private _directTimelineClock;
64
+ private _media;
93
65
  constructor();
94
66
  connectedCallback(): void;
95
67
  disconnectedCallback(): void;
96
68
  attributeChangedCallback(name: string, _old: string | null, val: string | null): void;
97
69
  /**
98
- * Access the inner `<iframe>` element rendering the composition.
99
- *
100
- * Use this when integrating the player with editors, recorders, or
101
- * timeline tools (e.g. `@hyperframes/studio`) that need to inspect
102
- * the composition's DOM or read its `__player` / `__timelines`
103
- * runtime objects.
104
- *
105
- * **Common pitfall:** the iframe lives inside the player's Shadow DOM.
106
- * Passing the `<hyperframes-player>` element itself to code that expects
107
- * an `<iframe>` will silently break — `.contentWindow` returns `null`.
108
- * Always extract `iframeElement` first:
109
- *
110
- * ```ts
111
- * // ❌ Wrong — element ref doesn't expose contentWindow
112
- * iframeRef.current = playerRef.current;
113
- *
114
- * // ✓ Right — bridge the actual iframe
115
- * iframeRef.current = playerRef.current.iframeElement;
116
- * ```
70
+ * The inner `<iframe>` rendering the composition. Use this when integrating
71
+ * with tools that need `contentWindow` — `.contentWindow` on the
72
+ * `<hyperframes-player>` element itself returns `null` (Shadow DOM).
117
73
  */
118
74
  get iframeElement(): HTMLIFrameElement;
119
75
  play(): void;
120
76
  pause(): void;
121
- /**
122
- * Move playback to `timeInSeconds`.
123
- *
124
- * Two transports, with different precision semantics — read this before
125
- * writing assertions against `seek` from outside the player:
126
- *
127
- * - **Same-origin (sync) path** — when the runtime's `window.__player.seek`
128
- * is reachable, we call it directly. `timeInSeconds` is forwarded
129
- * *verbatim* (no rounding), so a same-origin scrub of `seek(7.3333)`
130
- * lands the runtime at `7.3333 s` — sub-frame precision relative to
131
- * `DEFAULT_FPS` (30). Studio scrub UIs that need fractional-frame
132
- * alignment (e.g. waveform scrubbing on long-duration audio) get the
133
- * exact requested time.
134
- * - **Cross-origin (postMessage) path** — when same-origin access throws
135
- * or `__player.seek` is missing, we fall back to the postMessage bridge.
136
- * The wire protocol carries integer frames (`frame: Math.round(t × FPS)`),
137
- * so cross-origin embeds are *frame-quantized* and `seek(7.3333)` lands
138
- * at `Math.round(7.3333 × 30) / 30 ≈ 7.3333…` (same value here, but for
139
- * most fractional inputs you'll see a snap to the nearest 1/30 s).
140
- *
141
- * `this._currentTime` always reflects the *requested* `timeInSeconds`
142
- * regardless of transport, so the controls UI shows the un-quantized value
143
- * either way; the asymmetry only affects what the runtime actually paints.
144
- */
145
77
  seek(timeInSeconds: number): void;
146
78
  get currentTime(): number;
147
79
  set currentTime(t: number);
@@ -161,172 +93,24 @@ declare class HyperframesPlayer extends HTMLElement {
161
93
  get loop(): boolean;
162
94
  set loop(l: boolean);
163
95
  private _sendControl;
164
- private _shaderCaptureScaleParam;
165
- private _shaderLoadingMode;
166
- private _prepareSrc;
167
- private _prepareSrcdoc;
168
96
  private _reloadShaderOptions;
169
- private _createShaderLoader;
170
- private _showShaderLoader;
171
- private _hideShaderLoader;
172
- private _scheduleShaderLoaderHideCleanup;
173
- private _resetShaderLoader;
174
- private _updateShaderLoader;
175
- /**
176
- * Reach into the runtime's `window.__player.seek` directly, skipping the
177
- * postMessage hop. Same-origin only — cross-origin embeds throw a
178
- * `SecurityError` on `contentWindow` property access, which we catch and
179
- * report as a no-op so the caller can transparently fall back to the
180
- * postMessage bridge. Returns `true` only when the runtime accepted the
181
- * call (`__player.seek` exists, is callable, and didn't throw).
182
- *
183
- * Studio has used this access path privately via `iframe.contentWindow.__player`
184
- * (see `useTimelinePlayer.ts`); this helper just formalizes the same
185
- * detection inside the player so external scrub UIs get the same
186
- * single-task latency. The runtime-side `seek` is the same wrapped
187
- * function the postMessage handler calls (`installRuntimeControlBridge`
188
- * routes through `player.seek`), so `markExplicitSeek()` and downstream
189
- * runtime state stay identical between the two paths.
190
- */
191
97
  private _trySyncSeek;
98
+ private _withDirectTimeline;
192
99
  private _tryDirectTimelineSeek;
193
100
  private _tryDirectTimelinePlay;
194
101
  private _tryDirectTimelinePause;
195
- private _startDirectTimelineClock;
196
- private _stopDirectTimelineClock;
197
- private _resolveDirectTimelineAdapter;
198
- private _resolveDirectTimelineAdapterFromWindow;
199
- private _hasRuntimeBridge;
200
- private _resolvePlaybackDurationAdapter;
201
- private _isControlsClick;
202
102
  private _onMessage;
203
- private _runtimeInjected;
103
+ private _onProbeReady;
104
+ private _rescale;
204
105
  private _onIframeLoad;
205
- /** Inject the HyperFrames runtime into the iframe if not already present. */
206
- private _injectRuntime;
207
- private _updateScale;
208
106
  private _setupControls;
209
- private _setupPoster;
210
- private _playParentMedia;
211
- private _reportPlaybackError;
212
- private _pauseParentMedia;
213
- /**
214
- * Drag parent-proxy `currentTime` onto the iframe's timeline. Called on
215
- * every runtime state message under parent ownership. Threshold is 50 ms
216
- * — ITU-R BT.1359 puts A/V offset perceptibility at roughly ±45 ms, so
217
- * anything looser risks audible lip-sync drift on talking-head content
218
- * (a core use case). The re-seek cost at this tightness is a handful of
219
- * extra `currentTime` writes per second; the media element's own buffer
220
- * smooths them out without visible rebuffer on the mirror path.
221
- */
222
- private static readonly MIRROR_DRIFT_THRESHOLD_SECONDS;
223
- /**
224
- * How many *consecutive* over-threshold steady-state samples we wait for
225
- * before issuing a `currentTime` write. A value of 2 means a single
226
- * spike (one slow bridge tick, one tab-throttled rAF batch, one GC pause)
227
- * is absorbed without a seek; sustained drift still corrects on the very
228
- * next tick after the threshold is crossed twice in a row.
229
- *
230
- * **Coupling with the timeline-control bridge** — read before changing:
231
- * worst_case_correction_latency_ms
232
- * ≈ MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES × bridgeMaxPostIntervalMs
233
- *
234
- * `bridgeMaxPostIntervalMs` (currently `80`) lives at
235
- * `packages/core/src/runtime/state.ts` (field on `RuntimeState`). At
236
- * today's values, worst-case is `2 × 80 ms = 160 ms` — still well under
237
- * the human shot-change tolerance for A/V re-sync. If you bump bridge
238
- * cadence (raising `bridgeMaxPostIntervalMs`) you may need to drop this
239
- * constant to `1` to keep the product under ~150 ms; if you tighten
240
- * cadence you can raise this to absorb more jitter without perceptual
241
- * cost. There is a back-reference in `state.ts` next to
242
- * `bridgeMaxPostIntervalMs` so a change to either side surfaces the
243
- * coupling.
244
- */
245
- private static readonly MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES;
246
- /**
247
- * Mirror parent-proxy `currentTime` to the iframe timeline. Defaults to
248
- * the *coalesced* path: a single over-threshold sample is treated as
249
- * jitter and merely increments a per-proxy counter; the actual seek only
250
- * fires once `MIRROR_REQUIRED_CONSECUTIVE_DRIFT_SAMPLES` consecutive
251
- * samples agree. Pass `{ force: true }` for one-shot alignment moments
252
- * (audio-ownership promotion, brand-new proxy initialization) where we
253
- * cannot tolerate even ~80 ms of misaligned audible playback.
254
- *
255
- * The counter is also reset on any in-threshold sample and on any
256
- * out-of-range timeline position, so a proxy that drops back into a
257
- * scene later starts fresh rather than carrying stale samples from the
258
- * last time it was active.
259
- */
260
- private _mirrorParentMediaTime;
261
- /**
262
- * Take ownership of audible playback. Fired in response to the runtime's
263
- * `media-autoplay-blocked` signal — the iframe has lost the autoplay lottery
264
- * and will never produce audio without a fresh gesture inside itself.
265
- *
266
- * Effects, in order:
267
- * 1. Ask the runtime to mute its own media output via the bridge. The
268
- * runtime then keeps advancing timed media for frame-accurate state
269
- * but produces no sound of its own, freeing us to be the single
270
- * audible source without racing a volume-reassert loop.
271
- * 2. Align every parent proxy's currentTime to the iframe's timeline so
272
- * the cut-over is imperceptible.
273
- * 3. If the player is currently playing, start the proxies.
274
- *
275
- * Idempotent: repeat calls are a no-op.
276
- */
277
- private _promoteToParentProxy;
278
- /**
279
- * Create a parent-frame media element, configure it, and start preloading.
280
- * Returns the newly-created proxy entry, or `null` if one already exists for
281
- * this src (dedup) — callers that need to act on the new element should
282
- * branch on the return value rather than inferring via `_parentMedia.length`.
283
- */
284
- private _createParentMedia;
285
- /**
286
- * Set up a single parent-frame audio from an explicit URL (via `audio-src`).
287
- * Convenience for the common single-narration case — starts preloading
288
- * immediately without waiting for the iframe to load.
289
- */
290
- private _setupParentAudioFromUrl;
291
- /**
292
- * Mirror every timed iframe media element (`audio[data-start]`,
293
- * `video[data-start]`) into a parent-frame proxy. The proxies preload at
294
- * iframe-ready time so the cut-over to parent ownership — should the
295
- * runtime's autoplay attempt later reject — is instantaneous.
296
- *
297
- * Under runtime ownership (the default) these proxies stay paused and
298
- * inert; the iframe is the audible source. Ownership flips only in
299
- * response to a real `media-autoplay-blocked` message from the runtime.
300
- *
301
- * Also installs a MutationObserver so that media added to the iframe
302
- * *after* the initial scan (sub-composition activation is the common
303
- * case) gets a proxy on the fly. Without this, under parent ownership
304
- * late-added `<audio data-start>` would be silenced by the runtime
305
- * (`outputMuted` sticks per-tick) but have no parent-frame counterpart
306
- * to play — a silent hole in the audio track.
307
- */
308
- private _setupParentMedia;
309
- /**
310
- * Create a parent-frame proxy mirroring a single iframe media element.
311
- * Extracted so both the initial scan and the MutationObserver path use
312
- * identical URL-resolution and attribute parsing.
313
- */
314
- private _adoptIframeMedia;
315
- /**
316
- * Watch the iframe document for subtree additions of timed media so
317
- * sub-composition activation (late-attached `<audio data-start>`) grows
318
- * the parent-proxy set automatically. Disconnected on iframe reload via
319
- * `_teardownMediaObserver`.
320
- */
321
- private _observeDynamicMedia;
322
- private _teardownMediaObserver;
323
- /**
324
- * Inverse of `_adoptIframeMedia`: drop the parent proxy mirroring a removed
325
- * iframe media element. Resolves the src identically so matching is exact,
326
- * then pauses, clears the src (frees the decoder), and splices it out.
327
- */
328
- private _detachIframeMedia;
329
- private _hidePoster;
107
+ get _audioOwner(): "parent" | "runtime";
108
+ get _parentMedia(): ProxyEntry[];
109
+ _mirrorParentMediaTime(t: number, opts?: {
110
+ force?: boolean;
111
+ }): void;
112
+ _promoteToParentProxy(): void;
113
+ _observeDynamicMedia(doc: Document): void;
330
114
  }
331
115
 
332
116
  export { type ControlsCallbacks, type ControlsOptions, HyperframesPlayer, SPEED_PRESETS, type ShaderLoadingMode, formatSpeed, formatTime };