@mebius-io/web 0.4.9 → 0.5.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.
package/dist/index.d.cts CHANGED
@@ -123,6 +123,37 @@ interface PlaybackStats {
123
123
  /** Estimated end-to-end latency in milliseconds, if known. */
124
124
  latencyMs?: number;
125
125
  }
126
+ /** Options for {@link MebiusClient.createCaptions}. */
127
+ interface CaptionsOptions {
128
+ /**
129
+ * Which translation to read from each segment, e.g. `"id"`. Must match a
130
+ * `targetLangs` entry your backend passed to `captions/start` — the engine
131
+ * only ever sends the translations that session produced.
132
+ */
133
+ lang: string;
134
+ }
135
+ /**
136
+ * One caption segment, timed against a wall clock so it can be compared to
137
+ * {@link MebiusPlayer.currentEpochMs}.
138
+ */
139
+ interface CaptionSegment {
140
+ /** Stable id for this segment. A later revision replaces it in place. */
141
+ segmentId: string;
142
+ /** Revision counter. Only the highest-`rev` copy of a `segmentId` is kept. */
143
+ rev: number;
144
+ /** `"interim"` or `"final"`. Interim only arrives if the session enabled it. */
145
+ state: "interim" | "final";
146
+ /** Unix ms the audio was actually spoken. Compare against the playhead. */
147
+ epochMs: number;
148
+ /** How long the segment should stay on screen once due, in ms. */
149
+ durationMs: number;
150
+ /** Original transcript, in the source language. */
151
+ text: string;
152
+ /** The requested {@link CaptionsOptions.lang} translation, if produced yet. */
153
+ translation?: string;
154
+ /** Always `true`. Render a machine-generated indicator — never as a direct quote. */
155
+ machineGenerated: true;
156
+ }
126
157
  /** Canonical Mebius error codes surfaced to your app. */
127
158
  type MebiusErrorCode = "TOKEN_EXPIRED" | "PERMISSION_DENIED" | "CONNECTION_FAILED" | "NOT_CONNECTED" | "STREAM_NOT_FOUND" | "UNKNOWN";
128
159
 
@@ -167,6 +198,17 @@ type PlayerEventMap = {
167
198
  ended: void;
168
199
  stats: PlaybackStats;
169
200
  };
201
+ /** Event payloads emitted by {@link MebiusCaptions}. */
202
+ type CaptionsEventMap = {
203
+ /** A segment became due (its `epochMs` reached the playhead). Render it. */
204
+ segment: CaptionSegment;
205
+ /** A previously-shown segment aged out (playhead passed its window). Clear it. */
206
+ cleared: {
207
+ segmentId: string;
208
+ };
209
+ /** The SSE connection dropped. `EventSource` reconnects on its own. */
210
+ error: void;
211
+ };
170
212
  type Listener<T> = (payload: T) => void;
171
213
  /**
172
214
  * A tiny strongly-typed event emitter. `EventMap` maps each event name to its
@@ -216,6 +258,19 @@ declare class SignalingClient {
216
258
  deliveryUrl(path: string): string;
217
259
  /** Playlist URL for scale-mode playback. */
218
260
  scalePlaylistUrl(streamId: string): string;
261
+ /**
262
+ * Realtime captions SSE URL. Same play token as media — the engine's
263
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
264
+ * read its captions with zero extra credential.
265
+ *
266
+ * `/api/v1/live/...`, NOT `/live/...`: unlike {@link scalePlaylistUrl}, which
267
+ * hits the engine's bare `/live/*` media-edge catch-all, captions are mounted
268
+ * under the versioned control-API group (mebius-stream-engine
269
+ * internal/api/routes.go) with the play-token middleware, not the proxy.
270
+ * Copying the media-edge prefix here 401s every request — the catch-all
271
+ * doesn't recognise the path and never reaches the captions handler at all.
272
+ */
273
+ captionsUrl(streamId: string, lang: string): string;
219
274
  private pathFor;
220
275
  /**
221
276
  * Run the session offer/answer exchange. Throws a {@link MebiusError} with a
@@ -315,11 +370,64 @@ declare class MebiusPlayer extends TypedEmitter<PlayerEventMap> {
315
370
  * drags to zero also survives a later unmute at the element level.
316
371
  */
