@livepeer-frameworks/player-core 0.0.3 → 0.1.0

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.
Files changed (58) hide show
  1. package/README.md +78 -0
  2. package/dist/cjs/index.js +792 -146
  3. package/dist/cjs/index.js.map +1 -1
  4. package/dist/esm/index.js +792 -146
  5. package/dist/esm/index.js.map +1 -1
  6. package/dist/player.css +3 -331
  7. package/dist/types/core/GatewayClient.d.ts +3 -4
  8. package/dist/types/core/InteractionController.d.ts +12 -0
  9. package/dist/types/core/MetaTrackManager.d.ts +1 -1
  10. package/dist/types/core/PlayerController.d.ts +18 -2
  11. package/dist/types/core/PlayerInterface.d.ts +10 -0
  12. package/dist/types/core/SeekingUtils.d.ts +3 -1
  13. package/dist/types/core/StreamStateClient.d.ts +1 -1
  14. package/dist/types/players/HlsJsPlayer.d.ts +8 -0
  15. package/dist/types/players/MewsWsPlayer/index.d.ts +1 -1
  16. package/dist/types/players/VideoJsPlayer.d.ts +12 -4
  17. package/dist/types/players/WebCodecsPlayer/SyncController.d.ts +1 -1
  18. package/dist/types/players/WebCodecsPlayer/index.d.ts +11 -0
  19. package/dist/types/players/WebCodecsPlayer/types.d.ts +25 -3
  20. package/dist/types/players/WebCodecsPlayer/worker/types.d.ts +20 -2
  21. package/dist/types/types.d.ts +32 -1
  22. package/dist/types/vanilla/FrameWorksPlayer.d.ts +5 -5
  23. package/dist/types/vanilla/index.d.ts +3 -3
  24. package/dist/workers/decoder.worker.js +183 -6
  25. package/dist/workers/decoder.worker.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/core/ABRController.ts +1 -1
  28. package/src/core/CodecUtils.ts +1 -1
  29. package/src/core/GatewayClient.ts +8 -10
  30. package/src/core/LiveDurationProxy.ts +0 -1
  31. package/src/core/MetaTrackManager.ts +1 -1
  32. package/src/core/PlayerController.ts +232 -26
  33. package/src/core/PlayerInterface.ts +6 -0
  34. package/src/core/PlayerManager.ts +49 -0
  35. package/src/core/StreamStateClient.ts +3 -3
  36. package/src/core/SubtitleManager.ts +1 -1
  37. package/src/core/TelemetryReporter.ts +1 -1
  38. package/src/core/TimerManager.ts +1 -1
  39. package/src/core/scorer.ts +8 -4
  40. package/src/players/DashJsPlayer.ts +23 -11
  41. package/src/players/HlsJsPlayer.ts +29 -5
  42. package/src/players/MewsWsPlayer/SourceBufferManager.ts +3 -3
  43. package/src/players/MewsWsPlayer/WebSocketManager.ts +0 -1
  44. package/src/players/MewsWsPlayer/index.ts +7 -5
  45. package/src/players/MistPlayer.ts +1 -1
  46. package/src/players/MistWebRTCPlayer/index.ts +1 -1
  47. package/src/players/NativePlayer.ts +2 -2
  48. package/src/players/VideoJsPlayer.ts +33 -31
  49. package/src/players/WebCodecsPlayer/SyncController.ts +1 -2
  50. package/src/players/WebCodecsPlayer/WebSocketController.ts +1 -1
  51. package/src/players/WebCodecsPlayer/index.ts +25 -7
  52. package/src/players/WebCodecsPlayer/types.ts +31 -3
  53. package/src/players/WebCodecsPlayer/worker/decoder.worker.ts +20 -13
  54. package/src/players/WebCodecsPlayer/worker/types.ts +4 -0
  55. package/src/styles/player.css +0 -314
  56. package/src/types.ts +43 -1
  57. package/src/vanilla/FrameWorksPlayer.ts +5 -5
  58. package/src/vanilla/index.ts +3 -3
@@ -296,16 +296,17 @@ export class DashJsPlayerImpl extends BasePlayer {
296
296
  });
297
297
 
298
298
  // Configure dashjs v5 streaming settings BEFORE initialization
