@voltkit-rtc/client 0.4.0 → 0.6.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.
- package/CHANGELOG.md +22 -0
- package/README.md +128 -29
- package/dist/cjs/index.d.ts +70 -8
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +357 -27
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/music.d.ts +1 -1
- package/dist/cjs/music.d.ts.map +1 -1
- package/dist/cjs/quality.d.ts +19 -21
- package/dist/cjs/quality.d.ts.map +1 -1
- package/dist/cjs/quality.js +46 -28
- package/dist/cjs/quality.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/esm/index.d.ts +70 -8
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +354 -27
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/music.d.ts +1 -1
- package/dist/esm/music.d.ts.map +1 -1
- package/dist/esm/quality.d.ts +19 -21
- package/dist/esm/quality.d.ts.map +1 -1
- package/dist/esm/quality.js +43 -28
- package/dist/esm/quality.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/package.json +1 -1
- package/src/index.ts +327 -27
- package/src/music.ts +1 -1
- package/src/quality.ts +58 -29
- package/src/recording.ts +571 -0
package/src/quality.ts
CHANGED
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Video quality tiers and the simulcast layers sent for each.
|
|
3
|
-
*
|
|
4
|
-
* A project can publish at 720p or 480p on any plan. 1080p uses more
|
|
5
|
-
* bandwidth, so the server allows it only for projects that have it enabled,
|
|
6
|
-
* and for the first project during its free trial; the join result says which
|
|
7
|
-
* (`video.maxQuality`), and the server caps each publisher's incoming bitrate
|
|
8
|
-
* to match.
|
|
9
|
-
*/
|
|
1
|
+
/** All video qualities are available on every plan; per-project safety restrictions still apply. */
|
|
10
2
|
|
|
11
3
|
export type VideoQuality = '1080p' | '720p' | '480p';
|
|
12
4
|
|
|
@@ -17,13 +9,14 @@ export type LayerCount = 1 | 2 | 3;
|
|
|
17
9
|
|
|
18
10
|
/** What the server allows this project to publish, from the join result. */
|
|
19
11
|
export interface VideoPolicy {
|
|
20
|
-
maxQuality:
|
|
12
|
+
maxQuality: VideoQuality;
|
|
13
|
+
maxFrameRate?: 30 | 60;
|
|
21
14
|
/** The server's cap on one publisher's incoming bitrate, in bits per second. */
|
|
22
15
|
maxBitrate?: number;
|
|
23
16
|
}
|
|
24
17
|
|
|
25
18
|
/** Servers from before quality tiers send no policy; they get the default tier. */
|
|
26
|
-
export const DEFAULT_VIDEO_POLICY: VideoPolicy = { maxQuality: '
|
|
19
|
+
export const DEFAULT_VIDEO_POLICY: VideoPolicy = { maxQuality: '1080p', maxFrameRate: 60 };
|
|
27
20
|
|
|
28
21
|
/** Each tier's 16:9 frame. */
|
|
29
22
|
const TIER_SIZE: Record<VideoQuality, { width: number; height: number }> = {
|
|
@@ -36,27 +29,18 @@ const RANK: Record<VideoQuality, number> = { '480p': 0, '720p': 1, '1080p': 2 };
|
|
|
36
29
|
|
|
37
30
|
/** The lower of what was asked for and what the project may use. */
|
|
38
31
|
export function allowedQuality(requested: VideoQuality | undefined, policy: VideoPolicy = DEFAULT_VIDEO_POLICY): VideoQuality {
|
|
39
|
-
const max = policy.maxQuality
|
|
32
|
+
const max = policy.maxQuality in RANK ? policy.maxQuality : '1080p';
|
|
40
33
|
const wanted = requested && requested in RANK ? requested : max;
|
|
41
34
|
return RANK[wanted] <= RANK[max] ? wanted : max;
|
|
42
35
|
}
|
|
43
36
|
|
|
44
|
-
/**
|
|
45
|
-
* The bitrate a layer of this many pixels gets, at up to 30 fps.
|
|
46
|
-
* About 1080p 4 Mbps, 720p 2.5 Mbps, 540p 1.5 Mbps, 480p 1 Mbps,
|
|
47
|
-
* 360p 700 kbps, 240p 400 kbps, smaller 200 kbps.
|
|
48
|
-
*
|
|
49
|
-
* Measured on a 720p camera picture: 2.5 Mbps brought VP8's quantiser down
|
|
50
|
-
* from about 64 (at 1.8 Mbps) to about 55, which is finer detail, most of all
|
|
51
|
-
* in motion. The server's per-project caps (4 Mbps up to 720p, 7 Mbps with
|
|
52
|
-
* 1080p) carry these ladders with their audio.
|
|
53
|
-
*/
|
|
37
|
+
/** The authoritative 30 fps bitrate for a layer of this many pixels. */
|
|
54
38
|
export function layerBitrate(pixels: number): number {
|
|
55
|
-
if (pixels >= 1_800_000) return
|
|
56
|
-
if (pixels >= 800_000) return
|
|
57
|
-
if (pixels >= 450_000) return
|
|
58
|
-
if (pixels >= 360_000) return
|
|
59
|
-
if (pixels >= 200_000) return
|
|
39
|
+
if (pixels >= 1_800_000) return 6_000_000;
|
|
40
|
+
if (pixels >= 800_000) return 3_000_000;
|
|
41
|
+
if (pixels >= 450_000) return 2_000_000;
|
|
42
|
+
if (pixels >= 360_000) return 1_200_000;
|
|
43
|
+
if (pixels >= 200_000) return 800_000;
|
|
60
44
|
if (pixels >= 100_000) return 400_000;
|
|
61
45
|
return 200_000;
|
|
62
46
|
}
|
|
@@ -66,6 +50,7 @@ export interface VideoEncoding {
|
|
|
66
50
|
scaleResolutionDownBy: number;
|
|
67
51
|
maxBitrate: number;
|
|
68
52
|
scalabilityMode: 'L1T3';
|
|
53
|
+
maxFramerate: number;
|
|
69
54
|
}
|
|
70
55
|
|
|
71
56
|
/**
|
|
@@ -98,7 +83,7 @@ export function topLayerSize(width: number, height: number, quality: VideoQualit
|
|
|
98
83
|
* browser shrinks the picture and then sends fewer layers: from two it keeps
|
|
99
84
|
* the full one, from three it lost it.
|
|
100
85
|
*/
|
|
101
|
-
export function videoEncodings(width: number, height: number, quality: VideoQuality, maxLayers: LayerCount = 3): VideoEncoding[] {
|
|
86
|
+
export function videoEncodings(width: number, height: number, quality: VideoQuality, maxLayers: LayerCount = 3, frameRate: 30 | 60 = 30): VideoEncoding[] {
|
|
102
87
|
const { width: w, height: h, scale } = topLayerSize(width, height, quality);
|
|
103
88
|
const top = w * h;
|
|
104
89
|
const possible = top >= 960 * 540 ? 3 : top >= 480 * 270 ? 2 : 1;
|
|
@@ -108,7 +93,8 @@ export function videoEncodings(width: number, height: number, quality: VideoQual
|
|
|
108
93
|
const layer = (divisor: number, rid?: string): VideoEncoding => ({
|
|
109
94
|
...(rid ? { rid } : {}),
|
|
110
95
|
scaleResolutionDownBy: round(scale * divisor),
|
|
111
|
-
maxBitrate: layerBitrate(top / (divisor * divisor)),
|
|
96
|
+
maxBitrate: layerBitrate(top / (divisor * divisor)) * (divisor === 1 && frameRate === 60 ? 1.5 : 1),
|
|
97
|
+
maxFramerate: divisor === 1 ? frameRate : 30,
|
|
112
98
|
scalabilityMode: 'L1T3',
|
|
113
99
|
});
|
|
114
100
|
if (layers === 3) return [layer(4, 'q'), layer(2, 'h'), layer(1, 'f')];
|
|
@@ -124,3 +110,46 @@ export function videoEncodings(width: number, height: number, quality: VideoQual
|
|
|
124
110
|
export function sameLayout(a: readonly VideoEncoding[], b: readonly VideoEncoding[]): boolean {
|
|
125
111
|
return a.length === b.length && a.every((encoding, i) => encoding.scaleResolutionDownBy === b[i].scaleResolutionDownBy);
|
|
126
112
|
}
|
|
113
|
+
|
|
114
|
+
/** Actual producer layer dimensions, smallest first; includes portrait and screen video. */
|
|
115
|
+
export interface VideoLayer {
|
|
116
|
+
spatialLayer: number;
|
|
117
|
+
width: number;
|
|
118
|
+
height: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export type ReceiveQuality = 'auto' | VideoQuality;
|
|
122
|
+
export type ScreenMode = 'detail' | 'motion';
|
|
123
|
+
|
|
124
|
+
/** Never invent layers when an older producer does not describe them. */
|
|
125
|
+
export function receiveSpatialLayer(layers: readonly VideoLayer[], quality: VideoQuality): number {
|
|
126
|
+
const valid = layers.filter((l) => Number.isInteger(l.spatialLayer) && l.spatialLayer >= 0 &&
|
|
127
|
+
Number.isFinite(l.width) && l.width > 0 && Number.isFinite(l.height) && l.height > 0)
|
|
128
|
+
.sort((a, b) => a.width * a.height - b.width * b.height);
|
|
129
|
+
if (!valid.length) throw new Error('producer has no video layer metadata');
|
|
130
|
+
const tier = TIER_SIZE[quality];
|
|
131
|
+
const fitting = valid.filter((l) => Math.max(l.width, l.height) <= tier.width + 1 && Math.min(l.width, l.height) <= tier.height + 1);
|
|
132
|
+
return (fitting.at(-1) ?? valid[0]).spatialLayer;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function describeVideoLayers(width: number, height: number, encodings: readonly VideoEncoding[], quality: VideoQuality = '1080p'): VideoLayer[] {
|
|
136
|
+
const fallback = TIER_SIZE[quality];
|
|
137
|
+
const w = width > 0 ? width : fallback.width;
|
|
138
|
+
const h = height > 0 ? height : fallback.height;
|
|
139
|
+
return encodings.map((encoding, spatialLayer) => ({ spatialLayer, width: Math.round(w / encoding.scaleResolutionDownBy), height: Math.round(h / encoding.scaleResolutionDownBy) }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Screen frames are capped to native 1080p, never enlarged, with two adaptive copies. */
|
|
143
|
+
export function screenEncodings(width: number, height: number, mode: ScreenMode = 'detail'): VideoEncoding[] {
|
|
144
|
+
const nativeWidth = width > 0 ? width : 1920;
|
|
145
|
+
const nativeHeight = height > 0 ? height : 1080;
|
|
146
|
+
const scale = Math.max(1, Math.max(nativeWidth, nativeHeight) / 1920, Math.min(nativeWidth, nativeHeight) / 1080);
|
|
147
|
+
const w = nativeWidth / scale;
|
|
148
|
+
const h = nativeHeight / scale;
|
|
149
|
+
const layer = (divisor: number, rid?: string): VideoEncoding => ({
|
|
150
|
+
...(rid ? { rid } : {}), scaleResolutionDownBy: scale * divisor,
|
|
151
|
+
maxBitrate: divisor === 1 ? (mode === 'motion' ? 6_000_000 : 2_500_000) : 600_000,
|
|
152
|
+
maxFramerate: mode === 'motion' ? 30 : 15, scalabilityMode: 'L1T3',
|
|
153
|
+
});
|
|
154
|
+
return w * h >= 480 * 270 ? [layer(2, 'h'), layer(1, 'f')] : [layer(1)];
|
|
155
|
+
}
|
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
|
+
}
|