317
372
  setVolume(volume: number): void;
373
+ /**
374
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
375
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
376
+ *
377
+ * This is what {@link MebiusClient.createCaptions} compares against a
378
+ * segment's `epochMs` to know when it is due. Delegating to the transport
379
+ * rather than reading the element directly is what keeps this correct across
380
+ * a route failover: the player may switch from HLS to FLV mid-session, and
381
+ * the clock source has to follow.
382
+ */
383
+ currentEpochMs(): number | null;
318
384
  private attach;
319
385
  private startStats;
320
386
  private stopStats;
321
387
  }
322
388
 
389
+ /**
390
+ * Realtime captions — subscribes to the engine's caption SSE feed and emits
391
+ * segments on the video's own timeline, not on arrival order.
392
+ *
393
+ * The engine (mebius-stream-engine, `internal/caption`) does the ASR/translate
394
+ * work and starts sending segments the moment a sentence finishes — which is
395
+ * BEFORE the viewer's player, sitting behind the live edge by however much its
396
+ * transport buffers, has shown the matching frame. Rendering on arrival would
397
+ * show the caption before the streamer says it. So every segment is buffered by
398
+ * `epochMs` (when the audio was actually spoken, a wall clock) and released only
399
+ * once {@link MebiusPlayer.currentEpochMs} reaches it. See
400
+ * mebius-stream-engine/docs/INTEGRATION.md §6.
401
+ */
402
+
403
+ /**
404
+ * Subscribes to one stream's caption feed. Create with
405
+ * {@link MebiusClient.createCaptions}, `start()` it, and listen for `"segment"`
406
+ * / `"cleared"`.
407
+ *
408
+ * Starting the caption SESSION (`captions/start`, which spends money) is a
409
+ * separate, server-side call — this class only ever reads the feed a session
410
+ * already produces. That split is deliberate: the control endpoint needs an API
411
+ * key, which must never reach a browser.
412
+ */
413
+ declare class MebiusCaptions extends TypedEmitter<CaptionsEventMap> {
414
+ private readonly signaling;
415
+ private readonly player;
416
+ private readonly opts;
417
+ private es;
418
+ private timer;
419
+ private readonly pending;
420
+ private readonly shown;
421
+ /** @internal */
422
+ constructor(signaling: SignalingClient, player: MebiusPlayer, opts: CaptionsOptions);
423
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
424
+ start(streamId: string): void;
425
+ /** Close the connection and drop all buffered segments. */
426
+ stop(): void;
427
+ private onFrame;
428
+ private tick;
429
+ }
430
+
323
431
  /**
324
432
  * A live connection to Mebius. Obtain one from {@link Mebius.connect}, then
325
433
  * create broadcasters and players from it.
@@ -352,6 +460,19 @@ declare class MebiusClient extends TypedEmitter<ClientEventMap> {
352
460
  * black frame to a live audience, so it belongs here rather than in every app.
353
461
  */
354
462
  createMonitor(): MebiusPlayer;
463
+ /**
464
+ * Subscribe to a stream's realtime captions.
465
+ *
466
+ * Reads the same feed a session already produces — it does NOT start the
467
+ * caption session itself. `captions/start` spends money and requires an API
468
+ * key, so it belongs to your own backend (see
469
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
470
+ * on for a stream. This only ever consumes what that call turned on.
471
+ *
472
+ * `player` must be the one showing `streamId`: captions are timed against its
473
+ * playhead, and a mismatched player would compare against the wrong clock.
474
+ */
475
+ createCaptions(player: MebiusPlayer, options: CaptionsOptions): MebiusCaptions;
355
476
  /** Close the connection and release resources. */
356
477
  disconnect(reason?: string): void;
357
478
  private assertConnected;
@@ -377,4 +498,4 @@ declare const Mebius: {
377
498
  _reset(): void;
378
499
  };
379
500
 
380
- export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
501
+ export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type CaptionSegment, type CaptionsEventMap, type CaptionsOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusCaptions, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
package/dist/index.d.ts CHANGED
@@ -123,6 +123,37 @@ interface PlaybackStats {
123
123
  /** Estimated end-to-end latency in milliseconds, if known. */
124
124
  latencyMs?: number;
125
125
  }
