@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/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: LocalPublicationSource;
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
- const wasPublishing = [...new Set([...this.publications.values()].map((p) => p.source))];
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(track: MediaStreamTrack, source: LocalPublicationSource): Promise<LocalPublication> {
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({ track, appData: { source } });
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
+ }