@livepeer-frameworks/player-core 0.0.4 → 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 +14 -1
  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
@@ -143,6 +143,8 @@ export interface CreatePipelineMessage {
143
143
  track: TrackInfo;
144
144
  opts: {
145
145
  optimizeForLatency: boolean;
146
+ /** Payload format: 'avcc' (length-prefixed) or 'annexb' (start-code delimited) */
147
+ payloadFormat?: 'avcc' | 'annexb';
146
148
  };
147
149
  uid?: number;
148
150
  }
@@ -181,9 +183,10 @@ export interface ClosePipelineMessage {
181
183
  }
182
184
  export interface FrameTimingMessage {
183
185
  type: 'frametiming';
184
- action: 'setSpeed' | 'reset';
186
+ action: 'setSpeed' | 'reset' | 'setPaused';
185
187
  speed?: number;
186
188
  tweak?: number;
189
+ paused?: boolean;
187
190
  uid?: number;
188
191
  }
189
192
  export interface SeekWorkerMessage {
@@ -196,7 +199,19 @@ export interface DebuggingMessage {
196
199
  value: boolean | 'verbose';
197
200
  uid?: number;
198
201
  }
199
- export type MainToWorkerMessage = CreatePipelineMessage | ConfigurePipelineMessage | ReceiveChunkMessage | SetWritableMessage | CreateGeneratorMessage | ClosePipelineMessage | FrameTimingMessage | SeekWorkerMessage | DebuggingMessage;
202
+ export interface FrameStepMessage {
203
+ type: 'framestep';
204
+ direction: -1 | 1;
205
+ uid?: number;
206
+ }
207
+ export interface WriteFrameResponseMessage {
208
+ type: 'writeframe';
209
+ idx: number;
210
+ uid?: number;
211
+ status: 'ok' | 'error';
212
+ error?: string;
213
+ }
214
+ export type MainToWorkerMessage = CreatePipelineMessage | ConfigurePipelineMessage | ReceiveChunkMessage | SetWritableMessage | CreateGeneratorMessage | ClosePipelineMessage | FrameTimingMessage | SeekWorkerMessage | DebuggingMessage | FrameStepMessage | WriteFrameResponseMessage;
200
215
  export interface AddTrackMessage {
201
216
  type: 'addtrack';
202
217
  idx: number;
@@ -228,6 +243,7 @@ export interface SendEventMessage {
228
243
  type: 'sendevent';
229
244
  kind: string;
230
245
  message?: string;
246
+ time?: number;
231
247
  idx?: number;
232
248
  uid?: number;
233
249
  }
@@ -243,7 +259,13 @@ export interface AckMessage {
243
259
  status?: 'ok' | 'error';
244
260
  error?: string;
245
261
  }
246
- export type WorkerToMainMessage = AddTrackMessage | RemoveTrackMessage | SetPlaybackRateMessage | ClosedMessage | LogMessage | SendEventMessage | StatsMessage | AckMessage;
262
+ export interface WriteFrameMessage {
263
+ type: 'writeframe';
264
+ idx: number;
265
+ frame: AudioData;
266
+ uid?: number;
267
+ }
268
+ export type WorkerToMainMessage = AddTrackMessage | RemoveTrackMessage | SetPlaybackRateMessage | ClosedMessage | LogMessage | SendEventMessage | StatsMessage | AckMessage | WriteFrameMessage;
247
269
  export interface FrameTimingStats {
248
270
  /** Timestamp when frame entered decoder (microseconds) */
249
271
  in: number;
@@ -10,6 +10,8 @@ export interface CreateMessage {
10
10
  track: TrackInfo;
11
11
  opts: {
12
12
  optimizeForLatency: boolean;
13
+ /** Payload format: 'avcc' (length-prefixed) or 'annexb' (start-code delimited) */
14
+ payloadFormat?: 'avcc' | 'annexb';
13
15
  };
14
16
  uid: number;
15
17
  }
@@ -48,9 +50,10 @@ export interface CloseMessage {
48
50
  }
49
51
  export interface FrameTimingMessage {
50
52
  type: 'frametiming';
51
- action: 'setSpeed' | 'reset';
53
+ action: 'setSpeed' | 'reset' | 'setPaused';
52
54
  speed?: number;
53
55
  tweak?: number;
56
+ paused?: boolean;
54
57
  uid: number;
55
58
  }
56
59
  export interface SeekMessage {
@@ -58,12 +61,17 @@ export interface SeekMessage {
58
61
  seekTime: number;
59
62
  uid: number;
60
63
  }
64
+ export interface FrameStepMessage {
65
+ type: 'framestep';
66
+ direction: -1 | 1;
67
+ uid: number;
68
+ }
61
69
  export interface DebuggingMessage {
62
70
  type: 'debugging';
63
71
  value: boolean | 'verbose';
64
72
  uid: number;
65
73
  }
66
- export type MainToWorkerMessage = CreateMessage | ConfigureMessage | ReceiveMessage | SetWritableMessage | CreateGeneratorMessage | CloseMessage | FrameTimingMessage | SeekMessage | DebuggingMessage;
74
+ export type MainToWorkerMessage = CreateMessage | ConfigureMessage | ReceiveMessage | SetWritableMessage | CreateGeneratorMessage | CloseMessage | FrameTimingMessage | SeekMessage | FrameStepMessage | DebuggingMessage;
67
75
  export interface AddTrackMessage {
68
76
  type: 'addtrack';
69
77
  idx: number;
@@ -97,6 +105,7 @@ export interface SendEventMessage {
97
105
  type: 'sendevent';
98
106
  kind: string;
99
107
  message?: string;
108
+ time?: number;
100
109
  idx?: number;
101
110
  uid: number;
102
111
  }
@@ -159,6 +168,13 @@ export interface PipelineState {
159
168
  data: Uint8Array;
160
169
  }>;
161
170
  outputQueue: DecodedFrame[];
171
+ /** Recent video frames for backward/forward stepping (video only) */
172
+ frameHistory?: Array<{
173
+ frame: VideoFrame;
174
+ timestamp: number;
175
+ }>;
176
+ /** Cursor into frameHistory for step navigation */
177
+ historyCursor?: number | null;
162
178
  stats: {
163
179
  framesIn: number;
164
180
  framesDecoded: number;
@@ -172,6 +188,8 @@ export interface PipelineState {
172
188
  lastChunkBytes: string;
173
189
  };
174
190
  optimizeForLatency: boolean;
191
+ /** Payload format: 'avcc' (length-prefixed) or 'annexb' (start-code delimited) */
192
+ payloadFormat: 'avcc' | 'annexb';
175
193
  }
176
194
  export interface ScheduleResult {
177
195
  /** Whether frame should be output now */
@@ -50,6 +50,8 @@ export interface PlayerOptions {
50
50
  hlsConfig?: HlsJsConfig;
51
51
  /** DASH.js configuration override (merged with defaults) */
52
52
  dashConfig?: DashJsConfig;
53
+ /** Video.js VHS configuration override (merged with defaults) */
54
+ vhsConfig?: VhsConfig;
53
55
  /** WebRTC configuration (ICE servers, etc.) */
54
56
  rtcConfig?: RTCConfiguration;
55
57
  /** String to append to all request URLs (auth tokens, tracking params) */
@@ -98,6 +100,21 @@ export interface DashJsConfig {
98
100
  };
99
101
  [key: string]: unknown;
100
102
  }
103
+ /** Video.js VHS (http-streaming) configuration subset */
104
+ export interface VhsConfig {
105
+ /** Start with lowest quality for faster initial playback */
106
+ enableLowInitialPlaylist?: boolean;
107
+ /** Initial bandwidth estimate in bits per second (e.g., 5_000_000 for 5 Mbps) */
108
+ bandwidth?: number;
109
+ /** Persist bandwidth estimate in localStorage across sessions */
110
+ useBandwidthFromLocalStorage?: boolean;
111
+ /** Enable partial segment appends for lower latency */
112
+ handlePartialData?: boolean;
113
+ /** Time delta for live range safety calculations (seconds) */
114
+ liveRangeSafeTimeDelta?: number;
115
+ /** Pass-through for other VHS options */
116
+ [key: string]: unknown;
117
+ }
101
118
  export type StreamProtocol = 'WHEP' | 'HLS' | 'DASH' | 'MP4' | 'WEBM' | 'RTMP' | 'MIST_HTML';
102
119
  export interface OutputCapabilities {
103
120
  supportsSeek: boolean;
@@ -138,7 +155,7 @@ export interface ContentMetadata {
138
155
  durationSeconds?: number;
139
156
  thumbnailUrl?: string;
140
157
  createdAt?: string;
141
- status?: 'AVAILABLE' | 'PROCESSING' | 'ERROR' | 'OFFLINE';
158
+ status?: 'AVAILABLE' | 'PROCESSING' | 'ERROR' | 'OFFLINE' | 'ONLINE' | 'INITIALIZING' | 'BOOTING' | 'WAITING_FOR_DATA' | 'SHUTTING_DOWN' | 'INVALID';
142
159
  viewers?: number;
143
160
  isLive?: boolean;
144
161
  recordingSizeBytes?: number;
@@ -147,6 +164,19 @@ export interface ContentMetadata {
147
164
  dvrStatus?: 'recording' | 'completed';
148
165
  /** Native container format: mp4, m3u8, webm, etc. */
149
166
  format?: string;
167
+ /** MistServer authoritative snapshot (merged into this metadata) */
168
+ mist?: MistStreamInfo;
169
+ /** Parsed track summary (derived from Mist metadata when available) */
170
+ tracks?: Array<{
171
+ type: 'video' | 'audio' | 'meta';
172
+ codec?: string;
173
+ width?: number;
174
+ height?: number;
175
+ bitrate?: number;
176
+ fps?: number;
177
+ channels?: number;
178
+ sampleRate?: number;
179
+ }>;
150
180
  }
151
181
  export interface ContentEndpoints {
152
182
  primary: EndpointInfo;
@@ -360,4 +390,5 @@ export interface PlayerMetadata {
360
390
  channels?: number;
361
391
  sampleRate?: number;
362
392
  }>;
393
+ mist?: MistStreamInfo;
363
394
  }
@@ -6,11 +6,11 @@
6
6
  *
7
7
  * @example
8
8
  * ```typescript
9
- * import { FrameWorksPlayer } from '@livepeer-frameworks/player/vanilla';
10
- * import '@livepeer-frameworks/player/player.css';
9
+ * import { FrameWorksPlayer } from '@livepeer-frameworks/player-core/vanilla';
10
+ * import '@livepeer-frameworks/player-core/player.css';
11
11
  *
12
12
  * const player = new FrameWorksPlayer('#player', {
13
- * contentId: 'my-stream',
13
+ * contentId: 'pk_...',
14
14
  * contentType: 'live',
15
15
  * gatewayUrl: 'https://gateway.example.com/graphql',
16
16
  * onStateChange: (state) => console.log('State:', state),
@@ -30,7 +30,7 @@ export interface FrameWorksPlayerOptions {
30
30
  /** Content identifier (stream name) */
31
31
  contentId: string;
32
32
  /** Content type */
33
- contentType: ContentType;
33
+ contentType?: ContentType;
34
34
  /** Pre-resolved endpoints (skip gateway) */
35
35
  endpoints?: ContentEndpoints;
36
36
  /** Gateway URL (required if endpoints not provided) */
@@ -57,7 +57,7 @@ export interface FrameWorksPlayerOptions {
57
57
  }
58
58
  interface LegacyConfig {
59
59
  contentId: string;
60
- contentType: ContentType;
60
+ contentType?: ContentType;
61
61
  thumbnailUrl?: string | null;
62
62
  options?: {
63
63
  gatewayUrl?: string;
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * @example
5
5
  * ```typescript
6
- * import { FrameWorksPlayer } from '@livepeer-frameworks/player/vanilla';
7
- * import '@livepeer-frameworks/player/player.css';
6
+ * import { FrameWorksPlayer } from '@livepeer-frameworks/player-core/vanilla';
7
+ * import '@livepeer-frameworks/player-core/player.css';
8
8
  *
9
9
  * const player = new FrameWorksPlayer('#player', {
10
- * contentId: 'my-stream',
10
+ * contentId: 'pk_...',
11
11
  * contentType: 'live',
12
12
  * gatewayUrl: 'https://gateway.example.com/graphql',
13
13
  * });
@@ -57,6 +57,65 @@
57
57
  trackBaseTimes.clear();
58
58
  log(`Reset all track baseTimes`);
59
59
  }
60
+ function cloneVideoFrame(frame) {
61
+ try {
62
+ if ('clone' in frame) {
63
+ return frame.clone();
64
+ }
65
+ return new VideoFrame(frame);
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function pushFrameHistory(pipeline, frame, timestamp) {
72
+ if (pipeline.track.type !== 'video')
73
+ return;
74
+ if (!pipeline.frameHistory)
75
+ pipeline.frameHistory = [];
76
+ const cloned = cloneVideoFrame(frame);
77
+ if (!cloned)
78
+ return;
79
+ pipeline.frameHistory.push({ frame: cloned, timestamp });
80
+ // Trim history
81
+ while (pipeline.frameHistory.length > MAX_FRAME_HISTORY) {
82
+ const entry = pipeline.frameHistory.shift();
83
+ if (entry) {
84
+ try {
85
+ entry.frame.close();
86
+ }
87
+ catch { }
88
+ }
89
+ }
90
+ pipeline.historyCursor = pipeline.frameHistory.length - 1;
91
+ }
92
+ function alignHistoryCursorToLastOutput(pipeline) {
93
+ if (!pipeline.frameHistory || pipeline.frameHistory.length === 0)
94
+ return;
95
+ const lastTs = pipeline.stats.lastOutputTimestamp;
96
+ if (!Number.isFinite(lastTs)) {
97
+ pipeline.historyCursor = pipeline.frameHistory.length - 1;
98
+ return;
99
+ }
100
+ // Find first history entry greater than last output, then step back one
101
+ const idx = pipeline.frameHistory.findIndex(entry => entry.timestamp > lastTs);
102
+ if (idx === -1) {
103
+ pipeline.historyCursor = pipeline.frameHistory.length - 1;
104
+ return;
105
+ }
106
+ pipeline.historyCursor = Math.max(0, idx - 1);
107
+ }
108
+ function getPrimaryVideoPipeline() {
109
+ let selected = null;
110
+ for (const pipeline of pipelines.values()) {
111
+ if (pipeline.track.type === 'video') {
112
+ if (!selected || pipeline.idx < selected.idx) {
113
+ selected = pipeline;
114
+ }
115
+ }
116
+ }
117
+ return selected;
118
+ }
60
119
  // Stats update interval
61
120
  let statsTimer = null;
62
121
  const STATS_INTERVAL_MS = 250;
@@ -64,6 +123,9 @@
64
123
  // Per Chrome WebCodecs best practices: drop when decodeQueueSize > 2
65
124
  // This ensures decoder doesn't fall too far behind before corrective action
66
125
  const MAX_DECODER_QUEUE_SIZE = 2;
126
+ const MAX_FRAME_HISTORY = 60;
127
+ const MAX_PAUSED_OUTPUT_QUEUE = 120;
128
+ const MAX_PAUSED_INPUT_QUEUE = 600;
67
129
  // ============================================================================
68
130
  // Logging
69
131
  // ============================================================================
@@ -113,6 +175,9 @@
113
175
  case 'seek':
114
176
  handleSeek(msg);
115
177
  break;
178
+ case 'framestep':
179
+ handleFrameStep(msg);
180
+ break;
116
181
  case 'debugging':
117
182
  debugging = msg.value;
118
183
  log(`Debugging set to: ${msg.value}`);
@@ -137,6 +202,8 @@
137
202
  writer: null,
138
203
  inputQueue: [],
139
204
  outputQueue: [],
205
+ frameHistory: track.type === 'video' ? [] : undefined,
206
+ historyCursor: track.type === 'video' ? null : undefined,
140
207
  stats: {
141
208
  framesIn: 0,
142
209
  framesDecoded: 0,
@@ -151,6 +218,7 @@
151
218
  lastChunkBytes: '',
152
219
  },
153
220
  optimizeForLatency: opts.optimizeForLatency,
221
+ payloadFormat: opts.payloadFormat || 'avcc',
154
222
  };
155
223
  pipelines.set(idx, pipeline);
156
224
  // Start stats reporting if not already running
@@ -230,7 +298,11 @@
230
298
  hardwareAcceleration: 'prefer-hardware',
231
299
  };
232
300
  // Pass description directly from WebSocket INIT data (per reference rawws.js line 1052)
233
- if (description && description.byteLength > 0) {
301
+ // For Annex B format (ws/video/h264), SPS/PPS comes inline in the bitstream - skip description
302
+ if (pipeline.payloadFormat === 'annexb') {
303
+ log(`Annex B mode - SPS/PPS inline in bitstream, no description needed`);
304
+ }
305
+ else if (description && description.byteLength > 0) {
234
306
  config.description = description;
235
307
  log(`Configuring with description (${description.byteLength} bytes)`);
236
308
  }
@@ -374,7 +446,7 @@
374
446
  // Frame Input/Output
375
447
  // ============================================================================
376
448
  function handleReceive(msg) {
377
- const { idx, chunk, uid } = msg;
449
+ const { idx, chunk } = msg;
378
450
  const pipeline = pipelines.get(idx);
379
451
  if (!pipeline) {
380
452
  logVerbose(`Received chunk for unknown pipeline ${idx}`);
@@ -386,6 +458,15 @@
386
458
  logVerbose(`Queued chunk for track ${idx} (configured=${pipeline.configured}, decoder=${!!pipeline.decoder})`);
387
459
  return;
388
460
  }
461
+ // If paused and output queue is saturated, queue input to preserve per-frame stepping
462
+ if (frameTiming.paused && pipeline.outputQueue.length >= MAX_PAUSED_OUTPUT_QUEUE) {
463
+ pipeline.inputQueue.push(chunk);
464
+ if (pipeline.inputQueue.length > MAX_PAUSED_INPUT_QUEUE) {
465
+ pipeline.inputQueue.splice(0, pipeline.inputQueue.length - MAX_PAUSED_INPUT_QUEUE);
466
+ logVerbose(`Trimmed paused input queue for track ${idx} to ${MAX_PAUSED_INPUT_QUEUE}`);
467
+ }
468
+ return;
469
+ }
389
470
  // Log only first 3 chunks per track to confirm receiving
390
471
  if (pipeline.stats.framesIn < 3) {
391
472
  log(`Received chunk ${pipeline.stats.framesIn} for track ${idx}: type=${chunk.type}, ts=${chunk.timestamp / 1000}ms, size=${chunk.data.byteLength}`);
@@ -410,6 +491,8 @@
410
491
  * Based on Chrome WebCodecs best practices: drop when decodeQueueSize > 2
411
492
  */
412
493
  function shouldDropFramesDueToDecoderPressure(pipeline) {
494
+ if (frameTiming.paused)
495
+ return false;
413
496
  if (!pipeline.decoder)
414
497
  return false;
415
498
  const queueSize = pipeline.decoder.decodeQueueSize;
@@ -515,6 +598,9 @@
515
598
  }
516
599
  }
517
600
  function processOutputQueue(pipeline) {
601
+ if (frameTiming.paused) {
602
+ return;
603
+ }
518
604
  // Check if pipeline is closed (e.g., player destroyed) - clean up queued frames
519
605
  if (pipeline.closed) {
520
606
  while (pipeline.outputQueue.length > 0) {
@@ -618,7 +704,7 @@
618
704
  // Schedule check for when frame should be ready
619
705
  return { shouldOutput: false, earliness: -delay, checkDelayMs: Math.max(1, Math.floor(delay)) };
620
706
  }
621
- function outputFrame(pipeline, entry) {
707
+ function outputFrame(pipeline, entry, options) {
622
708
  if (!pipeline.writer || pipeline.closed) {
623
709
  entry.frame.close();
624
710
  return;
@@ -631,6 +717,10 @@
631
717
  if (pipeline.stats.framesOut <= 3) {
632
718
  log(`Output frame ${pipeline.stats.framesOut} for track ${pipeline.idx}: ts=${entry.timestamp}μs`);
633
719
  }
720
+ // Store history for frame stepping (video only)
721
+ if (pipeline.track.type === 'video' && !(options?.skipHistory)) {
722
+ pushFrameHistory(pipeline, entry.frame, entry.timestamp);
723
+ }
634
724
  // Write returns a Promise - handle rejection to avoid unhandled promise errors
635
725
  // Frame ownership is transferred to the stream, so we don't need to close() on success
636
726
  pipeline.writer.write(entry.frame).then(() => {
@@ -639,6 +729,7 @@
639
729
  type: 'sendevent',
640
730
  kind: 'timeupdate',
641
731
  idx: pipeline.idx,
732
+ time: entry.timestamp / 1e6,
642
733
  uid: uidCounter++,
643
734
  };
644
735
  self.postMessage(message);
@@ -742,13 +833,14 @@
742
833
  }
743
834
  };
744
835
  self.addEventListener('message', handler);
745
- // Send frame to main thread
746
- self.postMessage({
836
+ // Send frame to main thread (transfer AudioData)
837
+ const msg = {
747
838
  type: 'writeframe',
748
839
  idx,
749
840
  frame,
750
841
  uid: frameUid,
751
- }, [frame]);
842
+ };
843
+ self.postMessage(msg, { transfer: [frame] });
752
844
  });
753
845
  },
754
846
  close: () => Promise.resolve(),
@@ -763,6 +855,7 @@
763
855
  self.postMessage(message);
764
856
  log(`Set up frame relay for track ${idx} (Safari audio)`);
765
857
  }
858
+ // @ts-ignore - MediaStreamTrackGenerator may not be in standard types
766
859
  }
767
860
  else if (typeof MediaStreamTrackGenerator !== 'undefined') {
768
861
  // Chrome/Edge: use MediaStreamTrackGenerator in worker
@@ -832,12 +925,96 @@
832
925
  frameTiming.speed.combined = frameTiming.speed.main * frameTiming.speed.tweak;
833
926
  log(`Speed set to ${frameTiming.speed.combined} (main: ${frameTiming.speed.main}, tweak: ${frameTiming.speed.tweak})`);
834
927
  }
928
+ else if (action === 'setPaused') {
929
+ frameTiming.paused = msg.paused === true;
930
+ log(`Frame timing paused=${frameTiming.paused}`);
931
+ }
835
932
  else if (action === 'reset') {
836
933
  frameTiming.seeking = false;
837
934
  log('Frame timing reset (seek complete)');
838
935
  }
839
936
  sendAck(uid);
840
937
  }
938
+ function handleFrameStep(msg) {
939
+ const { direction, uid } = msg;
940
+ log(`FrameStep request dir=${direction} paused=${frameTiming.paused}`);
941
+ if (!frameTiming.paused) {
942
+ log(`FrameStep ignored (not paused)`);
943
+ sendAck(uid);
944
+ return;
945
+ }
946
+ const pipeline = getPrimaryVideoPipeline();
947
+ if (!pipeline || !pipeline.writer || pipeline.closed) {
948
+ log(`FrameStep ignored (pipeline missing or closed)`);
949
+ sendAck(uid);
950
+ return;
951
+ }
952
+ pipeline.frameHistory = pipeline.frameHistory ?? [];
953
+ if (pipeline.historyCursor === null || pipeline.historyCursor === undefined) {
954
+ alignHistoryCursorToLastOutput(pipeline);
955
+ }
956
+ log(`FrameStep pipeline idx=${pipeline.idx} outQueue=${pipeline.outputQueue.length} history=${pipeline.frameHistory.length} cursor=${pipeline.historyCursor}`);
957
+ if (direction < 0) {
958
+ const nextIndex = (pipeline.historyCursor ?? 0) - 1;
959
+ if (nextIndex < 0 || pipeline.frameHistory.length === 0) {
960
+ log(`FrameStep back: no history`);
961
+ sendAck(uid);
962
+ return;
963
+ }
964
+ pipeline.historyCursor = nextIndex;
965
+ const entry = pipeline.frameHistory[nextIndex];
966
+ const clone = entry ? cloneVideoFrame(entry.frame) : null;
967
+ if (!clone) {
968
+ log(`FrameStep back: failed to clone frame`);
969
+ sendAck(uid);
970
+ return;
971
+ }
972
+ log(`FrameStep back: output ts=${entry.timestamp}`);
973
+ outputFrame(pipeline, { frame: clone, timestamp: entry.timestamp, decodedAt: performance.now() }, { skipHistory: true });
974
+ sendAck(uid);
975
+ return;
976
+ }
977
+ if (direction > 0) {
978
+ // If we're stepping forward within history (after stepping back), use history
979
+ const cursor = pipeline.historyCursor;
980
+ if (cursor !== null && cursor !== undefined && cursor < pipeline.frameHistory.length - 1) {
981
+ pipeline.historyCursor = cursor + 1;
982
+ const entry = pipeline.frameHistory[pipeline.historyCursor];
983
+ const clone = entry ? cloneVideoFrame(entry.frame) : null;
984
+ if (!clone) {
985
+ log(`FrameStep forward: failed to clone frame`);
986
+ sendAck(uid);
987
+ return;
988
+ }
989
+ log(`FrameStep forward (history): output ts=${entry.timestamp}`);
990
+ outputFrame(pipeline, { frame: clone, timestamp: entry.timestamp, decodedAt: performance.now() }, { skipHistory: true });
991
+ sendAck(uid);
992
+ return;
993
+ }
994
+ // Otherwise, output the next queued frame
995
+ if (pipeline.outputQueue.length > 1) {
996
+ const wasSorted = pipeline.outputQueue.every((entry, i, arr) => i === 0 || arr[i - 1].timestamp <= entry.timestamp);
997
+ if (!wasSorted) {
998
+ pipeline.outputQueue.sort((a, b) => a.timestamp - b.timestamp);
999
+ }
1000
+ }
1001
+ const lastTs = pipeline.stats.lastOutputTimestamp;
1002
+ let idx = pipeline.outputQueue.findIndex(e => e.timestamp > lastTs);
1003
+ if (idx === -1 && pipeline.outputQueue.length > 0)
1004
+ idx = 0;
1005
+ if (idx === -1) {
1006
+ log(`FrameStep forward: no queued frame available`);
1007
+ sendAck(uid);
1008
+ return;
1009
+ }
1010
+ const entry = pipeline.outputQueue.splice(idx, 1)[0];
1011
+ log(`FrameStep forward (queue): output ts=${entry.timestamp}`);
1012
+ outputFrame(pipeline, entry);
1013
+ sendAck(uid);
1014
+ return;
1015
+ }
1016
+ sendAck(uid);
1017
+ }
841
1018
  // ============================================================================
842
1019
  // Cleanup
843
1020
  // ============================================================================