@tribe-nest/media-client 0.1.1 → 0.2.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/build/core/state.d.ts +3 -0
- package/build/core/state.d.ts.map +1 -1
- package/build/core/state.js +7 -1
- package/build/core/state.js.map +1 -1
- package/build/react/index.d.ts +15 -5
- package/build/react/index.d.ts.map +1 -1
- package/build/react/index.js +60 -7
- package/build/react/index.js.map +1 -1
- package/build/room/browserDevice.d.ts.map +1 -1
- package/build/room/browserDevice.js +13 -4
- package/build/room/browserDevice.js.map +1 -1
- package/build/room/device.d.ts +13 -0
- package/build/room/device.d.ts.map +1 -1
- package/build/room/room.d.ts +63 -2
- package/build/room/room.d.ts.map +1 -1
- package/build/room/room.js +119 -4
- package/build/room/room.js.map +1 -1
- package/package.json +1 -1
- package/src/core/_tests/state.spec.ts +46 -0
- package/src/core/state.ts +10 -1
- package/src/react/index.tsx +62 -12
- package/src/room/_tests/room.spec.ts +237 -4
- package/src/room/browserDevice.ts +14 -4
- package/src/room/device.ts +13 -0
- package/src/room/room.ts +184 -5
package/src/room/room.ts
CHANGED
|
@@ -81,15 +81,99 @@ export type MediaTrack = {
|
|
|
81
81
|
track: MediaStreamTrack;
|
|
82
82
|
/** Paused at the SOURCE, as the publisher left it. */
|
|
83
83
|
paused: boolean;
|
|
84
|
+
/** The publisher's declared label, when the node relayed one. Rendering only. */
|
|
85
|
+
source?: string;
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
/** What a local track is, in the vocabulary the node checks against `publishKinds`. */
|
|
87
89
|
export type LocalPublicationSource = "camera" | "microphone" | "screen";
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* Every label this SDK publishes under.
|
|
93
|
+
*
|
|
94
|
+
* `program` is a COMPOSED feed rather than a device: a broadcast studio's
|
|
95
|
+
* rendered output, published so a server-side egress can restream it. It is
|
|
96
|
+
* not a capture, so it is never a "lost source" to re-offer a person after a
|
|
97
|
+
* drop, and `useLocalMedia`'s device controls never touch it. The node treats
|
|
98
|
+
* it as its track's kind (`requiredPublishKind`), so a token needs only
|
|
99
|
+
* `publishKinds: ["audio", "video"]`.
|
|
100
|
+
*/
|
|
101
|
+
export type PublishSourceLabel = LocalPublicationSource | "program";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How to encode one publication, where the default is wrong.
|
|
105
|
+
*
|
|
106
|
+
* A camera takes the browser's defaults. A composed program feed does not:
|
|
107
|
+
* an RTMP restream needs H.264 (RTMP carries nothing else, and the egress
|
|
108
|
+
* copies rather than transcodes), and a 1080p canvas at the default bitrate
|
|
109
|
+
* is a smear. Both are per-publication facts, not per-room ones.
|
|
110
|
+
*/
|
|
111
|
+
export type PublishOptions = {
|
|
112
|
+
/** Pick this codec from the router's list. Ignored when the router lacks it. */
|
|
113
|
+
codec?: "h264" | "vp8";
|
|
114
|
+
/** Cap for the encoder, in kbit/s. Read as the TOP layer under simulcast. */
|
|
115
|
+
maxBitrateKbps?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Send three spatial layers instead of one, so each consumer can be served
|
|
118
|
+
* the one its connection can carry.
|
|
119
|
+
*
|
|
120
|
+
* The choosing is not ours and does not need to be: mediasoup picks a layer
|
|
121
|
+
* per consumer from that transport's own bandwidth estimate, so a viewer on
|
|
122
|
+
* a poor line gets 360p rather than a stall, with nothing to implement on
|
|
123
|
+
* either end. What we decide is only whether the layers exist to choose
|
|
124
|
+
* from.
|
|
125
|
+
*
|
|
126
|
+
* Use it for a feed with an AUDIENCE. It is wrong for a camera in a small
|
|
127
|
+
* call, where everyone can carry the one encoding and the extra layers are
|
|
128
|
+
* encoder time spent on nobody.
|
|
129
|
+
*
|
|
130
|
+
* ## Why VP8 and not H.264
|
|
131
|
+
*
|
|
132
|
+
* Browser simulcast is reliable on VP8 and is not on H.264, where it depends
|
|
133
|
+
* on the hardware encoder and quietly degrades to one layer. A caller that
|
|
134
|
+
* needs H.264 downstream (an RTMP restream copies rather than transcodes)
|
|
135
|
+
* should publish one encoding and leave this off. The two are exclusive by
|
|
136
|
+
* design rather than by accident.
|
|
137
|
+
*/
|
|
138
|
+
simulcast?: boolean;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The three layers, quarter / half / full.
|
|
143
|
+
*
|
|
144
|
+
* `scaleResolutionDownBy` rather than three bitrates, so the shape holds
|
|
145
|
+
* whatever resolution the caller is publishing. The rids are the conventional
|
|
146
|
+
* one-letter names every SFU and every browser log uses, which is worth more
|
|
147
|
+
* than a more descriptive name nobody would recognise in a WebRTC dump.
|
|
148
|
+
*/
|
|
149
|
+
const SIMULCAST_LAYERS = [
|
|
150
|
+
{ rid: "q", scaleResolutionDownBy: 4, scalabilityMode: "L1T1" },
|
|
151
|
+
{ rid: "h", scaleResolutionDownBy: 2, scalabilityMode: "L1T1" },
|
|
152
|
+
{ rid: "f", scaleResolutionDownBy: 1, scalabilityMode: "L1T1" },
|
|
153
|
+
] as const;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What each layer may spend, given the caller's cap for the top one.
|
|
157
|
+
*
|
|
158
|
+
* Quarter resolution is a sixteenth of the pixels, but not a sixteenth of the
|
|
159
|
+
* bits: an encoder needs proportionally more per pixel at small sizes, and a
|
|
160
|
+
* layer starved below what its resolution costs is a layer that looks worse
|
|
161
|
+
* than it should while still being sent. A quarter and a tenth are the
|
|
162
|
+
* conventional split and are what mediasoup's own examples use.
|
|
163
|
+
*/
|
|
164
|
+
function simulcastEncodings(maxBitrateKbps: number | undefined): unknown[] {
|
|
165
|
+
const top = (maxBitrateKbps ?? 0) * 1000;
|
|
166
|
+
const share = [0.1, 0.25, 1];
|
|
167
|
+
return SIMULCAST_LAYERS.map((layer, index) => ({
|
|
168
|
+
...layer,
|
|
169
|
+
...(top ? { maxBitrate: Math.round(top * share[index]!) } : {}),
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
|
|
89
173
|
export type LocalPublication = {
|
|
90
174
|
producerId: string;
|
|
91
175
|
kind: "audio" | "video";
|
|
92
|
-
source:
|
|
176
|
+
source: PublishSourceLabel;
|
|
93
177
|
track: MediaStreamTrack;
|
|
94
178
|
handle: MediaProducerHandle;
|
|
95
179
|
/**
|
|
@@ -188,7 +272,15 @@ export class MediaRoom {
|
|
|
188
272
|
this.lastError = cause;
|
|
189
273
|
// What the person was sending, before it is torn down. Recorded so the
|
|
190
274
|
// screen can say so once the room is back (see `lostPublicationSources`).
|
|
191
|
-
|
|
275
|
+
// A program feed is excluded: it is a studio's output, not something a
|
|
276
|
+
// person switched on, so nobody should be asked to switch it back on.
|
|
277
|
+
const wasPublishing = [
|
|
278
|
+
...new Set(
|
|
279
|
+
[...this.publications.values()]
|
|
280
|
+
.map((p) => p.source)
|
|
281
|
+
.filter((s): s is LocalPublicationSource => s !== "program"),
|
|
282
|
+
),
|
|
283
|
+
];
|
|
192
284
|
// Before the decision, and unconditionally. The transports are dead
|
|
193
285
|
// whatever happens next, and so is every capture that was feeding them.
|
|
194
286
|
this.teardownMedia();
|
|
@@ -422,14 +514,29 @@ export class MediaRoom {
|
|
|
422
514
|
* `publishKinds`: a token granting audio only must not be able to publish a
|
|
423
515
|
* screen share by relabelling it, and the node is where that is decided.
|
|
424
516
|
*/
|
|
425
|
-
async publish(
|
|
517
|
+
async publish(
|
|
518
|
+
track: MediaStreamTrack,
|
|
519
|
+
source: PublishSourceLabel,
|
|
520
|
+
options: PublishOptions = {},
|
|
521
|
+
): Promise<LocalPublication> {
|
|
426
522
|
const kind = track.kind === "audio" ? "audio" : "video";
|
|
427
523
|
if (!this.device.canProduce(kind)) {
|
|
428
524
|
throw new Error(`this browser cannot produce ${kind}`);
|
|
429
525
|
}
|
|
430
526
|
|
|
431
527
|
const transport = await this.ensureSendTransport();
|
|
432
|
-
const handle = await transport.produce({
|
|
528
|
+
const handle = await transport.produce({
|
|
529
|
+
track,
|
|
530
|
+
appData: { source },
|
|
531
|
+
...(options.codec ? { codec: options.codec } : {}),
|
|
532
|
+
...(options.simulcast
|
|
533
|
+
? { encodings: simulcastEncodings(options.maxBitrateKbps) }
|
|
534
|
+
: options.maxBitrateKbps
|
|
535
|
+
? { encodings: [{ maxBitrate: options.maxBitrateKbps * 1000 }] }
|
|
536
|
+
: {}),
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
if (source === "program") await keepProgramResolution(handle, this.options.onLog);
|
|
433
540
|
|
|
434
541
|
const publication: LocalPublication = {
|
|
435
542
|
producerId: handle.id,
|
|
@@ -450,7 +557,7 @@ export class MediaRoom {
|
|
|
450
557
|
void this.unpublish(handle.id);
|
|
451
558
|
}),
|
|
452
559
|
);
|
|
453
|
-
if (this.lostSources.includes(source)) {
|
|
560
|
+
if (source !== "program" && this.lostSources.includes(source)) {
|
|
454
561
|
this.lostSources = this.lostSources.filter((s) => s !== source);
|
|
455
562
|
if (this.lostSources.length === 0) this.lostSources = NO_SOURCES;
|
|
456
563
|
}
|
|
@@ -458,6 +565,35 @@ export class MediaRoom {
|
|
|
458
565
|
return publication;
|
|
459
566
|
}
|
|
460
567
|
|
|
568
|
+
/**
|
|
569
|
+
* Swap the capture behind a publication, keeping the producer.
|
|
570
|
+
*
|
|
571
|
+
* A device change (another camera, another microphone) is this, and it is
|
|
572
|
+
* deliberately NOT unpublish plus publish: that changes the producer id,
|
|
573
|
+
* which is the key every consumer's tile and every studio's scene holds, so
|
|
574
|
+
* a switched camera vanished from the stage until somebody re-added it.
|
|
575
|
+
* The old capture is stopped here; the new one is watched for ending the
|
|
576
|
+
* same way the first was.
|
|
577
|
+
*/
|
|
578
|
+
async replaceTrack(producerId: string, track: MediaStreamTrack): Promise<void> {
|
|
579
|
+
const publication = this.publications.get(producerId);
|
|
580
|
+
if (!publication) throw new Error("no such publication");
|
|
581
|
+
if (track.kind !== publication.kind) throw new Error(`a ${publication.kind} publication cannot carry ${track.kind}`);
|
|
582
|
+
if (track === publication.track) return;
|
|
583
|
+
|
|
584
|
+
await publication.handle.replaceTrack(track);
|
|
585
|
+
this.trackEndWatchers.get(producerId)?.();
|
|
586
|
+
publication.track.stop();
|
|
587
|
+
this.trackEndWatchers.set(
|
|
588
|
+
producerId,
|
|
589
|
+
whenTrackEnds(track, () => {
|
|
590
|
+
void this.unpublish(producerId);
|
|
591
|
+
}),
|
|
592
|
+
);
|
|
593
|
+
this.publications.set(producerId, { ...publication, track });
|
|
594
|
+
this.emit();
|
|
595
|
+
}
|
|
596
|
+
|
|
461
597
|
async unpublish(producerId: string): Promise<void> {
|
|
462
598
|
const publication = this.publications.get(producerId);
|
|
463
599
|
if (!publication) return;
|
|
@@ -569,6 +705,7 @@ export class MediaRoom {
|
|
|
569
705
|
kind: response.kind,
|
|
570
706
|
track: consumer.track,
|
|
571
707
|
paused: entry.paused,
|
|
708
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
572
709
|
});
|
|
573
710
|
|
|
574
711
|
// RULE 3: resume LAST. The node creates every consumer paused, so media
|
|
@@ -774,3 +911,45 @@ export async function connectToRoom(options: MediaRoomOptions): Promise<MediaRoo
|
|
|
774
911
|
await room.connect();
|
|
775
912
|
return room;
|
|
776
913
|
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* The program feed keeps its RESOLUTION and gives up frame rate instead.
|
|
917
|
+
*
|
|
918
|
+
* Chrome's default for a video sender is `balanced`, which under a low
|
|
919
|
+
* bandwidth estimate or CPU pressure scales the encode down. For a camera tile
|
|
920
|
+
* that is the right trade. For the program feed it is wrong twice over.
|
|
921
|
+
*
|
|
922
|
+
* First, this feed is the broadcast: a 1080p composite arriving at Twitch as
|
|
923
|
+
* 270p is the product, not a degraded preview. Twitch reported exactly that.
|
|
924
|
+
*
|
|
925
|
+
* Second, and worse, the RTMP leg cannot survive the CHANGE. The egress muxes
|
|
926
|
+
* the feed with `-c:v copy`, and an FLV header carries the dimensions once, at
|
|
927
|
+
* the start. When Chrome adapts resolution mid-stream the SPS changes under a
|
|
928
|
+
* header that still describes the old size, and the player refuses the stream:
|
|
929
|
+
*
|
|
930
|
+
* Your browser encountered an error while decoding the video. (Error #3000)
|
|
931
|
+
*
|
|
932
|
+
* while ffmpeg reports `drop_frames=0`, a healthy bitrate, and an established
|
|
933
|
+
* socket. Nothing on the server can see it.
|
|
934
|
+
*
|
|
935
|
+
* `maintain-resolution` makes the encoder drop frames rather than pixels, so
|
|
936
|
+
* the SPS stays put for the life of the broadcast.
|
|
937
|
+
*
|
|
938
|
+
* Best-effort: the setting is not universally implemented, and a browser that
|
|
939
|
+
* ignores it is no worse off than before. Only the program source, because a
|
|
940
|
+
* camera is better off shrinking than freezing.
|
|
941
|
+
*/
|
|
942
|
+
async function keepProgramResolution(
|
|
943
|
+
handle: MediaProducerHandle,
|
|
944
|
+
onLog?: (level: "warn" | "debug", message: string, detail?: unknown) => void,
|
|
945
|
+
): Promise<void> {
|
|
946
|
+
const sender = handle.rtpSender;
|
|
947
|
+
if (!sender || handle.kind !== "video") return;
|
|
948
|
+
try {
|
|
949
|
+
const params = sender.getParameters() as RTCRtpSendParameters & { degradationPreference?: string };
|
|
950
|
+
params.degradationPreference = "maintain-resolution";
|
|
951
|
+
await sender.setParameters(params);
|
|
952
|
+
} catch (err) {
|
|
953
|
+
onLog?.("warn", "could not pin the program feed's resolution", err);
|
|
954
|
+
}
|
|
955
|
+
}
|