126
+ /** Options for {@link MebiusClient.createCaptions}. */
127
+ interface CaptionsOptions {
128
+ /**
129
+ * Which translation to read from each segment, e.g. `"id"`. Must match a
130
+ * `targetLangs` entry your backend passed to `captions/start` — the engine
131
+ * only ever sends the translations that session produced.
132
+ */
133
+ lang: string;
134
+ }
135
+ /**
136
+ * One caption segment, timed against a wall clock so it can be compared to
137
+ * {@link MebiusPlayer.currentEpochMs}.
138
+ */
139
+ interface CaptionSegment {
140
+ /** Stable id for this segment. A later revision replaces it in place. */
141
+ segmentId: string;
142
+ /** Revision counter. Only the highest-`rev` copy of a `segmentId` is kept. */
143
+ rev: number;
144
+ /** `"interim"` or `"final"`. Interim only arrives if the session enabled it. */
145
+ state: "interim" | "final";
146
+ /** Unix ms the audio was actually spoken. Compare against the playhead. */
147
+ epochMs: number;
148
+ /** How long the segment should stay on screen once due, in ms. */
149
+ durationMs: number;
150
+ /** Original transcript, in the source language. */
151
+ text: string;
152
+ /** The requested {@link CaptionsOptions.lang} translation, if produced yet. */
153
+ translation?: string;
154
+ /** Always `true`. Render a machine-generated indicator — never as a direct quote. */
155
+ machineGenerated: true;
156
+ }
126
157
  /** Canonical Mebius error codes surfaced to your app. */
127
158
  type MebiusErrorCode = "TOKEN_EXPIRED" | "PERMISSION_DENIED" | "CONNECTION_FAILED" | "NOT_CONNECTED" | "STREAM_NOT_FOUND" | "UNKNOWN";
128
159
 
@@ -167,6 +198,17 @@ type PlayerEventMap = {
167
198
  ended: void;
168
199
  stats: PlaybackStats;
169
200
  };
201
+ /** Event payloads emitted by {@link MebiusCaptions}. */
202
+ type CaptionsEventMap = {
203
+ /** A segment became due (its `epochMs` reached the playhead). Render it. */
204
+ segment: CaptionSegment;
205
+ /** A previously-shown segment aged out (playhead passed its window). Clear it. */
206
+ cleared: {
207
+ segmentId: string;
208
+ };
209
+ /** The SSE connection dropped. `EventSource` reconnects on its own. */
210
+ error: void;
211
+ };
170
212
  type Listener<T> = (payload: T) => void;
171
213
  /**
172
214
  * A tiny strongly-typed event emitter. `EventMap` maps each event name to its
@@ -216,6 +258,19 @@ declare class SignalingClient {
216
258
  deliveryUrl(path: string): string;
217
259
  /** Playlist URL for scale-mode playback. */
218
260
  scalePlaylistUrl(streamId: string): string;
261
+ /**
262
+ * Realtime captions SSE URL. Same play token as media — the engine's
263
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
264
+ * read its captions with zero extra credential.
265
+ *
266
+ * `/api/v1/live/...`, NOT `/live/...`: unlike {@link scalePlaylistUrl}, which
267
+ * hits the engine's bare `/live/*` media-edge catch-all, captions are mounted
268
+ * under the versioned control-API group (mebius-stream-engine
269
+ * internal/api/routes.go) with the play-token middleware, not the proxy.
270
+ * Copying the media-edge prefix here 401s every request — the catch-all
271
+ * doesn't recognise the path and never reaches the captions handler at all.
272
+ */
273
+ captionsUrl(streamId: string, lang: string): string;
219
274
  private pathFor;
220
275
  /**
221
276
  * Run the session offer/answer exchange. Throws a {@link MebiusError} with a
@@ -315,11 +370,64 @@ declare class MebiusPlayer extends TypedEmitter<PlayerEventMap> {
315
370
  * drags to zero also survives a later unmute at the element level.
316
371
  */
317
372
  setVolume(volume: number): void;