299
+ // AGGRESSIVE settings for fastest startup and low latency
299
300
  this.dashPlayer.updateSettings({
300
301
  streaming: {
301
- // Buffer settings
302
+ // AGGRESSIVE: Minimal buffers for fastest startup
302
303
  buffer: {
303
304
  fastSwitchEnabled: true,
304
- stableBufferTime: 16,
305
- bufferTimeAtTopQuality: 30,
306
- bufferTimeAtTopQualityLongForm: 60,
307
- bufferToKeep: 30,
308
- bufferPruningInterval: 30,
305
+ stableBufferTime: 4, // Reduced from 16 (aggressive!)
306
+ bufferTimeAtTopQuality: 8, // Reduced from 30
307
+ bufferTimeAtTopQualityLongForm: 15, // Reduced from 60
308
+ bufferToKeep: 10, // Reduced from 30
309
+ bufferPruningInterval: 10, // Reduced from 30
309
310
  },
310
311
  // Gaps/stall handling
311
312
  gaps: {
@@ -314,12 +315,23 @@ export class DashJsPlayerImpl extends BasePlayer {
314
315
  smallGapLimit: 1.5,
315
316
  threshold: 0.3,
316
317
  },
317
- // ABR - try disabling to isolate the issue
318
+ // AGGRESSIVE: ABR with high initial bitrate estimate
318
319
  abr: {
319
320
  autoSwitchBitrate: { video: true, audio: true },
320
321
  limitBitrateByPortal: false,
321
322
  useDefaultABRRules: true,
322
- initialBitrate: { video: -1, audio: -1 }, // Let dashjs choose
323
+ initialBitrate: { video: 5_000_000, audio: 128_000 }, // 5Mbps initial (was -1)
324
+ },
325
+ // LIVE CATCHUP - critical for maintaining live edge (was missing!)
326
+ liveCatchup: {
327
+ enabled: true,
328
+ maxDrift: 1.5, // Seek to live if drift > 1.5s
329
+ playbackRate: {
330
+ max: 0.15, // Speed up by max 15%
331
+ min: -0.15, // Slow down by max 15%
332
+ },
333
+ playbackBufferMin: 0.3, // Min buffer before catchup
334
+ mode: 'liveCatchupModeDefault',
323
335
  },
324
336
  // Retry settings - more aggressive
325
337
  retryAttempts: {
@@ -354,11 +366,11 @@ export class DashJsPlayerImpl extends BasePlayer {
354
366
  abandonLoadTimeout: 5000, // 5 seconds instead of default 10
355
367
  xhrWithCredentials: false,
356
368
  text: { defaultEnabled: false },
357
- // Live delay settings for live streams
369
+ // AGGRESSIVE: Tighter live delay
358
370
  delay: {
359
- liveDelay: 4, // Target 4 seconds behind live edge
371
+ liveDelay: 2, // Reduced from 4 (2s behind live edge)
360
372
  liveDelayFragmentCount: null,
361
- useSuggestedPresentationDelay: true,
373
+ useSuggestedPresentationDelay: false, // Ignore manifest suggestions
362
374
  },
363
375
  },
364
376
  debug: {
@@ -3,6 +3,7 @@ import { checkProtocolMismatch, getBrowserInfo } from '../core/detector';
3
3
  import { translateCodec } from '../core/CodecUtils';
4
4
  import { LiveDurationProxy } from '../core/LiveDurationProxy';
5
5
  import type { StreamSource, StreamInfo, PlayerOptions, PlayerCapability } from '../core/PlayerInterface';
6
+ import type { HlsJsConfig } from '../types';
6
7
 
7
8
  // Player implementation class
8
9
  export class HlsJsPlayerImpl extends BasePlayer {
@@ -148,13 +149,36 @@ export class HlsJsPlayerImpl extends BasePlayer {
148
149
  console.log('[HLS.js] hls.js module imported, Hls.isSupported():', Hls.isSupported?.());
149
150
 
150
151
  if (Hls.isSupported()) {
151
- this.hls = new Hls({
152
+ // Build optimized HLS.js config with user overrides
153
+ const hlsConfig: HlsJsConfig = {
154
+ // Worker disabled for lower latency (per HLS.js maintainer recommendation)
152
155
  enableWorker: false,
156
+
157
+ // LL-HLS support
153
158
  lowLatencyMode: true,
154
- maxBufferLength: 15,
155
- maxMaxBufferLength: 60,
156
- backBufferLength: 30 // Reduced from 90 to prevent memory issues on long streams
157
- });
159
+
160
+ // AGGRESSIVE: Assume 5 Mbps initially (not 500kbps default)
161
+ // This dramatically improves startup time by selecting appropriate quality faster
162
+ abrEwmaDefaultEstimate: 5_000_000,
163
+
164
+ // AGGRESSIVE: Minimal buffers for fastest startup
165
+ maxBufferLength: 6, // Reduced from 15 (just 2 segments @ 3s)
166
+ maxMaxBufferLength: 15, // Reduced from 60
167
+ backBufferLength: Infinity, // Let browser manage (per maintainer advice)
168
+
169
+ // Stay close to live edge but not too aggressive
170
+ liveSyncDuration: 4, // Target 4 seconds behind live edge
171
+ liveMaxLatencyDuration: 8, // Max 8 seconds before seeking to live
172
+
173
+ // Faster ABR adaptation for live
174
+ abrEwmaFastLive: 2.0, // Faster than default 3.0
175
+ abrEwmaSlowLive: 6.0, // Faster than default 9.0
176
+
177
+ // Allow user overrides
178
+ ...options.hlsConfig,
179
+ };
180
+
181
+ this.hls = new Hls(hlsConfig);
158
182
 
159
183
  this.hls.attachMedia(video);
160
184
 
@@ -314,7 +314,7 @@ export class SourceBufferManager {
314
314
  try {
315
315
  // Make sure end time is never 0 (mews.js:376)
316
316
  this.sourceBuffer.remove(0, Math.max(0.1, currentTime - keepaway));
317
- } catch (e) {
317
+ } catch {
318
318
  // Ignore errors during cleanup
319
319
  }
320
320
  });
@@ -343,7 +343,7 @@ export class SourceBufferManager {
343
343
  if (!isNaN(this.mediaSource.duration)) {
344
344
  this.sourceBuffer.remove(0, Infinity);
345
345
  }
346
- } catch (e) {
346
+ } catch {
347
347
  // Ignore
348
348
  }
349
349
 
@@ -371,7 +371,7 @@ export class SourceBufferManager {
371
371
  if (this.sourceBuffer && this.mediaSource.readyState === 'open') {
372
372
  try {
373
373
  this.mediaSource.removeSourceBuffer(this.sourceBuffer);
374
- } catch (e) {
374
+ } catch {
375
375
  // Ignore
376
376
  }
377
377
  }
@@ -105,7 +105,6 @@ export class WebSocketManager {
105
105
  return false;
106
106
  }
107
107
 
108
- // Helper to schedule retry with tracking
109
108
  const scheduleRetry = (delay: number) => {
110
109
  const timer = setTimeout(() => {
111
110
  this.pendingRetryTimers.delete(timer);
@@ -121,7 +121,7 @@ export class MewsWsPlayerImpl extends BasePlayer {
121
121
  return Object.keys(playableTracks);
122
122
  }
123
123
 
124
- async initialize(container: HTMLElement, source: StreamSource, options: PlayerOptions): Promise<HTMLVideoElement> {
124
+ async initialize(container: HTMLElement, source: StreamSource, options: PlayerOptions, streamInfo?: StreamInfo): Promise<HTMLVideoElement> {
125
125
  this.container = container;
126
126
  container.classList.add('fw-player-container');
127
127
 
@@ -153,12 +153,14 @@ export class MewsWsPlayerImpl extends BasePlayer {
153
153
  endpoint: anyOpts.analytics?.endpoint || null
154
154
  };
155
155
 
156
- // Get stream type from options if available
157
- if ((source as any).type === 'live') {
156
+ // Get stream type from streamInfo if available
157
+ // Note: source.type is a MIME string (e.g., 'ws/video/mp4'), not 'live'/'vod'
158
+ if (streamInfo?.type === 'live') {
158
159
  this.streamType = 'live';
159
- } else if ((source as any).type === 'vod') {
160
+ } else if (streamInfo?.type === 'vod') {
160
161
  this.streamType = 'vod';
161
162
  }
163
+ // Fallback: will be determined by server on_time messages (end === 0 means live)
162
164
 
163
165
  try {
164
166
  // Initialize MediaSource (mews.js:138-196)
@@ -917,7 +919,7 @@ export class MewsWsPlayerImpl extends BasePlayer {
917
919
  this.sbManager?._do(() => {
918
920
  try {
919
921
  // Clear buffer for clean loop
920
- } catch (e) {}
922
+ } catch {}
921
923
  });
922
924
  }
923
925
  });
@@ -146,7 +146,7 @@ export class MistPlayerImpl extends BasePlayer {
146
146
  if (ref && typeof ref.unload === 'function') {
147
147
  ref.unload();
148
148
  }
149
- } catch (e) {
149
+ } catch {
150
150
  // Ignore cleanup errors
151
151
  }
152
152
  try {
@@ -499,7 +499,7 @@ export class MistWebRTCPlayerImpl extends BasePlayer {
499
499
 
500
500
  // Private methods
501
501
 
502
- private async setupWebRTC(video: HTMLVideoElement, source: StreamSource, options: PlayerOptions): Promise<void> {
502
+ private async setupWebRTC(video: HTMLVideoElement, source: StreamSource, _options: PlayerOptions): Promise<void> {
503
503
  const sourceAny = source as any;
504
504
  const iceServers: RTCIceServer[] = sourceAny?.iceServers || [];
505
505
 
@@ -109,7 +109,7 @@ export class NativePlayerImpl extends BasePlayer {
109
109
 
110
110
  // Safari cannot play WebM - skip entirely
111
111
  // Reference: html5.js:28-29
112
- if (mimetype === 'html5/video/webm' && browser.name === 'safari') {
112
+ if (mimetype === 'html5/video/webm' && browser.isSafari) {
113
113
  return false;
114
114
  }
115
115
 
@@ -262,7 +262,7 @@ export class NativePlayerImpl extends BasePlayer {
262
262
  // Use LiveDurationProxy for all live streams (non-WHEP)
263
263
  // WHEP handles its own live edge via signaling
264
264
  // This enables seeking and jump-to-live for native MP4/WebM/HLS live streams
265
- const isLiveStream = streamInfo?.type === 'live' || !isFinite(streamInfo?.duration ?? Infinity);
265
+ const isLiveStream = streamInfo?.type === 'live';
266
266
  if (source.type !== 'whep' && isLiveStream) {
267
267
  this.setupLiveDurationProxy(video);
268
268
  this.setupAutoRecovery(video);
@@ -154,6 +154,10 @@ export class VideoJsPlayerImpl extends BasePlayer {
154
154
  // When using custom controls (controls: false), disable ALL VideoJS UI elements
155
155
  const useVideoJsControls = options.controls === true;
156
156
 
157
+ // Android < 7 workaround: enable overrideNative for HLS
158
+ const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
159
+ const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
160
+
157
161
  // Build VideoJS options
158
162
  // NOTE: We disable UI components but NOT children array - that breaks playback
159
163
  const vjsOptions: Record<string, any> = {
@@ -169,17 +173,36 @@ export class VideoJsPlayerImpl extends BasePlayer {
169
173
  controlBar: useVideoJsControls,
170
174
  liveTracker: useVideoJsControls,
171
175
  // Don't set children: [] - that can break internal VideoJS components
172
- };
173
176
 
174
- // Android < 7 workaround: enable overrideNative for HLS
175
- const androidMatch = navigator.userAgent.match(/android\s([\d.]*)/i);
176
- const androidVersion = androidMatch ? parseFloat(androidMatch[1]) : null;
177
- if (androidVersion && androidVersion < 7) {
178
- console.debug('[VideoJS] Android < 7 detected, enabling overrideNative');
179
- vjsOptions.html5 = { hls: { overrideNative: true } };
180
- vjsOptions.nativeAudioTracks = false;
181
- vjsOptions.nativeVideoTracks = false;
182
- }
177
+ // VHS (http-streaming) configuration - AGGRESSIVE for fastest startup
178
+ html5: {
179
+ vhs: {
180
+ // AGGRESSIVE: Start with lower quality for instant playback
181
+ enableLowInitialPlaylist: true,
182
+
183
+ // AGGRESSIVE: Assume 5 Mbps initially
184
+ bandwidth: 5_000_000,
185
+
186
+ // Persist bandwidth across sessions for returning users
187
+ useBandwidthFromLocalStorage: true,
188
+
189
+ // Enable partial segment processing for lower latency
190
+ handlePartialData: true,
191
+
192
+ // AGGRESSIVE: Very tight live range
193
+ liveRangeSafeTimeDelta: 0.3,
194
+
195
+ // Allow user overrides via options.vhsConfig
196
+ ...options.vhsConfig,
197
+ },
198
+ // Android < 7 workaround
199
+ ...(androidVersion && androidVersion < 7 ? {
200
+ hls: { overrideNative: true }
201
+ } : {}),
202
+ },
203
+ nativeAudioTracks: androidVersion && androidVersion < 7 ? false : undefined,
204
+ nativeVideoTracks: androidVersion && androidVersion < 7 ? false : undefined,
205
+ };
183
206
 
184
207
  console.debug('[VideoJS] Creating player with options:', vjsOptions);
185
208
  this.videojsPlayer = videojs(video, vjsOptions);
@@ -410,27 +433,6 @@ export class VideoJsPlayerImpl extends BasePlayer {
410
433
  return v?.currentTime ?? 0;
411
434
  }
412
435
 
413
- getDuration(): number {
414
- const v = this.proxyElement || this.videoElement;
415
- return v?.duration ?? 0;
416
- }
417
-
418
- getSeekableRange(): { start: number; end: number } | null {
419
- if (this.videojsPlayer?.seekable) {
420
- try {
421
- const seekable = this.videojsPlayer.seekable();
422
- if (seekable && seekable.length > 0) {
423
- const start = seekable.start(0) + this.timeCorrection;
424
- const end = seekable.end(seekable.length - 1) + this.timeCorrection;
425
- if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
426
- return { start, end };
427
- }
428
- }
429
- } catch {}
430
- }
431
- return null;
432
- }
433
-
434
436
  /**
435
437
  * Seek to time using VideoJS API (fixes backwards seeking in HLS).
436
438
  * Time should be in the corrected coordinate space (with firstms offset applied).
@@ -16,7 +16,6 @@
16
16
 
17
17
  import type {
18
18
  LatencyProfile,
19
- BufferState,
20
19
  SyncState,
21
20
  TrackInfo,
22
21
  } from './types';
@@ -383,7 +382,7 @@ export class SyncController {
383
382
  /**
384
383
  * Register a new track
385
384
  */
386
- addTrack(trackIndex: number, track: TrackInfo): void {
385
+ addTrack(_trackIndex: number, _track: TrackInfo): void {
387
386
  // Jitter tracking will be initialized on first chunk
388
387
  }
389
388
 
@@ -183,7 +183,7 @@ export class WebSocketController {
183
183
  }
184
184
  };
185
185
 
186
- this.ws.onerror = (event) => {
186
+ this.ws.onerror = (_event) => {
187
187
  this.log('WebSocket error');
188
188
  this.emit('error', new Error('WebSocket error'));
189
189
  };
@@ -27,7 +27,6 @@ import type {
27
27
  InfoMessage,
28
28
  OnTimeMessage,
29
29
  RawChunk,
30
- LatencyProfileName,
31
30
  WebCodecsPlayerOptions,
32
31
  WebCodecsStats,
33
32
  MainToWorkerMessage,
@@ -36,7 +35,7 @@ import type {
36
35
  import { WebSocketController } from './WebSocketController';
37
36
  import { SyncController } from './SyncController';
38
37
  import { getPresentationTimestamp, isInitData } from './RawChunkParser';
39
- import { getLatencyProfile, mergeLatencyProfile, selectDefaultProfile } from './LatencyProfiles';
38
+ import { mergeLatencyProfile, selectDefaultProfile } from './LatencyProfiles';
40
39
  import { createTrackGenerator, hasNativeMediaStreamTrackGenerator } from './polyfills/MediaStreamTrackGenerator';
41
40
 
42
41
  /**
@@ -114,11 +113,13 @@ export class WebCodecsPlayerImpl extends BasePlayer {
114
113
  name: 'WebCodecs Player',
115
114
  shortname: 'webcodecs',
116
115
  priority: 0, // Highest priority - lowest latency option
117
- // Raw WebSocket (12-byte header + AVCC NAL units) - NOT MP4-muxed
116
+ // Raw WebSocket (12-byte header + codec frames) - NOT MP4-muxed
118
117
  // MistServer's output_wsraw.cpp provides full codec negotiation (audio + video)
118
+ // MistServer's output_h264.cpp uses same 12-byte header but Annex B payload (video-only)
119
119
  // NOTE: ws/video/mp4 is MP4-fragmented which needs MEWS player (uses MSE)
120
120
  mimes: [
121
- 'ws/video/raw', 'wss/video/raw', // Raw codec frames (audio + video)
121
+ 'ws/video/raw', 'wss/video/raw', // Raw codec frames - AVCC format (audio + video)
122
+ 'ws/video/h264', 'wss/video/h264', // Annex B H264/HEVC (video-only, same 12-byte header)
122
123
  ],
123
124
  };
124
125
 
@@ -136,6 +137,8 @@ export class WebCodecsPlayerImpl extends BasePlayer {
136
137
  private debugging = false;
137
138
  private verboseDebugging = false;
138
139
  private streamType: 'live' | 'vod' = 'live';
140
+ /** Payload format: 'avcc' for ws/video/raw, 'annexb' for ws/video/h264 */
141
+ private payloadFormat: 'avcc' | 'annexb' = 'avcc';
139
142
  private workerUidCounter = 0;
140
143
  private workerListeners = new Map<number, (msg: WorkerToMainMessage) => void>();
141
144
 
@@ -209,7 +212,8 @@ export class WebCodecsPlayerImpl extends BasePlayer {
209
212
  }
210
213
  } else {
211
214
  // Use VideoDecoder.isConfigSupported()
212
- result = await VideoDecoder.isConfigSupported(config as VideoDecoderConfig);
215
+ const videoResult = await VideoDecoder.isConfigSupported(config as VideoDecoderConfig);
216
+ result = { supported: videoResult.supported === true, config: videoResult.config };
213
217
  }
214
218
  break;
215
219
  }
@@ -217,7 +221,8 @@ export class WebCodecsPlayerImpl extends BasePlayer {
217
221
  // Audio requires numberOfChannels and sampleRate
218
222
  config.numberOfChannels = track.channels ?? 2;
219
223
  config.sampleRate = track.rate ?? 48000;
220
- result = await AudioDecoder.isConfigSupported(config as AudioDecoderConfig);
224
+ const audioResult = await AudioDecoder.isConfigSupported(config as AudioDecoderConfig);
225
+ result = { supported: audioResult.supported === true, config: audioResult.config };
221
226
  break;
222
227
  }
223
228
  default:
@@ -311,6 +316,11 @@ export class WebCodecsPlayerImpl extends BasePlayer {
311
316
  }
312
317
  }
313
318
 
319
+ // Annex B H264 WebSocket is video-only (no audio payloads)
320
+ if (mimetype.includes('video/h264')) {
321
+ delete playableTracks.audio;
322
+ }
323
+
314
324
  if (Object.keys(playableTracks).length === 0) {
315
325
  return false;
316
326
  }
@@ -341,6 +351,13 @@ export class WebCodecsPlayerImpl extends BasePlayer {
341
351
  this._bytesReceived = 0;
342
352
  this._messagesReceived = 0;
343
353
 
354
+ // Detect payload format from source MIME type
355
+ // ws/video/h264 uses Annex B (start code delimited NALs), ws/video/raw uses AVCC (length-prefixed)
356
+ this.payloadFormat = source.type?.includes('h264') ? 'annexb' : 'avcc';
357
+ if (this.payloadFormat === 'annexb') {
358
+ this.log('Using Annex B payload format (ws/video/h264)');
359
+ }
360
+
344
361
  this.container = container;
345
362
  container.classList.add('fw-player-container');
346
363
 
@@ -932,7 +949,7 @@ export class WebCodecsPlayerImpl extends BasePlayer {
932
949
  const tracksObj = msg.meta.tracks;
933
950
  this.log(`Info contains ${Object.keys(tracksObj).length} tracks`);
934
951
 
935
- for (const [name, track] of Object.entries(tracksObj)) {
952
+ for (const [_name, track] of Object.entries(tracksObj)) {
936
953
  // Store track by its index for lookup when chunks arrive
937
954
  if (track.idx !== undefined) {
938
955
  this.tracksByIndex.set(track.idx, track);
@@ -1180,6 +1197,7 @@ export class WebCodecsPlayerImpl extends BasePlayer {
1180
1197
  track,
1181
1198
  opts: {
1182
1199
  optimizeForLatency: this.streamType === 'live',
1200
+ payloadFormat: this.payloadFormat, // 'avcc' for ws/video/raw, 'annexb' for ws/video/h264
1183
1201
  },
1184
1202
  uid: this.workerUidCounter++,
1185
1203
  });
@@ -213,6 +213,8 @@ export interface CreatePipelineMessage {
213
213
  track: TrackInfo;
214
214
  opts: {
215
215
  optimizeForLatency: boolean;
216
+ /** Payload format: 'avcc' (length-prefixed) or 'annexb' (start-code delimited) */
217
+ payloadFormat?: 'avcc' | 'annexb';
216
218
  };
217
219
  uid?: number;
218
220
  }
@@ -257,9 +259,10 @@ export interface ClosePipelineMessage {
257
259
 
258
260
  export interface FrameTimingMessage {
259
261
  type: 'frametiming';
260
- action: 'setSpeed' | 'reset';
262
+ action: 'setSpeed' | 'reset' | 'setPaused';
261
263
  speed?: number;
262
264
  tweak?: number;
265
+ paused?: boolean;
263
266
  uid?: number;
264
267
  }
265
268
 
@@ -275,6 +278,20 @@ export interface DebuggingMessage {
275
278
  uid?: number;
276
279
  }
277
280
 
281
+ export interface FrameStepMessage {
282
+ type: 'framestep';
283
+ direction: -1 | 1;
284
+ uid?: number;
285
+ }
286
+
287
+ export interface WriteFrameResponseMessage {
288
+ type: 'writeframe';
289
+ idx: number;
290
+ uid?: number;
291
+ status: 'ok' | 'error';
292
+ error?: string;
293
+ }
294
+
278
295
  export type MainToWorkerMessage =
279
296
  | CreatePipelineMessage
280
297
  | ConfigurePipelineMessage
@@ -284,7 +301,9 @@ export type MainToWorkerMessage =
284
301
  | ClosePipelineMessage
285
302
  | FrameTimingMessage
286
303
  | SeekWorkerMessage
287
- | DebuggingMessage;
304
+ | DebuggingMessage
305
+ | FrameStepMessage
306
+ | WriteFrameResponseMessage;
288
307
 
289
308
  // Worker -> Main thread messages
290
309
  export interface AddTrackMessage {
@@ -323,6 +342,7 @@ export interface SendEventMessage {
323
342
  type: 'sendevent';
324
343
  kind: string;
325
344
  message?: string;
345
+ time?: number;
326
346
  idx?: number;
327
347
  uid?: number;
328
348
  }
@@ -341,6 +361,13 @@ export interface AckMessage {
341
361
  error?: string;
342
362
  }
343
363
 
364
+ export interface WriteFrameMessage {
365
+ type: 'writeframe';
366
+ idx: number;
367
+ frame: AudioData;
368
+ uid?: number;
369
+ }
370
+
344
371
  export type WorkerToMainMessage =
345
372
  | AddTrackMessage
346
373
  | RemoveTrackMessage
@@ -349,7 +376,8 @@ export type WorkerToMainMessage =
349
376
  | LogMessage
350
377
  | SendEventMessage
351
378
  | StatsMessage
352
- | AckMessage;
379
+ | AckMessage
380
+ | WriteFrameMessage;
353
381
 
354
382
  // ============================================================================
355
383
  // Stats Types
@@ -20,7 +20,7 @@ import type {
20
20
  VideoDecoderInit,
21
21
  AudioDecoderInit,
22
22
  } from './types';
23
- import type { TrackInfo, PipelineStats, FrameTrackerStats } from '../types';
23
+ import type { PipelineStats, FrameTrackerStats } from '../types';
24
24
 
25
25
  // ============================================================================
26
26
  // Global State
@@ -139,7 +139,7 @@ let statsTimer: ReturnType<typeof setInterval> | null = null;
139
139
  const STATS_INTERVAL_MS = 250;
140
140
 
141
141
  // Frame dropping stats (Phase 2B)
142
- let totalFramesDropped = 0;
142
+ let _totalFramesDropped = 0;
143
143
 
144
144
  // Chrome-recommended decoder queue threshold
145
145
  // Per Chrome WebCodecs best practices: drop when decodeQueueSize > 2
@@ -259,6 +259,7 @@ function handleCreate(msg: MainToWorkerMessage & { type: 'create' }): void {
259
259
  lastChunkBytes: '' as string,
260
260
  },
261
261
  optimizeForLatency: opts.optimizeForLatency,
262
+ payloadFormat: opts.payloadFormat || 'avcc',
262
263
  };
263
264
 
264
265
  pipelines.set(idx, pipeline);
@@ -349,7 +350,10 @@ function configureVideoDecoder(pipeline: PipelineState, description?: Uint8Array
349
350
  };
350
351
 
351
352
  // Pass description directly from WebSocket INIT data (per reference rawws.js line 1052)
352
- if (description && description.byteLength > 0) {
353
+ // For Annex B format (ws/video/h264), SPS/PPS comes inline in the bitstream - skip description
354
+ if (pipeline.payloadFormat === 'annexb') {
355
+ log(`Annex B mode - SPS/PPS inline in bitstream, no description needed`);
356
+ } else if (description && description.byteLength > 0) {
353
357
  config.description = description;
354
358
  log(`Configuring with description (${description.byteLength} bytes)`);
355
359
  } else {
@@ -517,7 +521,7 @@ function resetPipelineAfterError(pipeline: PipelineState): void {
517
521
  // ============================================================================
518
522
 
519
523
  function handleReceive(msg: MainToWorkerMessage & { type: 'receive' }): void {
520
- const { idx, chunk, uid } = msg;
524
+ const { idx, chunk } = msg;
521
525
  const pipeline = pipelines.get(idx);
522
526
 
523
527
  if (!pipeline) {
@@ -555,7 +559,7 @@ function handleReceive(msg: MainToWorkerMessage & { type: 'receive' }): void {
555
559
  } else {
556
560
  // Drop delta frames when decoder is overwhelmed
557
561
  pipeline.stats.framesDropped++;
558
- totalFramesDropped++;
562
+ _totalFramesDropped++;
559
563
  logVerbose(`Dropped delta frame @ ${chunk.timestamp / 1000}ms (decoder queue: ${pipeline.decoder.decodeQueueSize})`);
560
564
  }
561
565
  return;
@@ -583,7 +587,7 @@ function shouldDropFramesDueToDecoderPressure(pipeline: PipelineState): boolean
583
587
  * Drop all frames up to the next keyframe in the input queue
584
588
  * Called when decoder is severely backed up
585
589
  */
586
- function dropToNextKeyframe(pipeline: PipelineState): number {
590
+ function _dropToNextKeyframe(pipeline: PipelineState): number {
587
591
  if (pipeline.inputQueue.length === 0) return 0;
588
592
 
589
593
  // Find next keyframe in queue
@@ -597,7 +601,7 @@ function dropToNextKeyframe(pipeline: PipelineState): number {
597
601
  // Drop all frames before keyframe
598
602
  const dropped = pipeline.inputQueue.splice(0, keyframeIdx);
599
603
  pipeline.stats.framesDropped += dropped.length;
600
- totalFramesDropped += dropped.length;
604
+ _totalFramesDropped += dropped.length;
601
605
 
602
606
  log(`Dropped ${dropped.length} frames to next keyframe`, 'warn');
603
607
 
@@ -994,13 +998,14 @@ function handleCreateGenerator(msg: MainToWorkerMessage & { type: 'creategenerat
994
998
  };
995
999
  self.addEventListener('message', handler);
996
1000
 
997
- // Send frame to main thread
998
- self.postMessage({
1001
+ // Send frame to main thread (transfer AudioData)
1002
+ const msg = {
999
1003
  type: 'writeframe',
1000
1004
  idx,
1001
1005
  frame,
1002
1006
  uid: frameUid,
1003
- }, [frame]);
1007
+ };
1008
+ self.postMessage(msg, { transfer: [frame] });
1004
1009
  });
1005
1010
  },
1006
1011
  close: () => Promise.resolve(),
@@ -1016,6 +1021,7 @@ function handleCreateGenerator(msg: MainToWorkerMessage & { type: 'creategenerat
1016
1021
  self.postMessage(message);
1017
1022
  log(`Set up frame relay for track ${idx} (Safari audio)`);
1018
1023
  }
1024
+ // @ts-ignore - MediaStreamTrackGenerator may not be in standard types
1019
1025
  } else if (typeof MediaStreamTrackGenerator !== 'undefined') {
1020
1026
  // Chrome/Edge: use MediaStreamTrackGenerator in worker
1021
1027
  // @ts-ignore
@@ -1149,9 +1155,10 @@ function handleFrameStep(msg: MainToWorkerMessage & { type: 'framestep' }): void
1149
1155
 
1150
1156
  if (direction > 0) {
1151
1157
  // If we're stepping forward within history (after stepping back), use history
1152
- if (pipeline.historyCursor !== null && pipeline.historyCursor < pipeline.frameHistory.length - 1) {
1153
- pipeline.historyCursor += 1;
1154
- const entry = pipeline.frameHistory[pipeline.historyCursor];
1158
+ const cursor = pipeline.historyCursor;
1159
+ if (cursor !== null && cursor !== undefined && cursor < pipeline.frameHistory.length - 1) {
1160
+ pipeline.historyCursor = cursor + 1;
1161
+ const entry = pipeline.frameHistory[pipeline.historyCursor!];
1155
1162
  const clone = entry ? cloneVideoFrame(entry.frame) : null;
1156
1163
  if (!clone) {
1157
1164
  log(`FrameStep forward: failed to clone frame`);
@@ -16,6 +16,8 @@ export interface CreateMessage {
16
16
  track: TrackInfo;
17
17
  opts: {
18
18
  optimizeForLatency: boolean;
19
+ /** Payload format: 'avcc' (length-prefixed) or 'annexb' (start-code delimited) */
20
+ payloadFormat?: 'avcc' | 'annexb';
19
21
  };
20
22
  uid: number;
21
23
  }
@@ -240,6 +242,8 @@ export interface PipelineState {
240
242
  lastChunkBytes: string;
241
243
  };
242
244
  optimizeForLatency: boolean;
245
+ /** Payload format: 'avcc' (length-prefixed) or 'annexb' (start-code delimited) */
246
+ payloadFormat: 'avcc' | 'annexb';
243
247
  }
244
248
 
245
249
  // ============================================================================