@voltkit-rtc/client 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +38 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +40 -7
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/recording.d.ts +91 -0
- package/dist/cjs/recording.d.ts.map +1 -0
- package/dist/cjs/recording.js +478 -0
- package/dist/cjs/recording.js.map +1 -0
- package/dist/cjs/stats.d.ts +16 -2
- package/dist/cjs/stats.d.ts.map +1 -1
- package/dist/cjs/stats.js +53 -5
- package/dist/cjs/stats.js.map +1 -1
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +36 -6
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/recording.d.ts +91 -0
- package/dist/esm/recording.d.ts.map +1 -0
- package/dist/esm/recording.js +471 -0
- package/dist/esm/recording.js.map +1 -0
- package/dist/esm/stats.d.ts +16 -2
- package/dist/esm/stats.d.ts.map +1 -1
- package/dist/esm/stats.js +53 -5
- package/dist/esm/stats.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +29 -6
- package/src/recording.ts +571 -0
- package/src/stats.ts +51 -6
package/src/recording.ts
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
/** Paid device-to-R2 recording. No storage secrets or media pass through the API. */
|
|
2
|
+
export type RecordingState =
|
|
3
|
+
| "starting"
|
|
4
|
+
| "recording"
|
|
5
|
+
| "backpressure"
|
|
6
|
+
| "uploading"
|
|
7
|
+
| "ready"
|
|
8
|
+
| "stopped"
|
|
9
|
+
| "error";
|
|
10
|
+
export interface RecordingEvent {
|
|
11
|
+
state: RecordingState;
|
|
12
|
+
bufferedBytes: number;
|
|
13
|
+
uploadedBytes: number;
|
|
14
|
+
recordingId?: string;
|
|
15
|
+
error?: Error;
|
|
16
|
+
}
|
|
17
|
+
export interface RecordingFile {
|
|
18
|
+
playbackUrl?: string;
|
|
19
|
+
retainUntil?: string;
|
|
20
|
+
durationSeconds?: number;
|
|
21
|
+
id: string;
|
|
22
|
+
state: string;
|
|
23
|
+
profileId: string;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
frameRate: number;
|
|
27
|
+
videoBitrate: number;
|
|
28
|
+
audioBitrate: number;
|
|
29
|
+
}
|
|
30
|
+
export interface RecordingSession {
|
|
31
|
+
recording: RecordingFile;
|
|
32
|
+
token: string;
|
|
33
|
+
chunkBytes: number;
|
|
34
|
+
uploadConcurrency: number;
|
|
35
|
+
maxBufferBytes: number;
|
|
36
|
+
segmentSeconds: number;
|
|
37
|
+
expiresAt: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RecordingOptions {
|
|
40
|
+
apiUrl: string;
|
|
41
|
+
chunkMiB?: 5 | 10;
|
|
42
|
+
onState?: (event: RecordingEvent) => void;
|
|
43
|
+
onRecordingReady?: (file: RecordingFile) => void;
|
|
44
|
+
}
|
|
45
|
+
export class RecordingError extends Error {
|
|
46
|
+
constructor(
|
|
47
|
+
message: string,
|
|
48
|
+
readonly status = 0,
|
|
49
|
+
) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "RecordingError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const delay = (ms: number) =>
|
|
55
|
+
new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
56
|
+
export async function recordingRequest<T>(
|
|
57
|
+
base: string,
|
|
58
|
+
path: string,
|
|
59
|
+
body: unknown,
|
|
60
|
+
token?: string,
|
|
61
|
+
): Promise<T> {
|
|
62
|
+
const response = await fetch(
|
|
63
|
+
`${base.replace(/\/$/, "")}/recording/upload${path}`,
|
|
64
|
+
{
|
|
65
|
+
method: "POST",
|
|
66
|
+
credentials: "omit",
|
|
67
|
+
headers: {
|
|
68
|
+
"Content-Type": "application/json",
|
|
69
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify(body),
|
|
72
|
+
signal: AbortSignal.timeout(path.endsWith("/complete") ? 180000 : 15000),
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
const result = (await response.json()) as {
|
|
76
|
+
success: boolean;
|
|
77
|
+
data?: T;
|
|
78
|
+
error?: { message: string };
|
|
79
|
+
};
|
|
80
|
+
if (!response.ok || !result.success || !result.data)
|
|
81
|
+
throw new RecordingError(
|
|
82
|
+
result.error?.message ?? "Recording request failed",
|
|
83
|
+
response.status,
|
|
84
|
+
);
|
|
85
|
+
return result.data;
|
|
86
|
+
}
|
|
87
|
+
/** Bounded uploader, also usable with an externally encoded native/file source. */
|
|
88
|
+
export class RecordingUploader {
|
|
89
|
+
private queue: Blob[] = [];
|
|
90
|
+
private pendingBytes = 0;
|
|
91
|
+
private bytes = 0;
|
|
92
|
+
private uploaded = 0;
|
|
93
|
+
private number = 0;
|
|
94
|
+
private readonly active = new Set<Promise<void>>();
|
|
95
|
+
private reserve: Promise<unknown> = Promise.resolve();
|
|
96
|
+
private failure: Error | null = null;
|
|
97
|
+
private finishing = false;
|
|
98
|
+
private sealed = false;
|
|
99
|
+
private canceled = false;
|
|
100
|
+
private readonly aborter = new AbortController();
|
|
101
|
+
private constructor(
|
|
102
|
+
readonly session: RecordingSession,
|
|
103
|
+
private readonly options: RecordingOptions,
|
|
104
|
+
) {}
|
|
105
|
+
static async create(
|
|
106
|
+
options: RecordingOptions,
|
|
107
|
+
roomToken: string,
|
|
108
|
+
mimeType: string,
|
|
109
|
+
): Promise<RecordingUploader> {
|
|
110
|
+
const url = new URL(options.apiUrl);
|
|
111
|
+
if (
|
|
112
|
+
url.protocol !== "https:" &&
|
|
113
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
114
|
+
)
|
|
115
|
+
throw new RecordingError("Recording API must use HTTPS");
|
|
116
|
+
const requestId = crypto.randomUUID();
|
|
117
|
+
let session: RecordingSession | undefined;
|
|
118
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
119
|
+
try {
|
|
120
|
+
session = await recordingRequest<RecordingSession>(
|
|
121
|
+
options.apiUrl,
|
|
122
|
+
"/sessions",
|
|
123
|
+
{
|
|
124
|
+
roomToken,
|
|
125
|
+
requestId,
|
|
126
|
+
mimeType,
|
|
127
|
+
...(options.chunkMiB ? { chunkMiB: options.chunkMiB } : {}),
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
break;
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if (
|
|
133
|
+
attempt === 2 ||
|
|
134
|
+
(error instanceof RecordingError &&
|
|
135
|
+
![409, 429, 503].includes(error.status))
|
|
136
|
+
)
|
|
137
|
+
throw error;
|
|
138
|
+
await delay(500 * (attempt + 1));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
!session ||
|
|
143
|
+
![5 * 1048576, 10 * 1048576].includes(session.chunkBytes) ||
|
|
144
|
+
session.maxBufferBytes < session.chunkBytes * 2 ||
|
|
145
|
+
session.maxBufferBytes > 100 * 1048576 ||
|
|
146
|
+
session.uploadConcurrency < 1 ||
|
|
147
|
+
session.uploadConcurrency > 4
|
|
148
|
+
)
|
|
149
|
+
throw new RecordingError("Invalid recording upload policy");
|
|
150
|
+
return new RecordingUploader(session, options);
|
|
151
|
+
}
|
|
152
|
+
get bufferedBytes(): number {
|
|
153
|
+
return this.bytes;
|
|
154
|
+
}
|
|
155
|
+
get uploadedBytes(): number {
|
|
156
|
+
return this.uploaded;
|
|
157
|
+
}
|
|
158
|
+
get hasCapacity(): boolean {
|
|
159
|
+
return (
|
|
160
|
+
!this.failure &&
|
|
161
|
+
!this.canceled &&
|
|
162
|
+
this.bytes < this.session.maxBufferBytes - this.session.chunkBytes
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
private emit(state: RecordingState, error?: Error): void {
|
|
166
|
+
try {
|
|
167
|
+
this.options.onState?.({
|
|
168
|
+
state,
|
|
169
|
+
bufferedBytes: this.bytes,
|
|
170
|
+
uploadedBytes: this.uploaded,
|
|
171
|
+
recordingId: this.session.recording.id,
|
|
172
|
+
...(error ? { error } : {}),
|
|
173
|
+
});
|
|
174
|
+
} catch {
|
|
175
|
+
/* UI callbacks never break accounting/cleanup. */
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
append(blob: Blob): void {
|
|
179
|
+
if (this.finishing || this.canceled)
|
|
180
|
+
throw new RecordingError("Recording upload has ended");
|
|
181
|
+
if (this.failure) throw this.failure;
|
|
182
|
+
if (!blob.size) return;
|
|
183
|
+
if (blob.size > this.session.maxBufferBytes - this.bytes)
|
|
184
|
+
throw new RecordingError(
|
|
185
|
+
"Recording buffer limit reached; stop recording without stopping the live stream",
|
|
186
|
+
);
|
|
187
|
+
this.queue.push(blob);
|
|
188
|
+
this.pendingBytes += blob.size;
|
|
189
|
+
this.bytes += blob.size;
|
|
190
|
+
this.pump();
|
|
191
|
+
this.emit(this.hasCapacity ? "recording" : "backpressure");
|
|
192
|
+
}
|
|
193
|
+
private take(size: number): Blob {
|
|
194
|
+
const blobs: Blob[] = [];
|
|
195
|
+
let remaining = size;
|
|
196
|
+
while (remaining > 0) {
|
|
197
|
+
const blob = this.queue.shift()!;
|
|
198
|
+
const length = Math.min(remaining, blob.size);
|
|
199
|
+
blobs.push(blob.slice(0, length));
|
|
200
|
+
if (length < blob.size) this.queue.unshift(blob.slice(length));
|
|
201
|
+
remaining -= length;
|
|
202
|
+
}
|
|
203
|
+
this.pendingBytes -= size;
|
|
204
|
+
return new Blob(blobs);
|
|
205
|
+
}
|
|
206
|
+
private pump(): void {
|
|
207
|
+
while (
|
|
208
|
+
!this.failure &&
|
|
209
|
+
!this.canceled &&
|
|
210
|
+
this.active.size < this.session.uploadConcurrency &&
|
|
211
|
+
this.pendingBytes >= this.session.chunkBytes
|
|
212
|
+
)
|
|
213
|
+
this.upload(this.take(this.session.chunkBytes), false);
|
|
214
|
+
}
|
|
215
|
+
private upload(blob: Blob, final: boolean): void {
|
|
216
|
+
const number = ++this.number;
|
|
217
|
+
// Reserve in sequence, while PUT bodies upload concurrently.
|
|
218
|
+
const signed = this.reserve.then(() =>
|
|
219
|
+
recordingRequest<{ url: string }>(
|
|
220
|
+
this.options.apiUrl,
|
|
221
|
+
`/sessions/${this.session.recording.id}/parts`,
|
|
222
|
+
{ number, bytes: blob.size, final },
|
|
223
|
+
this.session.token,
|
|
224
|
+
),
|
|
225
|
+
);
|
|
226
|
+
this.reserve = signed;
|
|
227
|
+
let task: Promise<void>;
|
|
228
|
+
task = (async () => {
|
|
229
|
+
let url = (await signed).url;
|
|
230
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
231
|
+
if (this.canceled) throw new RecordingError("Recording canceled");
|
|
232
|
+
try {
|
|
233
|
+
const response = await fetch(url, {
|
|
234
|
+
method: "PUT",
|
|
235
|
+
body: blob,
|
|
236
|
+
credentials: "omit",
|
|
237
|
+
signal: AbortSignal.any([
|
|
238
|
+
this.aborter.signal,
|
|
239
|
+
AbortSignal.timeout(45000),
|
|
240
|
+
]),
|
|
241
|
+
});
|
|
242
|
+
if (!response.ok)
|
|
243
|
+
throw new RecordingError("R2 upload failed", response.status);
|
|
244
|
+
this.bytes -= blob.size;
|
|
245
|
+
this.uploaded += blob.size;
|
|
246
|
+
return;
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (this.canceled || attempt === 3) throw error;
|
|
249
|
+
await delay(500 * 2 ** attempt);
|
|
250
|
+
url = (
|
|
251
|
+
await recordingRequest<{ url: string }>(
|
|
252
|
+
this.options.apiUrl,
|
|
253
|
+
`/sessions/${this.session.recording.id}/parts`,
|
|
254
|
+
{ number, bytes: blob.size, final },
|
|
255
|
+
this.session.token,
|
|
256
|
+
)
|
|
257
|
+
).url;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
})()
|
|
261
|
+
.catch((error) => {
|
|
262
|
+
this.failure =
|
|
263
|
+
error instanceof Error
|
|
264
|
+
? error
|
|
265
|
+
: new RecordingError("Recording upload failed");
|
|
266
|
+
this.emit("error", this.failure);
|
|
267
|
+
})
|
|
268
|
+
.finally(() => {
|
|
269
|
+
this.active.delete(task);
|
|
270
|
+
this.pump();
|
|
271
|
+
this.emit(
|
|
272
|
+
this.failure
|
|
273
|
+
? "error"
|
|
274
|
+
: this.hasCapacity
|
|
275
|
+
? "recording"
|
|
276
|
+
: "backpressure",
|
|
277
|
+
);
|
|
278
|
+
});
|
|
279
|
+
this.active.add(task);
|
|
280
|
+
}
|
|
281
|
+
async waitForCapacity(): Promise<void> {
|
|
282
|
+
while (!this.hasCapacity) {
|
|
283
|
+
if (this.failure) throw this.failure;
|
|
284
|
+
if (this.canceled) throw new RecordingError("Recording canceled");
|
|
285
|
+
await Promise.race(this.active);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async seal(): Promise<void> {
|
|
289
|
+
if (this.sealed) return;
|
|
290
|
+
this.finishing = true;
|
|
291
|
+
this.emit("uploading");
|
|
292
|
+
while (this.active.size) {
|
|
293
|
+
await Promise.all(this.active);
|
|
294
|
+
if (this.failure) throw this.failure;
|
|
295
|
+
}
|
|
296
|
+
if (this.pendingBytes) {
|
|
297
|
+
this.upload(this.take(this.pendingBytes), true);
|
|
298
|
+
await Promise.all(this.active);
|
|
299
|
+
}
|
|
300
|
+
if (this.failure) throw this.failure;
|
|
301
|
+
if (!this.number) {
|
|
302
|
+
await this.abort();
|
|
303
|
+
throw new RecordingError("No video was recorded");
|
|
304
|
+
}
|
|
305
|
+
this.sealed = true;
|
|
306
|
+
}
|
|
307
|
+
async complete(): Promise<RecordingFile> {
|
|
308
|
+
await this.seal();
|
|
309
|
+
const file = await recordingRequest<RecordingFile>(
|
|
310
|
+
this.options.apiUrl,
|
|
311
|
+
`/sessions/${this.session.recording.id}/complete`,
|
|
312
|
+
{},
|
|
313
|
+
this.session.token,
|
|
314
|
+
);
|
|
315
|
+
this.emit("ready");
|
|
316
|
+
return file;
|
|
317
|
+
}
|
|
318
|
+
async abort(): Promise<void> {
|
|
319
|
+
this.canceled = true;
|
|
320
|
+
this.aborter.abort();
|
|
321
|
+
await Promise.all(this.active);
|
|
322
|
+
this.queue = [];
|
|
323
|
+
this.bytes = 0;
|
|
324
|
+
this.pendingBytes = 0;
|
|
325
|
+
await recordingRequest(
|
|
326
|
+
this.options.apiUrl,
|
|
327
|
+
`/sessions/${this.session.recording.id}/abort`,
|
|
328
|
+
{},
|
|
329
|
+
this.session.token,
|
|
330
|
+
);
|
|
331
|
+
this.emit("stopped");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Encodes a downscaled COPY of local camera/mic. Never changes RTC tracks. */
|
|
336
|
+
export class CameraRecording {
|
|
337
|
+
private stopped = false;
|
|
338
|
+
private stopSegment: (() => void) | null = null;
|
|
339
|
+
private done: Promise<RecordingFile[]> | null = null;
|
|
340
|
+
private readonly files = new Map<number, RecordingFile>();
|
|
341
|
+
private readonly finalizers = new Set<Promise<void>>();
|
|
342
|
+
private finalFailure: Error | null = null;
|
|
343
|
+
private segmentNumber = 0;
|
|
344
|
+
constructor(
|
|
345
|
+
private readonly getStream: () => MediaStream | null,
|
|
346
|
+
private readonly getRoomToken: () => string,
|
|
347
|
+
private readonly options: RecordingOptions,
|
|
348
|
+
) {}
|
|
349
|
+
start(): Promise<void> {
|
|
350
|
+
const stream = this.getStream();
|
|
351
|
+
if (
|
|
352
|
+
!stream
|
|
353
|
+
?.getVideoTracks()
|
|
354
|
+
.some((track) => track.readyState === "live" && track.enabled)
|
|
355
|
+
)
|
|
356
|
+
throw new RecordingError("Publish an enabled camera before recording");
|
|
357
|
+
if (this.done) throw new RecordingError("Recording is already started");
|
|
358
|
+
let ready!: () => void, reject!: (e: unknown) => void;
|
|
359
|
+
const started = new Promise<void>((yes, no) => {
|
|
360
|
+
ready = yes;
|
|
361
|
+
reject = no;
|
|
362
|
+
});
|
|
363
|
+
this.done = this.run(ready)
|
|
364
|
+
.then((files) => {
|
|
365
|
+
ready();
|
|
366
|
+
return files;
|
|
367
|
+
})
|
|
368
|
+
.catch((error) => {
|
|
369
|
+
reject(error);
|
|
370
|
+
this.stopped = true;
|
|
371
|
+
try {
|
|
372
|
+
this.options.onState?.({
|
|
373
|
+
state: "error",
|
|
374
|
+
bufferedBytes: 0,
|
|
375
|
+
uploadedBytes: 0,
|
|
376
|
+
error:
|
|
377
|
+
error instanceof Error
|
|
378
|
+
? error
|
|
379
|
+
: new RecordingError("Recording failed"),
|
|
380
|
+
});
|
|
381
|
+
} catch {}
|
|
382
|
+
throw error;
|
|
383
|
+
});
|
|
384
|
+
void this.done.catch(() => undefined);
|
|
385
|
+
return started;
|
|
386
|
+
}
|
|
387
|
+
async stop(): Promise<RecordingFile[]> {
|
|
388
|
+
this.stopped = true;
|
|
389
|
+
this.stopSegment?.();
|
|
390
|
+
return this.done ?? [];
|
|
391
|
+
}
|
|
392
|
+
private async run(ready: () => void): Promise<RecordingFile[]> {
|
|
393
|
+
while (!this.stopped) {
|
|
394
|
+
const stream = this.getStream(),
|
|
395
|
+
camera = stream?.getVideoTracks()[0];
|
|
396
|
+
if (!stream || !camera || camera.readyState !== "live" || !camera.enabled)
|
|
397
|
+
break;
|
|
398
|
+
const mimeType = ["video/webm;codecs=vp8,opus", "video/mp4"].find(
|
|
399
|
+
(type) => MediaRecorder.isTypeSupported(type),
|
|
400
|
+
);
|
|
401
|
+
if (!mimeType)
|
|
402
|
+
throw new RecordingError(
|
|
403
|
+
"This browser cannot encode supported recording video",
|
|
404
|
+
);
|
|
405
|
+
const uploader = await RecordingUploader.create(
|
|
406
|
+
this.options,
|
|
407
|
+
this.getRoomToken(),
|
|
408
|
+
mimeType,
|
|
409
|
+
);
|
|
410
|
+
let video: HTMLVideoElement | null = null,
|
|
411
|
+
encoded: MediaStream | null = null,
|
|
412
|
+
timer: ReturnType<typeof setInterval> | undefined,
|
|
413
|
+
segmentTimer: ReturnType<typeof setTimeout> | undefined;
|
|
414
|
+
try {
|
|
415
|
+
if (this.stopped) {
|
|
416
|
+
await uploader.abort();
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
const policy = uploader.session.recording;
|
|
420
|
+
video = document.createElement("video");
|
|
421
|
+
video.srcObject = stream;
|
|
422
|
+
video.muted = true;
|
|
423
|
+
video.playsInline = true;
|
|
424
|
+
await video.play();
|
|
425
|
+
if (this.stopped) {
|
|
426
|
+
await uploader.abort();
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
const portrait =
|
|
430
|
+
(video.videoHeight || camera.getSettings().height || policy.height) >
|
|
431
|
+
(video.videoWidth || camera.getSettings().width || policy.width);
|
|
432
|
+
const canvas = document.createElement("canvas");
|
|
433
|
+
canvas.width = portrait ? policy.height : policy.width;
|
|
434
|
+
canvas.height = portrait ? policy.width : policy.height;
|
|
435
|
+
const context = canvas.getContext("2d");
|
|
436
|
+
if (!context) throw new RecordingError("Recording canvas unavailable");
|
|
437
|
+
encoded = canvas.captureStream(policy.frameRate);
|
|
438
|
+
for (const track of stream.getAudioTracks())
|
|
439
|
+
encoded.addTrack(track.clone());
|
|
440
|
+
const recorder = new MediaRecorder(encoded, {
|
|
441
|
+
mimeType,
|
|
442
|
+
videoBitsPerSecond: policy.videoBitrate,
|
|
443
|
+
audioBitsPerSecond: policy.audioBitrate,
|
|
444
|
+
});
|
|
445
|
+
let failure: Error | null = null;
|
|
446
|
+
const finished = new Promise<void>((resolve, reject) => {
|
|
447
|
+
recorder.onstop = () => (failure ? reject(failure) : resolve());
|
|
448
|
+
recorder.onerror = () => {
|
|
449
|
+
failure = new RecordingError("Recording encoder failed");
|
|
450
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
451
|
+
else reject(failure);
|
|
452
|
+
};
|
|
453
|
+
});
|
|
454
|
+
const stop = () => {
|
|
455
|
+
if (recorder.state !== "inactive") recorder.stop();
|
|
456
|
+
};
|
|
457
|
+
this.stopSegment = stop;
|
|
458
|
+
recorder.ondataavailable = (event) => {
|
|
459
|
+
try {
|
|
460
|
+
uploader.append(event.data);
|
|
461
|
+
if (!uploader.hasCapacity && recorder.state === "recording") {
|
|
462
|
+
recorder.pause();
|
|
463
|
+
void uploader
|
|
464
|
+
.waitForCapacity()
|
|
465
|
+
.then(() => {
|
|
466
|
+
if (!this.stopped && recorder.state === "paused")
|
|
467
|
+
recorder.resume();
|
|
468
|
+
})
|
|
469
|
+
.catch((error) => {
|
|
470
|
+
failure = error;
|
|
471
|
+
stop();
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
} catch (error) {
|
|
475
|
+
failure =
|
|
476
|
+
error instanceof Error
|
|
477
|
+
? error
|
|
478
|
+
: new RecordingError("Recording buffer failed");
|
|
479
|
+
stop();
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
const draw = () => {
|
|
483
|
+
const current = this.getStream();
|
|
484
|
+
const track = current?.getVideoTracks()[0];
|
|
485
|
+
if (!track || track.readyState !== "live" || !track.enabled) {
|
|
486
|
+
this.stopped = true;
|
|
487
|
+
stop();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
// A camera flip gets a separate file; WebRTC is untouched.
|
|
491
|
+
if (current !== stream) {
|
|
492
|
+
stop();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
encoded!.getAudioTracks().forEach((audio, index) => {
|
|
496
|
+
audio.enabled = stream.getAudioTracks()[index]?.enabled ?? false;
|
|
497
|
+
});
|
|
498
|
+
context.fillStyle = "#000";
|
|
499
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
500
|
+
if (video!.readyState >= 2) {
|
|
501
|
+
const scale = Math.min(
|
|
502
|
+
canvas.width / video!.videoWidth,
|
|
503
|
+
canvas.height / video!.videoHeight,
|
|
504
|
+
),
|
|
505
|
+
w = video!.videoWidth * scale,
|
|
506
|
+
h = video!.videoHeight * scale;
|
|
507
|
+
context.drawImage(
|
|
508
|
+
video!,
|
|
509
|
+
(canvas.width - w) / 2,
|
|
510
|
+
(canvas.height - h) / 2,
|
|
511
|
+
w,
|
|
512
|
+
h,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
recorder.start(1000);
|
|
517
|
+
draw();
|
|
518
|
+
ready();
|
|
519
|
+
timer = setInterval(draw, 1000 / policy.frameRate);
|
|
520
|
+
segmentTimer = setTimeout(stop, uploader.session.segmentSeconds * 1000);
|
|
521
|
+
await finished;
|
|
522
|
+
if (timer) clearInterval(timer);
|
|
523
|
+
if (segmentTimer) clearTimeout(segmentTimer);
|
|
524
|
+
encoded.getTracks().forEach((track) => track.stop());
|
|
525
|
+
video.pause();
|
|
526
|
+
video.srcObject = null;
|
|
527
|
+
await uploader.seal();
|
|
528
|
+
const index = this.segmentNumber++;
|
|
529
|
+
let finalize: Promise<void>;
|
|
530
|
+
finalize = uploader
|
|
531
|
+
.complete()
|
|
532
|
+
.then((file) => {
|
|
533
|
+
this.files.set(index, file);
|
|
534
|
+
try {
|
|
535
|
+
this.options.onRecordingReady?.(file);
|
|
536
|
+
} catch {}
|
|
537
|
+
})
|
|
538
|
+
.catch((error) => {
|
|
539
|
+
this.finalFailure =
|
|
540
|
+
error instanceof Error
|
|
541
|
+
? error
|
|
542
|
+
: new RecordingError("Recording validation failed");
|
|
543
|
+
this.stopped = true;
|
|
544
|
+
this.stopSegment?.();
|
|
545
|
+
})
|
|
546
|
+
.finally(() => this.finalizers.delete(finalize));
|
|
547
|
+
this.finalizers.add(finalize);
|
|
548
|
+
// Uploaded files hold no pending media buffers; validate in the
|
|
549
|
+
// background while the next segment records. Bound pending jobs too.
|
|
550
|
+
if (this.finalizers.size >= 2) await Promise.race(this.finalizers);
|
|
551
|
+
} catch (error) {
|
|
552
|
+
await uploader.abort().catch(() => undefined);
|
|
553
|
+
throw error;
|
|
554
|
+
} finally {
|
|
555
|
+
if (timer) clearInterval(timer);
|
|
556
|
+
if (segmentTimer) clearTimeout(segmentTimer);
|
|
557
|
+
this.stopSegment = null;
|
|
558
|
+
encoded?.getTracks().forEach((track) => track.stop());
|
|
559
|
+
if (video) {
|
|
560
|
+
video.pause();
|
|
561
|
+
video.srcObject = null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
await Promise.all(this.finalizers);
|
|
566
|
+
if (this.finalFailure) throw this.finalFailure;
|
|
567
|
+
return [...this.files.entries()]
|
|
568
|
+
.sort((a, b) => a[0] - b[0])
|
|
569
|
+
.map(([, file]) => file);
|
|
570
|
+
}
|
|
571
|
+
}
|
package/src/stats.ts
CHANGED
|
@@ -38,6 +38,11 @@ export interface VideoSendStats {
|
|
|
38
38
|
qualityLimitation?: string;
|
|
39
39
|
/** The browser's estimate of the upload it can use, in bits per second. */
|
|
40
40
|
availableOutgoingBitrate?: number;
|
|
41
|
+
/** Input to the RTP sender, when reported. May be a canvas rather than the physical camera. */
|
|
42
|
+
source?: { width: number; height: number; fps?: number };
|
|
43
|
+
/** Browser-reported encoder details; absent when not exposed. H264 alone does not imply hardware encoding. */
|
|
44
|
+
encoderImplementation?: string;
|
|
45
|
+
powerEfficientEncoder?: boolean;
|
|
41
46
|
/** Every layer, smallest first. */
|
|
42
47
|
layers: VideoLayerStats[];
|
|
43
48
|
}
|
|
@@ -77,6 +82,9 @@ const oneDecimal = (value: number): number => Math.round(value * 10) / 10;
|
|
|
77
82
|
*/
|
|
78
83
|
export function bitrateSince(baseline: StatsBaseline, key: string, bytes: number, timestamp: number): number {
|
|
79
84
|
const previous = baseline.get(key);
|
|
85
|
+
// Concurrent getStats calls can complete out of order. Do not rewind the
|
|
86
|
+
// baseline and inflate the next reading (or overwrite it with a duplicate).
|
|
87
|
+
if (previous && timestamp <= previous.timestamp) return 0;
|
|
80
88
|
baseline.set(key, { bytes, timestamp });
|
|
81
89
|
if (!previous) return 0;
|
|
82
90
|
const seconds = (timestamp - previous.timestamp) / 1000;
|
|
@@ -137,10 +145,14 @@ export function parseSendStats(
|
|
|
137
145
|
if (typeof entry.id === 'string') byId.set(entry.id, entry);
|
|
138
146
|
}
|
|
139
147
|
const outbound = entries.filter((e) => e.type === 'outbound-rtp' && isVideo(e));
|
|
148
|
+
const keys = new Set<string>();
|
|
140
149
|
|
|
141
150
|
const layers = outbound.map((entry): VideoLayerStats & { entry: StatsEntry } => {
|
|
142
151
|
const rid = optStr(entry.rid);
|
|
143
|
-
|
|
152
|
+
// A RID is a layer label, not a unique RTP stream. Reusing it with a new
|
|
153
|
+
// SSRC must start a fresh byte counter, even when the new count is higher.
|
|
154
|
+
const key = `${options.keyPrefix}${JSON.stringify([rid, entry.ssrc, entry.id])}`;
|
|
155
|
+
keys.add(key);
|
|
144
156
|
const bitrate = bitrateSince(options.baseline, key, num(entry.bytesSent), num(entry.timestamp));
|
|
145
157
|
const fps = oneDecimal(num(entry.framesPerSecond));
|
|
146
158
|
const reason = optStr(entry.qualityLimitationReason);
|
|
@@ -155,6 +167,7 @@ export function parseSendStats(
|
|
|
155
167
|
entry,
|
|
156
168
|
};
|
|
157
169
|
});
|
|
170
|
+
pruneBaseline(options.baseline, (key) => !key.startsWith(options.keyPrefix) || keys.has(key));
|
|
158
171
|
layers.sort(layerOrder);
|
|
159
172
|
|
|
160
173
|
// The biggest layer going out is what the best-connected viewer gets. With
|
|
@@ -163,6 +176,9 @@ export function parseSendStats(
|
|
|
163
176
|
const top = pool.reduce<(typeof layers)[number] | undefined>((best, l) => (!best || area(l) > area(best) ? l : best), undefined);
|
|
164
177
|
const codec = codecOf(byId, top?.entry) ?? outbound.map((e) => codecOf(byId, e)).find(Boolean);
|
|
165
178
|
const available = availableOutgoing(entries, byId);
|
|
179
|
+
const source = typeof top?.entry.mediaSourceId === 'string' ? byId.get(top.entry.mediaSourceId) : undefined;
|
|
180
|
+
const encoder = optStr(top?.entry.encoderImplementation);
|
|
181
|
+
const efficient = top?.entry.powerEfficientEncoder;
|
|
166
182
|
|
|
167
183
|
return {
|
|
168
184
|
...(codec !== undefined ? { codec } : {}),
|
|
@@ -173,6 +189,12 @@ export function parseSendStats(
|
|
|
173
189
|
bitrate: top?.bitrate ?? 0,
|
|
174
190
|
...(top?.qualityLimitation !== undefined ? { qualityLimitation: top.qualityLimitation } : {}),
|
|
175
191
|
...(available !== undefined ? { availableOutgoingBitrate: available } : {}),
|
|
192
|
+
...(source?.type === 'media-source' && isVideo(source) ? { source: {
|
|
193
|
+
width: num(source.width), height: num(source.height),
|
|
194
|
+
...(optNum(source.framesPerSecond) !== undefined ? { fps: oneDecimal(num(source.framesPerSecond)) } : {}),
|
|
195
|
+
} } : {}),
|
|
196
|
+
...(encoder !== undefined ? { encoderImplementation: encoder } : {}),
|
|
197
|
+
...(typeof efficient === 'boolean' ? { powerEfficientEncoder: efficient } : {}),
|
|
176
198
|
layers: layers.map(({ entry: _entry, ...layer }) => layer),
|
|
177
199
|
};
|
|
178
200
|
}
|
|
@@ -275,12 +297,25 @@ export class VideoLimitWatch {
|
|
|
275
297
|
private timer: ReturnType<typeof setInterval> | null = null;
|
|
276
298
|
private tracker = initialLimitTracker();
|
|
277
299
|
private sampling = false;
|
|
278
|
-
private
|
|
300
|
+
private baseline: StatsBaseline = new Map();
|
|
301
|
+
private generation = 0;
|
|
302
|
+
private visibilityDocument: Document | undefined;
|
|
303
|
+
private warmup = 0;
|
|
304
|
+
private readonly visibilityChanged = () => {
|
|
305
|
+
this.generation++;
|
|
306
|
+
this.baseline = new Map();
|
|
307
|
+
const was = this.tracker.reported;
|
|
308
|
+
this.tracker = initialLimitTracker();
|
|
309
|
+
this.warmup = 2;
|
|
310
|
+
this.onSuspend();
|
|
311
|
+
if (was && was !== 'none') this.report({ reason: 'none', width: 0, height: 0, fps: 0, bitrate: 0 });
|
|
312
|
+
};
|
|
279
313
|
|
|
280
314
|
constructor(
|
|
281
|
-
private readonly sample: (baseline: StatsBaseline) => Promise<VideoSendStats | null>,
|
|
315
|
+
private readonly sample: (baseline: StatsBaseline, isCurrent: () => boolean) => Promise<VideoSendStats | null>,
|
|
282
316
|
private readonly report: (event: VideoLimitedEvent) => void,
|
|
283
317
|
private readonly intervalMs = LIMIT_SAMPLE_MS,
|
|
318
|
+
private readonly onSuspend: () => void = () => {},
|
|
284
319
|
) {}
|
|
285
320
|
|
|
286
321
|
get running(): boolean {
|
|
@@ -290,10 +325,16 @@ export class VideoLimitWatch {
|
|
|
290
325
|
/** Start or stop sampling. Safe to call as often as anything changes. */
|
|
291
326
|
set(wanted: boolean): void {
|
|
292
327
|
if (wanted && !this.timer) {
|
|
328
|
+
this.visibilityDocument = typeof document !== 'undefined' ? document : undefined;
|
|
329
|
+
this.visibilityDocument?.addEventListener('visibilitychange', this.visibilityChanged);
|
|
330
|
+
if (this.visibilityDocument?.visibilityState === 'hidden') this.visibilityChanged();
|
|
293
331
|
this.timer = setInterval(() => void this.tick(), this.intervalMs);
|
|
294
332
|
} else if (!wanted && this.timer) {
|
|
295
333
|
clearInterval(this.timer);
|
|
296
334
|
this.timer = null;
|
|
335
|
+
this.generation++;
|
|
336
|
+
this.visibilityDocument?.removeEventListener('visibilitychange', this.visibilityChanged);
|
|
337
|
+
this.visibilityDocument = undefined;
|
|
297
338
|
}
|
|
298
339
|
}
|
|
299
340
|
|
|
@@ -305,17 +346,21 @@ export class VideoLimitWatch {
|
|
|
305
346
|
this.set(false);
|
|
306
347
|
const was = this.tracker.reported;
|
|
307
348
|
this.tracker = initialLimitTracker();
|
|
308
|
-
this.baseline
|
|
349
|
+
this.baseline = new Map();
|
|
309
350
|
return was && was !== 'none' ? { reason: 'none', width: 0, height: 0, fps: 0, bitrate: 0 } : null;
|
|
310
351
|
}
|
|
311
352
|
|
|
312
353
|
private async tick(): Promise<void> {
|
|
313
354
|
// A slow getStats must not pile samples up behind it.
|
|
314
355
|
if (this.sampling) return;
|
|
356
|
+
if (this.visibilityDocument?.visibilityState === 'hidden') return;
|
|
357
|
+
if (this.warmup > 0) { this.warmup--; return; }
|
|
358
|
+
const generation = this.generation;
|
|
359
|
+
const isCurrent = () => this.timer !== null && generation === this.generation && this.visibilityDocument?.visibilityState !== 'hidden';
|
|
315
360
|
this.sampling = true;
|
|
316
361
|
try {
|
|
317
|
-
const stats = await this.sample(this.baseline);
|
|
318
|
-
if (!stats || !
|
|
362
|
+
const stats = await this.sample(this.baseline, isCurrent);
|
|
363
|
+
if (!stats || !isCurrent()) return;
|
|
319
364
|
const { tracker, report } = trackLimit(this.tracker, limitReasonOf(stats.layers));
|
|
320
365
|
this.tracker = tracker;
|
|
321
366
|
if (report) this.report({ reason: report, width: stats.width, height: stats.height, fps: stats.fps, bitrate: stats.bitrate });
|