livekit-client 2.18.8 → 2.18.10

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 (193) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +5609 -644
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +2898 -2431
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.pt.worker.js +2 -0
  8. package/dist/livekit-client.pt.worker.js.map +1 -0
  9. package/dist/livekit-client.pt.worker.mjs +5834 -0
  10. package/dist/livekit-client.pt.worker.mjs.map +1 -0
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts +2 -1
  14. package/dist/src/api/SignalClient.d.ts.map +1 -1
  15. package/dist/src/e2ee/E2eeManager.d.ts +8 -7
  16. package/dist/src/e2ee/E2eeManager.d.ts.map +1 -1
  17. package/dist/src/e2ee/types.d.ts +35 -8
  18. package/dist/src/e2ee/types.d.ts.map +1 -1
  19. package/dist/src/e2ee/utils.d.ts +5 -5
  20. package/dist/src/e2ee/utils.d.ts.map +1 -1
  21. package/dist/src/e2ee/worker/DataCryptor.d.ts +5 -5
  22. package/dist/src/e2ee/worker/DataCryptor.d.ts.map +1 -1
  23. package/dist/src/e2ee/worker/FrameCryptor.d.ts +21 -4
  24. package/dist/src/e2ee/worker/FrameCryptor.d.ts.map +1 -1
  25. package/dist/src/e2ee/worker/naluUtils.d.ts +1 -1
  26. package/dist/src/e2ee/worker/naluUtils.d.ts.map +1 -1
  27. package/dist/src/e2ee/worker/sifPayload.d.ts +7 -7
  28. package/dist/src/e2ee/worker/sifPayload.d.ts.map +1 -1
  29. package/dist/src/index.d.ts +4 -1
  30. package/dist/src/index.d.ts.map +1 -1
  31. package/dist/src/options.d.ts +7 -0
  32. package/dist/src/options.d.ts.map +1 -1
  33. package/dist/src/packetTrailer/PacketTrailerManager.d.ts +49 -0
  34. package/dist/src/packetTrailer/PacketTrailerManager.d.ts.map +1 -0
  35. package/dist/src/packetTrailer/packetTrailer.d.ts +32 -0
  36. package/dist/src/packetTrailer/packetTrailer.d.ts.map +1 -0
  37. package/dist/src/packetTrailer/types.d.ts +57 -0
  38. package/dist/src/packetTrailer/types.d.ts.map +1 -0
  39. package/dist/src/packetTrailer/utils.d.ts +9 -0
  40. package/dist/src/packetTrailer/utils.d.ts.map +1 -0
  41. package/dist/src/packetTrailer/worker/packetTrailer.worker.d.ts +2 -0
  42. package/dist/src/packetTrailer/worker/packetTrailer.worker.d.ts.map +1 -0
  43. package/dist/src/room/RTCEngine.d.ts +2 -1
  44. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  45. package/dist/src/room/Room.d.ts +3 -1
  46. package/dist/src/room/Room.d.ts.map +1 -1
  47. package/dist/src/room/data-track/LocalDataTrack.d.ts +2 -1
  48. package/dist/src/room/data-track/LocalDataTrack.d.ts.map +1 -1
  49. package/dist/src/room/data-track/RemoteDataTrack.d.ts +5 -1
  50. package/dist/src/room/data-track/RemoteDataTrack.d.ts.map +1 -1
  51. package/dist/src/room/data-track/depacketizer.d.ts +12 -4
  52. package/dist/src/room/data-track/depacketizer.d.ts.map +1 -1
  53. package/dist/src/room/data-track/frame.d.ts +3 -3
  54. package/dist/src/room/data-track/frame.d.ts.map +1 -1
  55. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +3 -1
  56. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  57. package/dist/src/room/data-track/incoming/pipeline.d.ts +4 -1
  58. package/dist/src/room/data-track/incoming/pipeline.d.ts.map +1 -1
  59. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +2 -2
  60. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  61. package/dist/src/room/data-track/outgoing/types.d.ts +4 -3
  62. package/dist/src/room/data-track/outgoing/types.d.ts.map +1 -1
  63. package/dist/src/room/data-track/packet/extensions.d.ts +4 -4
  64. package/dist/src/room/data-track/packet/extensions.d.ts.map +1 -1
  65. package/dist/src/room/data-track/packet/index.d.ts +5 -5
  66. package/dist/src/room/data-track/packet/index.d.ts.map +1 -1
  67. package/dist/src/room/data-track/packet/serializable.d.ts +1 -1
  68. package/dist/src/room/data-track/packet/serializable.d.ts.map +1 -1
  69. package/dist/src/room/data-track/types.d.ts +7 -0
  70. package/dist/src/room/data-track/types.d.ts.map +1 -1
  71. package/dist/src/room/events.d.ts +2 -2
  72. package/dist/src/room/participant/LocalParticipant.d.ts +3 -1
  73. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  74. package/dist/src/room/participant/Participant.d.ts +1 -1
  75. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  76. package/dist/src/room/track/PacketTrailerExtractor.d.ts +19 -0
  77. package/dist/src/room/track/PacketTrailerExtractor.d.ts.map +1 -0
  78. package/dist/src/room/track/RemoteVideoTrack.d.ts +16 -0
  79. package/dist/src/room/track/RemoteVideoTrack.d.ts.map +1 -1
  80. package/dist/src/room/track/Track.d.ts +1 -1
  81. package/dist/src/room/track/Track.d.ts.map +1 -1
  82. package/dist/src/room/track/create.d.ts.map +1 -1
  83. package/dist/src/room/track/options.d.ts +10 -0
  84. package/dist/src/room/track/options.d.ts.map +1 -1
  85. package/dist/src/room/track/utils.d.ts.map +1 -1
  86. package/dist/src/room/utils.d.ts +4 -3
  87. package/dist/src/room/utils.d.ts.map +1 -1
  88. package/dist/src/test/MockMediaStreamTrack.d.ts.map +1 -1
  89. package/dist/src/utils/dataPacketBuffer.d.ts +1 -1
  90. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  91. package/dist/src/version.d.ts +1 -1
  92. package/dist/ts4.2/api/SignalClient.d.ts +2 -1
  93. package/dist/ts4.2/e2ee/E2eeManager.d.ts +8 -7
  94. package/dist/ts4.2/e2ee/types.d.ts +35 -8
  95. package/dist/ts4.2/e2ee/utils.d.ts +5 -5
  96. package/dist/ts4.2/e2ee/worker/DataCryptor.d.ts +5 -5
  97. package/dist/ts4.2/e2ee/worker/FrameCryptor.d.ts +21 -4
  98. package/dist/ts4.2/e2ee/worker/naluUtils.d.ts +1 -1
  99. package/dist/ts4.2/e2ee/worker/sifPayload.d.ts +7 -7
  100. package/dist/ts4.2/index.d.ts +5 -1
  101. package/dist/ts4.2/options.d.ts +7 -0
  102. package/dist/ts4.2/packetTrailer/PacketTrailerManager.d.ts +49 -0
  103. package/dist/ts4.2/packetTrailer/packetTrailer.d.ts +32 -0
  104. package/dist/ts4.2/packetTrailer/types.d.ts +57 -0
  105. package/dist/ts4.2/packetTrailer/utils.d.ts +9 -0
  106. package/dist/ts4.2/packetTrailer/worker/packetTrailer.worker.d.ts +2 -0
  107. package/dist/ts4.2/room/RTCEngine.d.ts +2 -1
  108. package/dist/ts4.2/room/Room.d.ts +3 -1
  109. package/dist/ts4.2/room/data-track/LocalDataTrack.d.ts +2 -1
  110. package/dist/ts4.2/room/data-track/RemoteDataTrack.d.ts +5 -1
  111. package/dist/ts4.2/room/data-track/depacketizer.d.ts +12 -4
  112. package/dist/ts4.2/room/data-track/frame.d.ts +3 -3
  113. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +3 -1
  114. package/dist/ts4.2/room/data-track/incoming/pipeline.d.ts +4 -1
  115. package/dist/ts4.2/room/data-track/outgoing/OutgoingDataTrackManager.d.ts +2 -2
  116. package/dist/ts4.2/room/data-track/outgoing/types.d.ts +4 -3
  117. package/dist/ts4.2/room/data-track/packet/extensions.d.ts +4 -4
  118. package/dist/ts4.2/room/data-track/packet/index.d.ts +5 -5
  119. package/dist/ts4.2/room/data-track/packet/serializable.d.ts +1 -1
  120. package/dist/ts4.2/room/data-track/types.d.ts +7 -0
  121. package/dist/ts4.2/room/events.d.ts +2 -2
  122. package/dist/ts4.2/room/participant/LocalParticipant.d.ts +3 -1
  123. package/dist/ts4.2/room/participant/Participant.d.ts +1 -1
  124. package/dist/ts4.2/room/track/PacketTrailerExtractor.d.ts +19 -0
  125. package/dist/ts4.2/room/track/RemoteVideoTrack.d.ts +16 -0
  126. package/dist/ts4.2/room/track/Track.d.ts +1 -1
  127. package/dist/ts4.2/room/track/options.d.ts +10 -0
  128. package/dist/ts4.2/room/utils.d.ts +4 -3
  129. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +1 -1
  130. package/dist/ts4.2/version.d.ts +1 -1
  131. package/package.json +24 -16
  132. package/src/api/SignalClient.test.ts +102 -10
  133. package/src/api/SignalClient.ts +4 -2
  134. package/src/api/WebSocketStream.test.ts +0 -1
  135. package/src/e2ee/E2eeManager.ts +82 -30
  136. package/src/e2ee/types.ts +37 -8
  137. package/src/e2ee/utils.ts +7 -6
  138. package/src/e2ee/worker/DataCryptor.ts +6 -6
  139. package/src/e2ee/worker/FrameCryptor.test.ts +177 -4
  140. package/src/e2ee/worker/FrameCryptor.ts +94 -14
  141. package/src/e2ee/worker/ParticipantKeyHandler.test.ts +4 -4
  142. package/src/e2ee/worker/e2ee.worker.ts +13 -5
  143. package/src/e2ee/worker/naluUtils.ts +4 -4
  144. package/src/e2ee/worker/sifPayload.ts +10 -8
  145. package/src/index.ts +7 -0
  146. package/src/options.ts +8 -0
  147. package/src/packetTrailer/PacketTrailerManager.test.ts +172 -0
  148. package/src/packetTrailer/PacketTrailerManager.ts +250 -0
  149. package/src/packetTrailer/packetTrailer.test.ts +174 -0
  150. package/src/packetTrailer/packetTrailer.ts +276 -0
  151. package/src/packetTrailer/types.ts +75 -0
  152. package/src/packetTrailer/utils.test.ts +105 -0
  153. package/src/packetTrailer/utils.ts +50 -0
  154. package/src/packetTrailer/worker/packetTrailer.worker.ts +155 -0
  155. package/src/packetTrailer/worker/tsconfig.json +14 -0
  156. package/src/room/BackOffStrategy.test.ts +1 -1
  157. package/src/room/RTCEngine.test.ts +219 -0
  158. package/src/room/RTCEngine.ts +86 -20
  159. package/src/room/Room.test.ts +62 -1
  160. package/src/room/Room.ts +28 -5
  161. package/src/room/data-track/LocalDataTrack.ts +15 -7
  162. package/src/room/data-track/RemoteDataTrack.ts +8 -1
  163. package/src/room/data-track/depacketizer.test.ts +433 -1
  164. package/src/room/data-track/depacketizer.ts +79 -61
  165. package/src/room/data-track/frame.ts +2 -2
  166. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +194 -0
  167. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +21 -1
  168. package/src/room/data-track/incoming/pipeline.ts +13 -2
  169. package/src/room/data-track/outgoing/OutgoingDataTrackManager.test.ts +350 -198
  170. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +9 -3
  171. package/src/room/data-track/outgoing/types.ts +4 -3
  172. package/src/room/data-track/packet/extensions.ts +2 -2
  173. package/src/room/data-track/packet/index.ts +6 -6
  174. package/src/room/data-track/packet/serializable.ts +1 -1
  175. package/src/room/data-track/types.ts +8 -0
  176. package/src/room/events.ts +2 -2
  177. package/src/room/participant/LocalParticipant.test.ts +81 -0
  178. package/src/room/participant/LocalParticipant.ts +48 -7
  179. package/src/room/participant/Participant.ts +1 -1
  180. package/src/room/participant/publishUtils.ts +1 -1
  181. package/src/room/track/PacketTrailerExtractor.ts +43 -0
  182. package/src/room/track/RemoteVideoTrack.ts +23 -2
  183. package/src/room/track/Track.ts +1 -1
  184. package/src/room/track/create.ts +0 -4
  185. package/src/room/track/options.ts +11 -0
  186. package/src/room/track/record.ts +1 -1
  187. package/src/room/track/utils.ts +4 -1
  188. package/src/room/utils.test.ts +14 -1
  189. package/src/room/utils.ts +17 -3
  190. package/src/test/MockMediaStreamTrack.ts +0 -1
  191. package/src/type-polyfills/non-shared-typed-arrays.d.ts +6 -0
  192. package/src/utils/dataPacketBuffer.ts +1 -1
  193. package/src/version.ts +1 -1