373
+ /**
374
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
375
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
376
+ *
377
+ * This is what {@link MebiusClient.createCaptions} compares against a
378
+ * segment's `epochMs` to know when it is due. Delegating to the transport
379
+ * rather than reading the element directly is what keeps this correct across
380
+ * a route failover: the player may switch from HLS to FLV mid-session, and
381
+ * the clock source has to follow.
382
+ */
383
+ currentEpochMs(): number | null;
318
384
  private attach;
319
385
  private startStats;
320
386
  private stopStats;
321
387
  }
322
388
 
389
+ /**
390
+ * Realtime captions — subscribes to the engine's caption SSE feed and emits
391
+ * segments on the video's own timeline, not on arrival order.
392
+ *
393
+ * The engine (mebius-stream-engine, `internal/caption`) does the ASR/translate
394
+ * work and starts sending segments the moment a sentence finishes — which is
395
+ * BEFORE the viewer's player, sitting behind the live edge by however much its
396
+ * transport buffers, has shown the matching frame. Rendering on arrival would
397
+ * show the caption before the streamer says it. So every segment is buffered by
398
+ * `epochMs` (when the audio was actually spoken, a wall clock) and released only
399
+ * once {@link MebiusPlayer.currentEpochMs} reaches it. See
400
+ * mebius-stream-engine/docs/INTEGRATION.md §6.
401
+ */
402
+
403
+ /**
404
+ * Subscribes to one stream's caption feed. Create with
405
+ * {@link MebiusClient.createCaptions}, `start()` it, and listen for `"segment"`
406
+ * / `"cleared"`.
407
+ *
408
+ * Starting the caption SESSION (`captions/start`, which spends money) is a
409
+ * separate, server-side call — this class only ever reads the feed a session
410
+ * already produces. That split is deliberate: the control endpoint needs an API
411
+ * key, which must never reach a browser.
412
+ */
413
+ declare class MebiusCaptions extends TypedEmitter<CaptionsEventMap> {
414
+ private readonly signaling;
415
+ private readonly player;
416
+ private readonly opts;
417
+ private es;
418
+ private timer;
419
+ private readonly pending;
420
+ private readonly shown;
421
+ /** @internal */
422
+ constructor(signaling: SignalingClient, player: MebiusPlayer, opts: CaptionsOptions);
423
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
424
+ start(streamId: string): void;
425
+ /** Close the connection and drop all buffered segments. */
426
+ stop(): void;
427
+ private onFrame;
428
+ private tick;
429
+ }
430
+
323
431
  /**
324
432
  * A live connection to Mebius. Obtain one from {@link Mebius.connect}, then
325
433
  * create broadcasters and players from it.
@@ -352,6 +460,19 @@ declare class MebiusClient extends TypedEmitter<ClientEventMap> {
352
460
  * black frame to a live audience, so it belongs here rather than in every app.
353
461
  */
354
462
  createMonitor(): MebiusPlayer;
463
+ /**
464
+ * Subscribe to a stream's realtime captions.
465
+ *
466
+ * Reads the same feed a session already produces — it does NOT start the
467
+ * caption session itself. `captions/start` spends money and requires an API
468
+ * key, so it belongs to your own backend (see
469
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
470
+ * on for a stream. This only ever consumes what that call turned on.
471
+ *
472
+ * `player` must be the one showing `streamId`: captions are timed against its
473
+ * playhead, and a mismatched player would compare against the wrong clock.
474
+ */
475
+ createCaptions(player: MebiusPlayer, options: CaptionsOptions): MebiusCaptions;
355
476
  /** Close the connection and release resources. */
356
477
  disconnect(reason?: string): void;
357
478
  private assertConnected;
@@ -377,4 +498,4 @@ declare const Mebius: {
377
498
  _reset(): void;
378
499
  };
379
500
 
380
- export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
501
+ export { type BroadcastStats, type BroadcasterEventMap, type BroadcasterOptions, type CaptionSegment, type CaptionsEventMap, type CaptionsOptions, type ClientEventMap, Mebius, MebiusBroadcaster, MebiusCaptions, MebiusClient, type MebiusConnectOptions, type MebiusDelivery, MebiusError, type MebiusErrorCode, type MebiusInitOptions, MebiusPlayer, type MediaConstraint, type PlaybackMode, type PlaybackStats, type PlayerEventMap, type PlayerOptions, type ViewTarget, mebiusError };
@@ -23901,19 +23901,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
23901
23901
  isInterstitial(item) {
23902
23902
  return !!(item != null && item.event);
23903
23903
  }
23904
- retreiveMediaSource(assetId, toSegment) {
23904
+ retreiveMediaSource(assetId, toSegment2) {
23905
23905
  const player = this.getAssetPlayer(assetId);
23906
23906
  if (player) {
23907
- this.transferMediaFromPlayer(player, toSegment);
23907
+ this.transferMediaFromPlayer(player, toSegment2);
23908
23908
  }
23909
23909
  }
23910
- transferMediaFromPlayer(player, toSegment) {
23910
+ transferMediaFromPlayer(player, toSegment2) {
23911
23911
  const appendInPlace = player.interstitial.appendInPlace;
23912
23912
  const playerMedia = player.media;
23913
23913
  if (appendInPlace && playerMedia === this.primaryMedia) {
23914
23914
  this.bufferingAsset = null;
23915
- if (!toSegment || this.isInterstitial(toSegment) && !toSegment.event.appendInPlace) {
23916
- if (toSegment && playerMedia) {
23915
+ if (!toSegment2 || this.isInterstitial(toSegment2) && !toSegment2.event.appendInPlace) {
23916
+ if (toSegment2 && playerMedia) {
23917
23917
  this.detachedData = {
23918
23918
  media: playerMedia
23919
23919
  };
@@ -23923,7 +23923,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
23923
23923
  const attachMediaSourceData = player.transferMedia();
23924
23924
  this.log(`transfer MediaSource from ${player} ${stringify(attachMediaSourceData)}`);
23925
23925
  this.detachedData = attachMediaSourceData;
23926
- } else if (toSegment && playerMedia) {
23926
+ } else if (toSegment2 && playerMedia) {
23927
23927
  this.shouldPlay || (this.shouldPlay = !playerMedia.paused);
23928
23928
  }
23929
23929
  }
@@ -24942,13 +24942,13 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
24942
24942
  });
24943
24943
  return player;
24944
24944
  }
24945
- clearInterstitial(interstitial, toSegment) {
24946
- this.clearAssetPlayers(interstitial, toSegment);
24945
+ clearInterstitial(interstitial, toSegment2) {
24946
+ this.clearAssetPlayers(interstitial, toSegment2);
24947
24947
  interstitial.reset();
24948
24948
  }
24949
- clearAssetPlayers(interstitial, toSegment) {
24949
+ clearAssetPlayers(interstitial, toSegment2) {
24950
24950
  interstitial.assetList.forEach((asset) => {
24951
- this.clearAssetPlayer(asset.identifier, toSegment);
24951
+ this.clearAssetPlayer(asset.identifier, toSegment2);
24952
24952
  });
24953
24953
  }
24954
24954
  resetAssetPlayer(assetId) {
@@ -24960,12 +24960,12 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
24960
24960
  player.resetDetails();
24961
24961
  }
24962
24962
  }
24963
- clearAssetPlayer(assetId, toSegment) {
24963
+ clearAssetPlayer(assetId, toSegment2) {
24964
24964
  const playerIndex = this.getAssetPlayerQueueIndex(assetId);
24965
24965
  if (playerIndex !== -1) {
24966
24966
  const player = this.playerQueue[playerIndex];
24967
- this.log(`clear ${player} toSegment: ${toSegment ? segmentToString(toSegment) : toSegment}`);
24968
- this.transferMediaFromPlayer(player, toSegment);
24967
+ this.log(`clear ${player} toSegment: ${toSegment2 ? segmentToString(toSegment2) : toSegment2}`);
24968
+ this.transferMediaFromPlayer(player, toSegment2);
24969
24969
  this.playerQueue.splice(playerIndex, 1);
24970
24970
  player.destroy();
24971
24971
  }
@@ -42609,6 +42609,7 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
42609
42609
  __export(index_exports, {
42610
42610
  Mebius: () => Mebius,
42611
42611
  MebiusBroadcaster: () => MebiusBroadcaster,
42612
+ MebiusCaptions: () => MebiusCaptions,
42612
42613
  MebiusClient: () => MebiusClient,
42613
42614
  MebiusError: () => MebiusError,
42614
42615
  MebiusPlayer: () => MebiusPlayer,
@@ -43015,6 +43016,22 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43015
43016
  framesPerSecond: 0
43016
43017
  };
43017
43018
  }
43019
+ /**
43020
+ * hls.js exposes `playingDate` straight from the segment the element is
43021
+ * currently rendering, derived from the playlist's `EXT-X-PROGRAM-DATE-TIME`
43022
+ * (MediaMTX writes it). Safari's native player has no such property, but
43023
+ * `getStartDate()` (the wall-clock time of the playlist's first segment) plus
43024
+ * elapsed `currentTime` is the same clock by construction.
43025
+ */
43026
+ playheadEpochMs() {
43027
+ if (this.hls) return this.hls.playingDate?.getTime() ?? null;
43028
+ const video = this.video;
43029
+ if (video?.getStartDate) {
43030
+ const start = video.getStartDate().getTime();
43031
+ if (Number.isFinite(start)) return start + video.currentTime * 1e3;
43032
+ }
43033
+ return null;
43034
+ }
43018
43035
  };
43019
43036
 
43020
43037
  // src/internal/balanced-view-transport.ts
@@ -43386,6 +43403,82 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43386
43403
  return c;
43387
43404
  }
43388
43405
 
43406
+ // src/captions.ts
43407
+ var TICK_MS = 100;
43408
+ var STALE_MS = 5e3;
43409
+ var MebiusCaptions = class extends TypedEmitter {
43410
+ /** @internal */
43411
+ constructor(signaling, player, opts) {
43412
+ super();
43413
+ this.signaling = signaling;
43414
+ this.player = player;
43415
+ this.opts = opts;
43416
+ this.es = null;
43417
+ this.timer = null;
43418
+ this.pending = /* @__PURE__ */ new Map();
43419
+ this.shown = /* @__PURE__ */ new Set();
43420
+ }
43421
+ /** Open the SSE connection and begin emitting segments for `streamId`. */
43422
+ start(streamId) {
43423
+ if (this.es) return;
43424
+ const url = this.signaling.captionsUrl(streamId, this.opts.lang);
43425
+ const es = new EventSource(url);
43426
+ es.onmessage = (ev) => this.onFrame(ev);
43427
+ es.onerror = () => this.emit("error", void 0);
43428
+ this.es = es;
43429
+ this.timer = setInterval(() => this.tick(), TICK_MS);
43430
+ }
43431
+ /** Close the connection and drop all buffered segments. */
43432
+ stop() {
43433
+ this.es?.close();
43434
+ this.es = null;
43435
+ if (this.timer) clearInterval(this.timer);
43436
+ this.timer = null;
43437
+ this.pending.clear();
43438
+ this.shown.clear();
43439
+ }
43440
+ onFrame(ev) {
43441
+ let frame;
43442
+ try {
43443
+ frame = JSON.parse(ev.data);
43444
+ } catch {
43445
+ return;
43446
+ }
43447
+ if (frame.type !== "caption" || !frame.segmentId) return;
43448
+ const prev = this.pending.get(frame.segmentId);
43449
+ if (prev && (frame.rev ?? 0) < (prev.rev ?? 0)) return;
43450
+ this.pending.set(frame.segmentId, frame);
43451
+ }
43452
+ tick() {
43453
+ const now2 = this.player.currentEpochMs();
43454
+ if (now2 == null) return;
43455
+ for (const [id, frame] of this.pending) {
43456
+ const due = frame.epochMs ?? 0;
43457
+ if (due > now2) continue;
43458
+ if (due < now2 - STALE_MS) {
43459
+ this.pending.delete(id);
43460
+ if (this.shown.delete(id)) this.emit("cleared", { segmentId: id });
43461
+ continue;
43462
+ }
43463
+ this.shown.add(id);
43464
+ this.emit("segment", toSegment(id, frame, this.opts.lang));
43465
+ if (frame.state === "final") this.pending.delete(id);
43466
+ }
43467
+ }
43468
+ };
43469
+ function toSegment(segmentId, frame, lang) {
43470
+ return {
43471
+ segmentId,
43472
+ rev: frame.rev ?? 0,
43473
+ state: frame.state === "final" ? "final" : "interim",
43474
+ epochMs: frame.epochMs ?? 0,
43475
+ durationMs: frame.durationMs ?? 0,
43476
+ text: frame.text ?? "",
43477
+ translation: frame.translations?.[lang],
43478
+ machineGenerated: true
43479
+ };
43480
+ }
43481
+
43389
43482
  // src/internal/freeze-clock.ts
43390
43483
  var FreezeClock = class {
43391
43484
  constructor(now2 = Date.now) {
@@ -43555,6 +43648,19 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43555
43648
  this.video.volume = v;
43556
43649
  this.video.muted = v === 0;
43557
43650
  }
43651
+ /**
43652
+ * Wall-clock time (Unix ms) currently on screen, or `null` when the active
43653
+ * route cannot produce one (HTTP-FLV, WHEP — see {@link ViewTransport}).
43654
+ *
43655
+ * This is what {@link MebiusClient.createCaptions} compares against a
43656
+ * segment's `epochMs` to know when it is due. Delegating to the transport
43657
+ * rather than reading the element directly is what keeps this correct across
43658
+ * a route failover: the player may switch from HLS to FLV mid-session, and
43659
+ * the clock source has to follow.
43660
+ */
43661
+ currentEpochMs() {
43662
+ return this.transport?.playheadEpochMs?.() ?? null;
43663
+ }
43558
43664
  attach(transport) {
43559
43665
  transport.onEnded(() => {
43560
43666
  if (this.transport !== transport) return;
@@ -43649,6 +43755,23 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43649
43755
  scalePlaylistUrl(streamId) {
43650
43756
  return this.withToken(`${this.base()}/live/${encodeURIComponent(streamId)}/index.m3u8`);
43651
43757
  }
43758
+ /**
43759
+ * Realtime captions SSE URL. Same play token as media — the engine's
43760
+ * `PlayVerifier` gates both, so a viewer who can watch the stream can already
43761
+ * read its captions with zero extra credential.
43762
+ *
43763
+ * `/api/v1/live/...`, NOT `/live/...`: unlike {@link scalePlaylistUrl}, which
43764
+ * hits the engine's bare `/live/*` media-edge catch-all, captions are mounted
43765
+ * under the versioned control-API group (mebius-stream-engine
43766
+ * internal/api/routes.go) with the play-token middleware, not the proxy.
43767
+ * Copying the media-edge prefix here 401s every request — the catch-all
43768
+ * doesn't recognise the path and never reaches the captions handler at all.
43769
+ */
43770
+ captionsUrl(streamId, lang) {
43771
+ return this.withToken(
43772
+ `${this.base()}/api/v1/live/${encodeURIComponent(streamId)}/captions?lang=${encodeURIComponent(lang)}`
43773
+ );
43774
+ }
43652
43775
  // Maps a neutral session kind to the concrete signaling path segment. This
43653
43776
  // mapping (publish -> WHIP, view -> WHEP) lives ONLY in this method body, so
43654
43777
  // the protocol names never appear in any exported type signature.
@@ -43772,6 +43895,22 @@ Schedule: ${scheduleItems.map((seg) => segmentToString(seg))} pos: ${this.timeli
43772
43895
  this.assertConnected();
43773
43896
  return new MebiusPlayer(this.signaling, { mode: "low-latency" }, this.deliveries, this.telemetry, this.userId);
43774
43897
  }
43898
+ /**
43899
+ * Subscribe to a stream's realtime captions.
43900
+ *
43901
+ * Reads the same feed a session already produces — it does NOT start the
43902
+ * caption session itself. `captions/start` spends money and requires an API
43903
+ * key, so it belongs to your own backend (see
43904
+ * mebius-stream-engine/docs/API.md §5.1), called once when you want captions
43905
+ * on for a stream. This only ever consumes what that call turned on.
43906
+ *
43907
+ * `player` must be the one showing `streamId`: captions are timed against its
43908
+ * playhead, and a mismatched player would compare against the wrong clock.
43909
+ */
43910
+ createCaptions(player, options) {
43911
+ this.assertConnected();
43912
+ return new MebiusCaptions(this.signaling, player, options);
43913
+ }
43775
43914
  /** Close the connection and release resources. */
43776
43915
  disconnect(reason) {
43777
43916
  if (this.expiryTimer) clearTimeout(this.expiryTimer);