@@ -0,0 +1,250 @@
1
+ import type { TrackInfo } from '@livekit/protocol';
2
+ import log from '../logger';
3
+ import type Room from '../room/Room';
4
+ import { RoomEvent } from '../room/events';
5
+ import { PacketTrailerExtractor } from '../room/track/PacketTrailerExtractor';
6
+ import type RemoteTrack from '../room/track/RemoteTrack';
7
+ import RemoteVideoTrack from '../room/track/RemoteVideoTrack';
8
+ import type { PTDecodeMessage, PTUpdateTrackIdMessage, PTWorkerMessage } from './types';
9
+ import { isPacketTrailerSupported, shouldUsePacketTrailerScriptTransform } from './utils';
10
+
11
+ export interface PacketTrailerOptions {
12
+ /**
13
+ * Dedicated worker for extracting packet trailers off the main thread.
14
+ *
15
+ * Encoded video streams are transferred to the worker for processing, which
16
+ * avoids per-frame work on the main thread.
17
+ */
18
+ worker: Worker;
19
+ }
20
+
21
+ /**
22
+ * Manages packet trailer extraction for received video tracks.
23
+ *
24
+ * When a track's TrackInfo indicates packet trailer features, the manager
25
+ * wires up an encoded frame transform to strip the trailer from encoded frames
26
+ * and cache the metadata for lookup.
27
+ *
28
+ * Packet trailer extraction is worker-only. If no worker is configured, the
29
+ * SDK does not advertise packet trailer support and skips extraction.
30
+ *
31
+ * When E2EE is active, the E2EE FrameCryptor worker handles trailer
32
+ * extraction directly (before decryption), so this manager only creates
33
+ * the extractor/metadata cache — no separate pipeline is installed.
34
+ *
35
+ * @experimental
36
+ */
37
+ export class PacketTrailerManager {
38
+ private worker?: Worker;
39
+
40
+ private room?: Room;
41
+
42
+ private extractors = new Map<string, PacketTrailerExtractor>();
43
+
44
+ /**
45
+ * Tracks the trackId associated with each receiver that has had its
46
+ * encoded streams handed off to the worker. Used to detect receiver
47
+ * reuse (transceiver recycling) so we can remap trackIds instead of
48
+ * re-transferring already-consumed streams.
49
+ */
50
+ private workerPipelines = new Map<RTCRtpReceiver, string>();
51
+
52
+ constructor(options?: PacketTrailerOptions) {
53
+ this.worker = options?.worker;
54
+ }
55
+
56
+ /** @internal */
57
+ setup(room: Room) {
58
+ if (room === this.room) {
59
+ return;
60
+ }
61
+ this.room = room;
62
+
63
+ if (this.worker) {
64
+ this.worker.onmessage = this.onWorkerMessage;
65
+ this.worker.onerror = this.onWorkerError;
66
+ this.worker.postMessage({ kind: 'init' });
67
+ }
68
+
69
+ room
70
+ .on(RoomEvent.TrackSubscribed, (track, pub, _participant) => {
71
+ if (track.kind !== 'video') {
72
+ return;
73
+ }
74
+ this.setupReceiver(track as unknown as RemoteVideoTrack, pub.trackInfo);
75
+ })
76
+ .on(RoomEvent.TrackUnsubscribed, (track) => {
77
+ this.teardownTrack(track);
78
+ })
79
+ .on(RoomEvent.Disconnected, () => {
80
+ this.cleanup();
81
+ });
82
+ }
83
+
84
+ private setupReceiver(track: RemoteVideoTrack, trackInfo?: TrackInfo) {
85
+ const receiver = track.receiver;
86
+ if (!receiver) {
87
+ return;
88
+ }
89
+
90
+ // Only install a pipeline for tracks that actually advertise packet
91
+ // trailer features. This keeps us out of the way for tracks published by
92
+ // clients on older protocols or that don't opt into the feature.
93
+ const hasFeatures =
94
+ !!trackInfo?.packetTrailerFeatures && trackInfo.packetTrailerFeatures.length > 0;
95
+ if (!hasFeatures) {
96
+ if (!this.room?.hasE2EESetup) {
97
+ this.setupPassthroughReceiver(receiver, track.mediaStreamID);
98
+ }
99
+ return;
100
+ }
101
+
102
+ if (
103
+ !isPacketTrailerSupported(this.worker ? { worker: this.worker } : undefined) &&
104
+ !this.room?.hasE2EESetup
105
+ ) {
106
+ log.warn('packet trailer transform not supported; skipping extraction');
107
+ return;
108
+ }
109
+
110
+ const extractor = new PacketTrailerExtractor();
111
+ const trackId = track.mediaStreamID;
112
+
113
+ this.extractors.set(trackId, extractor);
114
+ track.packetTrailerExtractor = extractor;
115
+
116
+ if (this.room?.hasE2EESetup) {
117
+ // E2EE worker strips the trailer and injects metadata directly into
118
+ // the extractor via E2eeManager; no pipeline is needed here.
119
+ return;
120
+ }
121
+
122
+ this.setupWorkerReceiver(receiver, trackId, true);
123
+ }
124
+
125
+ private setupPassthroughReceiver(receiver: RTCRtpReceiver, trackId: string) {
126
+ if (shouldUsePacketTrailerScriptTransform()) {
127
+ if ('transform' in receiver) {
128
+ // @ts-ignore
129
+ receiver.transform = null;
130
+ }
131
+ return;
132
+ }
133
+
134
+ if (
135
+ this.worker &&
136
+ isPacketTrailerSupported({ worker: this.worker }) &&
137
+ !this.workerPipelines.has(receiver)
138
+ ) {
139
+ this.setupWorkerReceiver(receiver, trackId, false);
140
+ return;
141
+ }
142
+
143
+ if (this.worker && this.workerPipelines.has(receiver)) {
144
+ this.setupWorkerReceiver(receiver, trackId, false);
145
+ }
146
+ }
147
+
148
+ private setupWorkerReceiver(
149
+ receiver: RTCRtpReceiver,
150
+ newTrackId: string,
151
+ hasPacketTrailer = true,
152
+ ) {
153
+ const worker = this.worker;
154
+ if (!worker) {
155
+ return;
156
+ }
157
+
158
+ if (shouldUsePacketTrailerScriptTransform()) {
159
+ // @ts-ignore
160
+ receiver.transform = new RTCRtpScriptTransform(worker, {
161
+ kind: 'decode',
162
+ trackId: newTrackId,
163
+ });
164
+ return;
165
+ }
166
+
167
+ const existingTrackId = this.workerPipelines.get(receiver);
168
+
169
+ if (existingTrackId) {
170
+ // Receiver is reused (transceiver recycled). The worker already owns
171
+ // the encoded streams — just remap the trackId so metadata is keyed
172
+ // correctly and re-activate processing.
173
+ const msg: PTUpdateTrackIdMessage = {
174
+ kind: 'updateTrackId',
175
+ data: { oldTrackId: existingTrackId, newTrackId, hasPacketTrailer },
176
+ };
177
+ worker.postMessage(msg);
178
+ this.workerPipelines.set(receiver, newTrackId);
179
+ return;
180
+ }
181
+
182
+ if (!('createEncodedStreams' in receiver)) {
183
+ log.warn('createEncodedStreams not supported');
184
+ return;
185
+ }
186
+
187
+ let streams: { readable: ReadableStream; writable: WritableStream };
188
+ try {
189
+ // @ts-ignore — createEncodedStreams is not in standard typings
190
+ streams = receiver.createEncodedStreams();
191
+ } catch (err) {
192
+ log.warn('failed to create encoded streams', { error: err });
193
+ return;
194
+ }
195
+
196
+ const msg: PTDecodeMessage = {
197
+ kind: 'decode',
198
+ data: {
199
+ readableStream: streams.readable,
200
+ writableStream: streams.writable,
201
+ trackId: newTrackId,
202
+ hasPacketTrailer,
203
+ },
204
+ };
205
+ worker.postMessage(msg, [streams.readable, streams.writable]);
206
+ this.workerPipelines.set(receiver, newTrackId);
207
+ }
208
+
209
+ private teardownTrack(track: RemoteTrack) {
210
+ const trackId = track.mediaStreamID;
211
+ const extractor = this.extractors.get(trackId);
212
+ if (extractor) {
213
+ extractor.dispose();
214
+ this.extractors.delete(trackId);
215
+ }
216
+
217
+ if (track instanceof RemoteVideoTrack) {
218
+ track.packetTrailerExtractor = undefined;
219
+ }
220
+
221
+ // The receiver pipeline is intentionally left running. If the receiver is
222
+ // reused for a new track, `setupReceiver` will remap it. If the room
223
+ // disconnects, `cleanup` drops all state. Any metadata produced in the
224
+ // meantime is harmless — the extractor above has already been disposed and
225
+ // is no longer reachable from any track.
226
+ }
227
+
228
+ private cleanup() {
229
+ for (const extractor of this.extractors.values()) {
230
+ extractor.dispose();
231
+ }
232
+ this.extractors.clear();
233
+ this.workerPipelines.clear();
234
+ this.worker?.terminate();
235
+ }
236
+
237
+ private onWorkerMessage = (ev: MessageEvent<PTWorkerMessage>) => {
238
+ const msg = ev.data;
239
+ if (msg.kind === 'metadata') {
240
+ const extractor = this.extractors.get(msg.data.trackId);
241
+ if (extractor) {
242
+ extractor.storeMetadata(msg.data.rtpTimestamp, msg.data.ssrc, msg.data.metadata);
243
+ }
244
+ }
245
+ };
246
+
247
+ private onWorkerError = (ev: ErrorEvent) => {
248
+ log.error('packet trailer worker encountered an error:', { error: ev.error });
249
+ };
250
+ }
@@ -0,0 +1,174 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ appendPacketTrailer,
4
+ appendPacketTrailerToEncodedFrame,
5
+ extractPacketTrailer,
6
+ processPacketTrailer,
7
+ } from './packetTrailer';
8
+
9
+ describe('packetTrailer', () => {
10
+ afterEach(() => {
11
+ vi.useRealTimers();
12
+ });
13
+
14
+ it('extracts user timestamp and frame id from packet trailer', () => {
15
+ const payload = Uint8Array.from([1, 2, 3, 4]);
16
+ const trailer = appendPacketTrailer(payload, 1_744_249_600_123_456n, 42);
17
+ const extracted = extractPacketTrailer(trailer);
18
+
19
+ expect(Array.from(extracted.data)).toEqual(Array.from(payload));
20
+ expect(extracted.metadata).toEqual({
21
+ userTimestamp: 1_744_249_600_123_456n,
22
+ frameId: 42,
23
+ });
24
+ });
25
+
26
+ it('extracts timestamp-only trailer when frameId is 0', () => {
27
+ const payload = Uint8Array.from([1, 2, 3, 4]);
28
+ const trailer = appendPacketTrailer(payload, 1_744_249_600_123_456n, 0);
29
+ const extracted = extractPacketTrailer(trailer);
30
+
31
+ expect(Array.from(extracted.data)).toEqual(Array.from(payload));
32
+ expect(extracted.metadata).toEqual({
33
+ userTimestamp: 1_744_249_600_123_456n,
34
+ frameId: 0,
35
+ });
36
+ });
37
+
38
+ it('extracts frameId-only trailer when timestamp is 0', () => {
39
+ const payload = Uint8Array.from([1, 2, 3, 4]);
40
+ const trailer = appendPacketTrailer(payload, 0n, 42);
41
+ const extracted = extractPacketTrailer(trailer);
42
+
43
+ expect(Array.from(extracted.data)).toEqual(Array.from(payload));
44
+ expect(extracted.metadata).toEqual({
45
+ userTimestamp: 0n,
46
+ frameId: 42,
47
+ });
48
+ });
49
+
50
+ it('returns data unchanged when both timestamp and frameId are 0', () => {
51
+ const payload = Uint8Array.from([1, 2, 3, 4]);
52
+ const result = appendPacketTrailer(payload, 0n, 0);
53
+
54
+ expect(Array.from(result)).toEqual(Array.from(payload));
55
+ });
56
+
57
+ it('passes frames through when there is no valid trailer', () => {
58
+ const payload = Uint8Array.from([1, 2, 3, 4, 5]);
59
+ const extracted = extractPacketTrailer(payload);
60
+
61
+ expect(Array.from(extracted.data)).toEqual(Array.from(payload));
62
+ expect(extracted.metadata).toBeUndefined();
63
+ });
64
+
65
+ it('uses the encoded frame timestamp when metadata does not include an RTP timestamp', () => {
66
+ const payload = Uint8Array.from([1, 2, 3, 4]);
67
+ const trailer = appendPacketTrailer(payload, 1_744_249_600_123_456n, 42);
68
+ const frame = {
69
+ data: trailer.buffer,
70
+ timestamp: 1234,
71
+ getMetadata() {
72
+ return {};
73
+ },
74
+ } as unknown as RTCEncodedVideoFrame;
75
+
76
+ const result = processPacketTrailer(frame, 'track-id');
77
+
78
+ expect(result.payload).toEqual({
79
+ trackId: 'track-id',
80
+ rtpTimestamp: 1234,
81
+ ssrc: 0,
82
+ metadata: {
83
+ userTimestamp: 1_744_249_600_123_456n,
84
+ frameId: 42,
85
+ },
86
+ });
87
+ });
88
+
89
+ it('appends timestamp-only packet trailer to encoded frames', () => {
90
+ vi.useFakeTimers();
91
+ vi.setSystemTime(new Date('2025-04-10T12:00:00.123Z'));
92
+ const payload = Uint8Array.from([1, 2, 3, 4]);
93
+ const frame = { data: payload.buffer } as RTCEncodedVideoFrame;
94
+
95
+ appendPacketTrailerToEncodedFrame(frame, { timestamp: true }, 0);
96
+ const extracted = extractPacketTrailer(frame.data);
97
+
98
+ expect(Array.from(extracted.data)).toEqual(Array.from(payload));
99
+ expect(frame.data.byteLength).toBe(payload.byteLength + 15);
100
+ expect(extracted.metadata).toEqual({
101
+ userTimestamp: BigInt(Date.now()) * BigInt(1000),
102
+ frameId: 0,
103
+ });
104
+ });
105
+
106
+ it('appends frame-id-only packet trailer to encoded frames', () => {
107
+ const payload = Uint8Array.from([1, 2, 3, 4]);
108
+ const firstFrame = { data: payload.buffer.slice(0) } as RTCEncodedVideoFrame;
109
+ const secondFrame = { data: payload.buffer.slice(0) } as RTCEncodedVideoFrame;
110
+
111
+ appendPacketTrailerToEncodedFrame(firstFrame, { frameId: true }, 1);
112
+ appendPacketTrailerToEncodedFrame(secondFrame, { frameId: true }, 2);
113
+
114
+ expect(firstFrame.data.byteLength).toBe(payload.byteLength + 11);
115
+ expect(secondFrame.data.byteLength).toBe(payload.byteLength + 11);
116
+ expect(extractPacketTrailer(firstFrame.data).metadata).toEqual({
117
+ userTimestamp: 0n,
118
+ frameId: 1,
119
+ });
120
+ expect(extractPacketTrailer(secondFrame.data).metadata).toEqual({
121
+ userTimestamp: 0n,
122
+ frameId: 2,
123
+ });
124
+ });
125
+
126
+ it('appends both timestamp and frame id to encoded frames', () => {
127
+ vi.useFakeTimers();
128
+ vi.setSystemTime(new Date('2025-04-10T12:00:00.123Z'));
129
+ const payload = Uint8Array.from([1, 2, 3, 4]);
130
+ const frame = { data: payload.buffer } as RTCEncodedVideoFrame;
131
+
132
+ appendPacketTrailerToEncodedFrame(frame, { timestamp: true, frameId: true }, 7);
133
+
134
+ expect(extractPacketTrailer(frame.data).metadata).toEqual({
135
+ userTimestamp: BigInt(Date.now()) * BigInt(1000),
136
+ frameId: 7,
137
+ });
138
+ });
139
+
140
+ it.each([{}, { timestamp: false, frameId: false }])(
141
+ 'passes encoded frames through when no write features are enabled: %o',
142
+ (packetTrailer) => {
143
+ const payload = Uint8Array.from([1, 2, 3, 4]);
144
+ const frame = { data: payload.buffer } as RTCEncodedVideoFrame;
145
+
146
+ const changed = appendPacketTrailerToEncodedFrame(frame, packetTrailer, 1);
147
+
148
+ expect(changed).toBe(false);
149
+ expect(frame.data).toBe(payload.buffer);
150
+ expect(extractPacketTrailer(frame.data).metadata).toBeUndefined();
151
+ },
152
+ );
153
+
154
+ it('passes encoded frames through when publish options omit enabled features', () => {
155
+ const payload = Uint8Array.from([1, 2, 3, 4]);
156
+ const frame = { data: payload.buffer } as RTCEncodedVideoFrame;
157
+
158
+ const changed = appendPacketTrailerToEncodedFrame(frame, { timestamp: false }, 1);
159
+
160
+ expect(changed).toBe(false);
161
+ expect(frame.data).toBe(payload.buffer);
162
+ expect(extractPacketTrailer(frame.data).metadata).toBeUndefined();
163
+ });
164
+
165
+ it('passes encoded frames through when publish options are undefined', () => {
166
+ const payload = Uint8Array.from([1, 2, 3, 4]);
167
+ const frame = { data: payload.buffer } as RTCEncodedVideoFrame;
168
+
169
+ const changed = appendPacketTrailerToEncodedFrame(frame, undefined, 1);
170
+
171
+ expect(changed).toBe(false);
172
+ expect(frame.data).toBe(payload.buffer);
173
+ });
174
+ });
@@ -0,0 +1,276 @@
1
+ import type { PacketTrailerMetadata, PacketTrailerPublishOptions } from './types';
2
+ import { hasPacketTrailerPublishOptions } from './utils';
3
+
4
+ export const PACKET_TRAILER_MAGIC = Uint8Array.from([
5
+ 'L'.charCodeAt(0),
6
+ 'K'.charCodeAt(0),
7
+ 'T'.charCodeAt(0),
8
+ 'S'.charCodeAt(0),
9
+ ]);
10
+
11
+ export const PACKET_TRAILER_TIMESTAMP_TAG = 0x01;
12
+ export const PACKET_TRAILER_FRAME_ID_TAG = 0x02;
13
+ export const PACKET_TRAILER_ENVELOPE_SIZE = 5;
14
+
15
+ const TIMESTAMP_TLV_SIZE = 10;
16
+ const FRAME_ID_TLV_SIZE = 6;
17
+
18
+ export interface ExtractPacketTrailerResult {
19
+ data: Uint8Array;
20
+ metadata?: PacketTrailerMetadata;
21
+ }
22
+
23
+ export function appendPacketTrailer(
24
+ data: Uint8Array,
25
+ userTimestamp: bigint,
26
+ frameId: number,
27
+ ): Uint8Array {
28
+ const hasTimestamp = userTimestamp !== BigInt(0);
29
+ const hasFrameId = frameId !== 0;
30
+
31
+ if (!hasTimestamp && !hasFrameId) {
32
+ return data;
33
+ }
34
+
35
+ const trailerLength =
36
+ (hasTimestamp ? TIMESTAMP_TLV_SIZE : 0) +
37
+ (hasFrameId ? FRAME_ID_TLV_SIZE : 0) +
38
+ PACKET_TRAILER_ENVELOPE_SIZE;
39
+ const result = new Uint8Array(data.length + trailerLength);
40
+ let offset = 0;
41
+
42
+ result.set(data, offset);
43
+ offset += data.length;
44
+
45
+ if (hasTimestamp) {
46
+ result[offset++] = PACKET_TRAILER_TIMESTAMP_TAG ^ 0xff;
47
+ result[offset++] = 8 ^ 0xff;
48
+ writeUint64Xor(result, offset, userTimestamp);
49
+ offset += 8;
50
+ }
51
+
52
+ if (hasFrameId) {
53
+ result[offset++] = PACKET_TRAILER_FRAME_ID_TAG ^ 0xff;
54
+ result[offset++] = 4 ^ 0xff;
55
+ writeUint32Xor(result, offset, frameId);
56
+ offset += 4;
57
+ }
58
+
59
+ result[offset++] = trailerLength ^ 0xff;
60
+ result.set(PACKET_TRAILER_MAGIC, offset);
61
+
62
+ return result;
63
+ }
64
+
65
+ export function appendPacketTrailerToEncodedFrame(
66
+ frame: RTCEncodedVideoFrame,
67
+ options: PacketTrailerPublishOptions | undefined,
68
+ frameId: number,
69
+ ): boolean {
70
+ if (!hasPacketTrailerPublishOptions(options) || frame.data.byteLength === 0) {
71
+ return false;
72
+ }
73
+
74
+ const userTimestamp = options?.timestamp ? BigInt(Date.now()) * BigInt(1000) : BigInt(0);
75
+ const packetTrailerFrameId = options?.frameId ? frameId : 0;
76
+ const data = new Uint8Array(frame.data);
77
+ const result = appendPacketTrailer(data, userTimestamp, packetTrailerFrameId);
78
+
79
+ if (result.byteLength === data.byteLength) {
80
+ return false;
81
+ }
82
+
83
+ frame.data = result.buffer.slice(
84
+ result.byteOffset,
85
+ result.byteOffset + result.byteLength,
86
+ ) as ArrayBuffer;
87
+ return true;
88
+ }
89
+
90
+ export function extractPacketTrailer(data: ArrayBuffer | Uint8Array): ExtractPacketTrailerResult {
91
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
92
+ if (bytes.length < PACKET_TRAILER_ENVELOPE_SIZE) {
93
+ return { data: bytes };
94
+ }
95
+
96
+ const magicOffset = bytes.length - PACKET_TRAILER_MAGIC.length;
97
+ if (!matchesMagic(bytes, magicOffset)) {
98
+ return { data: bytes };
99
+ }
100
+
101
+ const trailerLength = bytes[bytes.length - PACKET_TRAILER_ENVELOPE_SIZE] ^ 0xff;
102
+ if (trailerLength < PACKET_TRAILER_ENVELOPE_SIZE || trailerLength > bytes.length) {
103
+ return { data: bytes };
104
+ }
105
+
106
+ const trailerStart = bytes.length - trailerLength;
107
+ const trailerEnd = bytes.length - PACKET_TRAILER_ENVELOPE_SIZE;
108
+ const strippedData = bytes.subarray(0, trailerStart);
109
+ let offset = trailerStart;
110
+ let foundAny = false;
111
+ const metadata: PacketTrailerMetadata = {
112
+ userTimestamp: BigInt(0),
113
+ frameId: 0,
114
+ };
115
+
116
+ while (offset + 2 <= trailerEnd) {
117
+ const tag = bytes[offset++] ^ 0xff;
118
+ const length = bytes[offset++] ^ 0xff;
119
+
120
+ if (offset + length > trailerEnd) {
121
+ break;
122
+ }
123
+
124
+ if (tag === PACKET_TRAILER_TIMESTAMP_TAG && length === 8) {
125
+ metadata.userTimestamp = readUint64Xor(bytes, offset);
126
+ foundAny = true;
127
+ } else if (tag === PACKET_TRAILER_FRAME_ID_TAG && length === 4) {
128
+ metadata.frameId = readUint32Xor(bytes, offset, length);
129
+ foundAny = true;
130
+ }
131
+
132
+ offset += length;
133
+ }
134
+
135
+ if (!foundAny) {
136
+ return { data: bytes };
137
+ }
138
+
139
+ return { data: strippedData, metadata };
140
+ }
141
+
142
+ function matchesMagic(data: Uint8Array, offset: number) {
143
+ for (let index = 0; index < PACKET_TRAILER_MAGIC.length; index += 1) {
144
+ if (data[offset + index] !== PACKET_TRAILER_MAGIC[index]) {
145
+ return false;
146
+ }
147
+ }
148
+ return true;
149
+ }
150
+
151
+ function readUint64Xor(data: Uint8Array, offset: number): bigint {
152
+ const hi = BigInt(
153
+ (((data[offset] ^ 0xff) << 24) |
154
+ ((data[offset + 1] ^ 0xff) << 16) |
155
+ ((data[offset + 2] ^ 0xff) << 8) |
156
+ (data[offset + 3] ^ 0xff)) >>>
157
+ 0,
158
+ );
159
+ const lo = BigInt(
160
+ (((data[offset + 4] ^ 0xff) << 24) |
161
+ ((data[offset + 5] ^ 0xff) << 16) |
162
+ ((data[offset + 6] ^ 0xff) << 8) |
163
+ (data[offset + 7] ^ 0xff)) >>>
164
+ 0,
165
+ );
166
+ return (hi << BigInt(32)) | lo;
167
+ }
168
+
169
+ function readUint32Xor(data: Uint8Array, offset: number, length: number) {
170
+ let value = 0;
171
+ for (let index = 0; index < length; index += 1) {
172
+ value = (value << 8) | (data[offset + index] ^ 0xff);
173
+ }
174
+ return value >>> 0;
175
+ }
176
+
177
+ function writeUint64Xor(target: Uint8Array, offset: number, value: bigint) {
178
+ const hi = Number((value >> BigInt(32)) & BigInt(0xffffffff));
179
+ const lo = Number(value & BigInt(0xffffffff));
180
+ target[offset] = (hi >>> 24) ^ 0xff;
181
+ target[offset + 1] = ((hi >>> 16) & 0xff) ^ 0xff;
182
+ target[offset + 2] = ((hi >>> 8) & 0xff) ^ 0xff;
183
+ target[offset + 3] = (hi & 0xff) ^ 0xff;
184
+ target[offset + 4] = (lo >>> 24) ^ 0xff;
185
+ target[offset + 5] = ((lo >>> 16) & 0xff) ^ 0xff;
186
+ target[offset + 6] = ((lo >>> 8) & 0xff) ^ 0xff;
187
+ target[offset + 7] = (lo & 0xff) ^ 0xff;
188
+ }
189
+
190
+ function writeUint32Xor(target: Uint8Array, offset: number, value: number) {
191
+ for (let index = 3; index >= 0; index -= 1) {
192
+ target[offset + (3 - index)] = ((value >> (index * 8)) & 0xff) ^ 0xff;
193
+ }
194
+ }
195
+
196
+ export function getFrameRtpTimestamp(
197
+ frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
198
+ ): number | undefined {
199
+ try {
200
+ const metadata = frame.getMetadata() as Record<string, unknown>;
201
+ if (typeof metadata.rtpTimestamp === 'number') {
202
+ return metadata.rtpTimestamp;
203
+ }
204
+ if (typeof metadata.timestamp === 'number') {
205
+ return metadata.timestamp;
206
+ }
207
+ } catch {
208
+ // getMetadata() might not be available
209
+ }
210
+ if (typeof frame.timestamp === 'number') {
211
+ return frame.timestamp;
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ export function getFrameSsrc(frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame): number {
217
+ try {
218
+ const metadata = frame.getMetadata() as Record<string, unknown>;
219
+ if (typeof metadata.synchronizationSource === 'number') {
220
+ return metadata.synchronizationSource;
221
+ }
222
+ } catch {}
223
+ return 0;
224
+ }
225
+
226
+ export interface PacketTrailerFramePayload {
227
+ trackId: string;
228
+ rtpTimestamp: number;
229
+ ssrc: number;
230
+ metadata: PacketTrailerMetadata;
231
+ }
232
+
233
+ export interface ProcessPacketTrailerResult {
234
+ data?: ArrayBuffer;
235
+ payload?: PacketTrailerFramePayload;
236
+ }
237
+
238
+ /**
239
+ * Extracts a packet trailer from an encoded frame and returns the stripped
240
+ * frame data (if any) along with a ready-to-post metadata payload. Returns an
241
+ * empty object when no trailer is present, an RTP timestamp can't be read, or
242
+ * a trackId isn't available.
243
+ */
244
+ export function processPacketTrailer(
245
+ frame: RTCEncodedVideoFrame | RTCEncodedAudioFrame,
246
+ trackId: string | undefined,
247
+ ): ProcessPacketTrailerResult {
248
+ if (frame.data.byteLength === 0) {
249
+ return {};
250
+ }
251
+
252
+ const result = extractPacketTrailer(frame.data);
253
+ if (!result.metadata) {
254
+ return {};
255
+ }
256
+
257
+ const strippedData = (result.data.buffer as ArrayBuffer).slice(
258
+ result.data.byteOffset,
259
+ result.data.byteOffset + result.data.byteLength,
260
+ );
261
+
262
+ const rtpTimestamp = getFrameRtpTimestamp(frame);
263
+ if (rtpTimestamp === undefined || !trackId) {
264
+ return { data: strippedData };
265
+ }
266
+
267
+ return {
268
+ data: strippedData,
269
+ payload: {
270
+ trackId,
271
+ rtpTimestamp,
272
+ ssrc: getFrameSsrc(frame),
273
+ metadata: result.metadata,
274
+ },
275
+ };
276
+ }