piixishstream 0.0.0-stage → 1.0.2

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 (123) hide show
  1. package/README.md +124 -2
  2. package/dist/client/GatewayEvents.d.ts +30 -0
  3. package/dist/client/GatewayEvents.js +2 -0
  4. package/dist/client/GatewayEvents.js.map +1 -0
  5. package/dist/client/GatewayOpCodes.d.ts +40 -0
  6. package/dist/client/GatewayOpCodes.js +42 -0
  7. package/dist/client/GatewayOpCodes.js.map +1 -0
  8. package/dist/client/Streamer.d.ts +30 -0
  9. package/dist/client/Streamer.js +188 -0
  10. package/dist/client/Streamer.js.map +1 -0
  11. package/dist/client/index.d.ts +3 -0
  12. package/dist/client/index.js +4 -0
  13. package/dist/client/index.js.map +1 -0
  14. package/dist/client/processing/AnnexBBitstreamReaderWriter.d.ts +23 -0
  15. package/dist/client/processing/AnnexBBitstreamReaderWriter.js +149 -0
  16. package/dist/client/processing/AnnexBBitstreamReaderWriter.js.map +1 -0
  17. package/dist/client/processing/AnnexBHelper.d.ts +93 -0
  18. package/dist/client/processing/AnnexBHelper.js +127 -0
  19. package/dist/client/processing/AnnexBHelper.js.map +1 -0
  20. package/dist/client/processing/SPSVUIRewriter.d.ts +1 -0
  21. package/dist/client/processing/SPSVUIRewriter.js +297 -0
  22. package/dist/client/processing/SPSVUIRewriter.js.map +1 -0
  23. package/dist/client/voice/BaseMediaConnection.d.ts +80 -0
  24. package/dist/client/voice/BaseMediaConnection.js +552 -0
  25. package/dist/client/voice/BaseMediaConnection.js.map +1 -0
  26. package/dist/client/voice/CodecPayloadType.d.ts +59 -0
  27. package/dist/client/voice/CodecPayloadType.js +60 -0
  28. package/dist/client/voice/CodecPayloadType.js.map +1 -0
  29. package/dist/client/voice/StreamConnection.d.ts +11 -0
  30. package/dist/client/voice/StreamConnection.js +34 -0
  31. package/dist/client/voice/StreamConnection.js.map +1 -0
  32. package/dist/client/voice/VoiceConnection.d.ts +8 -0
  33. package/dist/client/voice/VoiceConnection.js +15 -0
  34. package/dist/client/voice/VoiceConnection.js.map +1 -0
  35. package/dist/client/voice/VoiceMessageTypes.d.ts +178 -0
  36. package/dist/client/voice/VoiceMessageTypes.js +2 -0
  37. package/dist/client/voice/VoiceMessageTypes.js.map +1 -0
  38. package/dist/client/voice/VoiceOpCodes.d.ts +35 -0
  39. package/dist/client/voice/VoiceOpCodes.js +38 -0
  40. package/dist/client/voice/VoiceOpCodes.js.map +1 -0
  41. package/dist/client/voice/WebRtcWrapper.d.ts +23 -0
  42. package/dist/client/voice/WebRtcWrapper.js +159 -0
  43. package/dist/client/voice/WebRtcWrapper.js.map +1 -0
  44. package/dist/client/voice/index.d.ts +4 -0
  45. package/dist/client/voice/index.js +6 -0
  46. package/dist/client/voice/index.js.map +1 -0
  47. package/dist/index.d.ts +3 -0
  48. package/dist/index.js +4 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/media/AudioStream.d.ts +7 -0
  51. package/dist/media/AudioStream.js +12 -0
  52. package/dist/media/AudioStream.js.map +1 -0
  53. package/dist/media/BaseMediaStream.d.ts +31 -0
  54. package/dist/media/BaseMediaStream.js +164 -0
  55. package/dist/media/BaseMediaStream.js.map +1 -0
  56. package/dist/media/LibavCodecId.d.ts +541 -0
  57. package/dist/media/LibavCodecId.js +553 -0
  58. package/dist/media/LibavCodecId.js.map +1 -0
  59. package/dist/media/LibavDecoder.d.ts +5 -0
  60. package/dist/media/LibavDecoder.js +43 -0
  61. package/dist/media/LibavDecoder.js.map +1 -0
  62. package/dist/media/LibavDemuxer.d.ts +44 -0
  63. package/dist/media/LibavDemuxer.js +231 -0
  64. package/dist/media/LibavDemuxer.js.map +1 -0
  65. package/dist/media/VideoStream.d.ts +7 -0
  66. package/dist/media/VideoStream.js +12 -0
  67. package/dist/media/VideoStream.js.map +1 -0
  68. package/dist/media/encoders/index.d.ts +26 -0
  69. package/dist/media/encoders/index.js +12 -0
  70. package/dist/media/encoders/index.js.map +1 -0
  71. package/dist/media/encoders/merge.d.ts +3 -0
  72. package/dist/media/encoders/merge.js +10 -0
  73. package/dist/media/encoders/merge.js.map +1 -0
  74. package/dist/media/encoders/nvenc.d.ts +10 -0
  75. package/dist/media/encoders/nvenc.js +23 -0
  76. package/dist/media/encoders/nvenc.js.map +1 -0
  77. package/dist/media/encoders/software.d.ts +17 -0
  78. package/dist/media/encoders/software.js +31 -0
  79. package/dist/media/encoders/software.js.map +1 -0
  80. package/dist/media/encoders/vaapi.d.ts +6 -0
  81. package/dist/media/encoders/vaapi.js +22 -0
  82. package/dist/media/encoders/vaapi.js.map +1 -0
  83. package/dist/media/index.d.ts +4 -0
  84. package/dist/media/index.js +5 -0
  85. package/dist/media/index.js.map +1 -0
  86. package/dist/media/newApi.d.ts +130 -0
  87. package/dist/media/newApi.js +409 -0
  88. package/dist/media/newApi.js.map +1 -0
  89. package/dist/utils.d.ts +25 -0
  90. package/dist/utils.js +65 -0
  91. package/dist/utils.js.map +1 -0
  92. package/package.json +82 -5
  93. package/src/client/GatewayEvents.ts +48 -0
  94. package/src/client/GatewayOpCodes.ts +40 -0
  95. package/src/client/Streamer.ts +259 -0
  96. package/src/client/index.ts +3 -0
  97. package/src/client/processing/AnnexBBitstreamReaderWriter.ts +148 -0
  98. package/src/client/processing/AnnexBHelper.ts +134 -0
  99. package/src/client/processing/SPSVUIRewriter.ts +332 -0
  100. package/src/client/voice/BaseMediaConnection.ts +639 -0
  101. package/src/client/voice/CodecPayloadType.ts +59 -0
  102. package/src/client/voice/MediaUdp.ts.bak +171 -0
  103. package/src/client/voice/StreamConnection.ts +38 -0
  104. package/src/client/voice/VoiceConnection.ts +19 -0
  105. package/src/client/voice/VoiceMessageTypes.ts +263 -0
  106. package/src/client/voice/VoiceOpCodes.ts +36 -0
  107. package/src/client/voice/WebRtcWrapper.ts +218 -0
  108. package/src/client/voice/index.ts +5 -0
  109. package/src/index.ts +3 -0
  110. package/src/media/AudioStream.ts +18 -0
  111. package/src/media/BaseMediaStream.ts +198 -0
  112. package/src/media/LibavCodecId.ts +564 -0
  113. package/src/media/LibavDecoder.ts +47 -0
  114. package/src/media/LibavDemuxer.ts +293 -0
  115. package/src/media/VideoStream.ts +17 -0
  116. package/src/media/encoders/index.ts +27 -0
  117. package/src/media/encoders/merge.ts +14 -0
  118. package/src/media/encoders/nvenc.ts +38 -0
  119. package/src/media/encoders/software.ts +76 -0
  120. package/src/media/encoders/vaapi.ts +29 -0
  121. package/src/media/index.ts +4 -0
  122. package/src/media/newApi.ts +642 -0
  123. package/src/utils.ts +101 -0
@@ -0,0 +1,642 @@
1
+ import ffmpeg from "fluent-ffmpeg";
2
+ import pDebounce from "p-debounce";
3
+ import sharp from "sharp";
4
+ import Log from "debug-level";
5
+ import { type Packet, AV_PKT_FLAG_KEY } from "node-av";
6
+ import { PassThrough, type Readable } from "node:stream";
7
+ import { demux } from "./LibavDemuxer.js";
8
+ import { VideoStream } from "./VideoStream.js";
9
+ import { AudioStream } from "./AudioStream.js";
10
+ import { isBun, isDeno, isFiniteNonZero } from "../utils.js";
11
+ import { AVCodecID } from "./LibavCodecId.js";
12
+ import { createDecoder } from "./LibavDecoder.js";
13
+ import { Encoders } from "./encoders/index.js";
14
+
15
+ import type { Request } from "zeromq";
16
+ import type { SupportedVideoCodec } from "../utils.js";
17
+ import type { Streamer } from "../client/index.js";
18
+ import type { EncoderSettingsGetter } from "./encoders/index.js";
19
+ import type { VideoStreamInfo } from "./LibavDemuxer.js";
20
+ import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
21
+
22
+ export type PrepareStreamOptions = {
23
+ /**
24
+ * Disable video transcoding
25
+ * If enabled, all video related settings have no effects, and the input
26
+ * video stream is used as-is.
27
+ *
28
+ * You need to ensure that the video stream has the right properties
29
+ * (keyframe every 1s, B-frames disabled). Failure to do so will result in
30
+ * a glitchy stream, or degraded performance
31
+ */
32
+ noTranscoding: boolean;
33
+
34
+ /**
35
+ * Video width
36
+ */
37
+ width: number;
38
+
39
+ /**
40
+ * Video height
41
+ */
42
+ height: number;
43
+
44
+ /**
45
+ * Video frame rate
46
+ */
47
+ frameRate?: number;
48
+
49
+ /**
50
+ * Video codec
51
+ */
52
+ videoCodec: SupportedVideoCodec;
53
+
54
+ /**
55
+ * Video average bitrate in kbps
56
+ */
57
+ bitrateVideo: number;
58
+
59
+ /**
60
+ * Video max bitrate in kbps
61
+ */
62
+ bitrateVideoMax: number;
63
+
64
+ /**
65
+ * Audio bitrate in kbps
66
+ */
67
+ bitrateAudio: number;
68
+
69
+ /**
70
+ * Enable audio output
71
+ */
72
+ includeAudio: boolean;
73
+
74
+ /**
75
+ * Functions to get encoder settings
76
+ * This function will receive the average and max bitrate as the input, and
77
+ * returns an object containing encoder settings for the supported codecs
78
+ */
79
+ encoder: EncoderSettingsGetter;
80
+
81
+ /**
82
+ * Enable hardware accelerated decoding
83
+ */
84
+ hardwareAcceleratedDecoding: boolean;
85
+
86
+ /**
87
+ * Add some options to minimize latency
88
+ */
89
+ minimizeLatency: boolean;
90
+
91
+ /**
92
+ * Custom headers for HTTP requests
93
+ */
94
+ customHeaders: Record<string, string>;
95
+
96
+ /**
97
+ * Custom input options to pass directly to ffmpeg
98
+ * These will be added to the command before other options
99
+ */
100
+ customInputOptions: string[];
101
+
102
+ /**
103
+ * Custom ffmpeg flags/options to pass directly to ffmpeg
104
+ * These will be added to the command after other options
105
+ */
106
+ customFfmpegFlags: string[];
107
+ };
108
+
109
+ export type Controller = {
110
+ volume: number;
111
+ setVolume(newVolume: number): Promise<boolean>;
112
+ };
113
+
114
+ export function prepareStream(
115
+ input: string | Readable,
116
+ options: Partial<PrepareStreamOptions> = {},
117
+ cancelSignal?: AbortSignal,
118
+ ) {
119
+ cancelSignal?.throwIfAborted();
120
+ const defaultOptions = {
121
+ noTranscoding: false,
122
+ // negative values = resize by aspect ratio, see https://trac.ffmpeg.org/wiki/Scaling
123
+ width: -2,
124
+ height: -2,
125
+ frameRate: undefined,
126
+ videoCodec: "H264",
127
+ bitrateVideo: 5000,
128
+ bitrateVideoMax: 7000,
129
+ bitrateAudio: 128,
130
+ includeAudio: true,
131
+ encoder: Encoders.software(),
132
+ hardwareAcceleratedDecoding: false,
133
+ minimizeLatency: false,
134
+ customHeaders: {
135
+ "User-Agent":
136
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3",
137
+ Connection: "keep-alive",
138
+ },
139
+ customInputOptions: [],
140
+ customFfmpegFlags: [],
141
+ } satisfies PrepareStreamOptions;
142
+
143
+ function mergeOptions(opts: Partial<PrepareStreamOptions>) {
144
+ return {
145
+ noTranscoding: opts.noTranscoding ?? defaultOptions.noTranscoding,
146
+
147
+ width: isFiniteNonZero(opts.width)
148
+ ? Math.round(opts.width)
149
+ : defaultOptions.width,
150
+
151
+ height: isFiniteNonZero(opts.height)
152
+ ? Math.round(opts.height)
153
+ : defaultOptions.height,
154
+
155
+ frameRate:
156
+ isFiniteNonZero(opts.frameRate) && opts.frameRate > 0
157
+ ? opts.frameRate
158
+ : defaultOptions.frameRate,
159
+
160
+ videoCodec: opts.videoCodec ?? defaultOptions.videoCodec,
161
+
162
+ bitrateVideo:
163
+ isFiniteNonZero(opts.bitrateVideo) && opts.bitrateVideo > 0
164
+ ? Math.round(opts.bitrateVideo)
165
+ : defaultOptions.bitrateVideo,
166
+
167
+ bitrateVideoMax:
168
+ isFiniteNonZero(opts.bitrateVideoMax) && opts.bitrateVideoMax > 0
169
+ ? Math.round(opts.bitrateVideoMax)
170
+ : defaultOptions.bitrateVideoMax,
171
+
172
+ bitrateAudio:
173
+ isFiniteNonZero(opts.bitrateAudio) && opts.bitrateAudio > 0
174
+ ? Math.round(opts.bitrateAudio)
175
+ : defaultOptions.bitrateAudio,
176
+
177
+ encoder: opts.encoder ?? defaultOptions.encoder,
178
+
179
+ includeAudio: opts.includeAudio ?? defaultOptions.includeAudio,
180
+
181
+ hardwareAcceleratedDecoding:
182
+ opts.hardwareAcceleratedDecoding ??
183
+ defaultOptions.hardwareAcceleratedDecoding,
184
+
185
+ minimizeLatency: opts.minimizeLatency ?? defaultOptions.minimizeLatency,
186
+
187
+ customHeaders: {
188
+ ...defaultOptions.customHeaders,
189
+ ...opts.customHeaders,
190
+ },
191
+ customInputOptions:
192
+ opts.customInputOptions ?? defaultOptions.customInputOptions,
193
+ customFfmpegFlags:
194
+ opts.customFfmpegFlags ?? defaultOptions.customFfmpegFlags,
195
+ } satisfies PrepareStreamOptions;
196
+ }
197
+
198
+ const mergedOptions = mergeOptions(options);
199
+
200
+ let isHttpUrl = false;
201
+ let isHls = false;
202
+ let isSrt = false;
203
+
204
+ if (typeof input === "string") {
205
+ isHttpUrl = input.startsWith("http") || input.startsWith("https");
206
+ isHls = input.includes("m3u");
207
+ isSrt = input.startsWith("srt://");
208
+ }
209
+
210
+ const output = new PassThrough();
211
+
212
+ // command creation
213
+ const command = ffmpeg(input);
214
+
215
+ // input options
216
+ if (
217
+ mergedOptions.customInputOptions &&
218
+ mergedOptions.customInputOptions.length > 0
219
+ ) {
220
+ command.inputOptions(mergedOptions.customInputOptions);
221
+ }
222
+
223
+ const { hardwareAcceleratedDecoding, minimizeLatency, customHeaders } =
224
+ mergedOptions;
225
+ if (hardwareAcceleratedDecoding) command.inputOption("-hwaccel", "auto");
226
+
227
+ if (minimizeLatency) {
228
+ command.addOptions(["-fflags nobuffer", "-analyzeduration 0"]);
229
+ }
230
+
231
+ if (isHttpUrl) {
232
+ command.inputOption(
233
+ "-headers",
234
+ Object.entries(customHeaders)
235
+ .map(([k, v]) => `${k}: ${v}`)
236
+ .join("\r\n"),
237
+ );
238
+ if (!isHls) {
239
+ command.inputOptions([
240
+ "-reconnect 1",
241
+ "-reconnect_at_eof 1",
242
+ "-reconnect_streamed 1",
243
+ "-reconnect_delay_max 4294",
244
+ ]);
245
+ }
246
+ }
247
+
248
+ if (isSrt) {
249
+ command.inputOption("-scan_all_pmts 0");
250
+ }
251
+
252
+ // general output options
253
+ command.output(output).outputFormat("nut");
254
+
255
+ // video setup
256
+ const {
257
+ noTranscoding,
258
+ width,
259
+ height,
260
+ frameRate,
261
+ bitrateVideo,
262
+ bitrateVideoMax,
263
+ videoCodec,
264
+ encoder,
265
+ } = mergedOptions;
266
+ command.addOutputOption("-map 0:v");
267
+
268
+ if (noTranscoding) {
269
+ command.videoCodec("copy");
270
+ } else {
271
+ command.videoFilter(`scale=${width}:${height}`);
272
+
273
+ if (frameRate) command.fpsOutput(frameRate);
274
+
275
+ command.addOutputOption([
276
+ "-b:v",
277
+ `${bitrateVideo}k`,
278
+ "-maxrate:v",
279
+ `${bitrateVideoMax}k`,
280
+ "-bufsize:v",
281
+ `${Math.round(bitrateVideo / 2)}k`,
282
+ "-bf",
283
+ "0",
284
+ "-pix_fmt",
285
+ "yuv420p",
286
+ "-force_key_frames",
287
+ "expr:gte(t,n_forced*1)",
288
+ ]);
289
+
290
+ const encoderSettings = encoder(bitrateVideo, bitrateVideoMax)[videoCodec];
291
+ if (!encoderSettings)
292
+ throw new Error(`Encoder settings not specified for ${videoCodec}`);
293
+ command
294
+ .videoCodec(encoderSettings.name)
295
+ .videoFilter(encoderSettings.outFilters ?? [])
296
+ .outputOptions(encoderSettings.options)
297
+ .outputOptions(encoderSettings.globalOptions ?? []);
298
+ }
299
+
300
+ // audio setup
301
+ const { includeAudio, bitrateAudio } = mergedOptions;
302
+ if (includeAudio)
303
+ command
304
+ .addOutputOption("-map 0:a:0?")
305
+ .audioChannels(2)
306
+ /*
307
+ * I don't have much surround sound material to test this with,
308
+ * if you do and you have better settings for this, feel free to
309
+ * contribute!
310
+ */
311
+ .addOutputOption("-lfe_mix_level 1")
312
+ .audioFrequency(48000)
313
+ .audioCodec("libopus")
314
+ .audioBitrate(`${bitrateAudio}k`)
315
+ .audioFilters("volume@internal_lib=1.0");
316
+
317
+ // Add custom ffmpeg flags
318
+ if (
319
+ mergedOptions.customFfmpegFlags &&
320
+ mergedOptions.customFfmpegFlags.length > 0
321
+ ) {
322
+ command.addOptions(mergedOptions.customFfmpegFlags);
323
+ }
324
+
325
+ // exit handling
326
+ const promise = new Promise<void>((resolve, reject) => {
327
+ command.on("error", (err) => {
328
+ if (cancelSignal?.aborted)
329
+ /**
330
+ * fluent-ffmpeg might throw an error when SIGTERM is sent to
331
+ * the process, so we check if the abort signal is triggered
332
+ * and throw that instead
333
+ */
334
+ reject(cancelSignal.reason);
335
+ else reject(err);
336
+ });
337
+ command.on("end", () => resolve());
338
+ });
339
+ promise.catch(() => { });
340
+ cancelSignal?.addEventListener("abort", () => command.kill("SIGTERM"), {
341
+ once: true,
342
+ });
343
+
344
+ // realtime control mechanism
345
+ let currentVolume = 1;
346
+ let zmqClientPromise: Promise<Request> | undefined;
347
+ if (includeAudio && !isBun() && !isDeno()) {
348
+ function randomInclusive(start: number, end: number) {
349
+ return Math.floor(Math.random() * (end - start + 1)) + start;
350
+ }
351
+ // Last octet is from 2 to 254 to avoid WSL2 shenanigans
352
+ const loopbackIp = [
353
+ 127,
354
+ randomInclusive(0, 255),
355
+ randomInclusive(0, 255),
356
+ randomInclusive(2, 254),
357
+ ].join(".");
358
+ const zmqEndpoint = `tcp://${loopbackIp}:42069`;
359
+ command.audioFilters(`azmq=b=${zmqEndpoint.replaceAll(":", "\\\\:")}`);
360
+ zmqClientPromise = import("zeromq").then((zmq) => {
361
+ const client = new zmq.Request({
362
+ sendTimeout: 5000,
363
+ receiveTimeout: 5000,
364
+ });
365
+ client.connect(zmqEndpoint);
366
+ promise.catch(() => { }).finally(() => client.disconnect(zmqEndpoint));
367
+ return client;
368
+ });
369
+ }
370
+
371
+ command.run();
372
+
373
+ return {
374
+ command,
375
+ output,
376
+ promise,
377
+ controller: {
378
+ get volume() {
379
+ return currentVolume;
380
+ },
381
+ async setVolume(newVolume: number) {
382
+ if (newVolume < 0) return false;
383
+ try {
384
+ if (!zmqClientPromise) return false;
385
+ const client = await zmqClientPromise;
386
+ await client.send(`volume@internal_lib volume ${newVolume}`);
387
+ const [res] = await client.receive();
388
+ if (res.toString("utf-8").split(" ")[0] !== "0") return false;
389
+ currentVolume = newVolume;
390
+ return true;
391
+ } catch {
392
+ return false;
393
+ }
394
+ },
395
+ } satisfies Controller,
396
+ };
397
+ }
398
+
399
+ export type PlayStreamOptions = {
400
+ /**
401
+ * Set stream type as "Go Live" or camera stream
402
+ */
403
+ type: "go-live" | "camera";
404
+
405
+ /**
406
+ * Set format of the stream
407
+ */
408
+ format: "matroska" | "nut";
409
+
410
+ /**
411
+ * Override video width sent to Discord.
412
+ *
413
+ * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
414
+ */
415
+ width: number | ((v: VideoStreamInfo) => number);
416
+
417
+ /**
418
+ * Override video height sent to Discord.
419
+ *
420
+ * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
421
+ */
422
+ height: number | ((v: VideoStreamInfo) => number);
423
+
424
+ /**
425
+ * Override video frame rate sent to Discord.
426
+ *
427
+ * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
428
+ */
429
+ frameRate: number | ((v: VideoStreamInfo) => number);
430
+
431
+ /**
432
+ * Same as ffmpeg's `readrate_initial_burst` command line flag
433
+ *
434
+ * See https://ffmpeg.org/ffmpeg.html#:~:text=%2Dreadrate_initial_burst
435
+ */
436
+ readrateInitialBurst: number | undefined;
437
+
438
+ /**
439
+ * Enable stream preview from input stream (experimental)
440
+ */
441
+ streamPreview: boolean;
442
+ };
443
+
444
+ export async function playStream(
445
+ input: Readable,
446
+ streamer: Streamer,
447
+ options: Partial<PlayStreamOptions> = {},
448
+ cancelSignal?: AbortSignal,
449
+ ) {
450
+ const logger = new Log("playStream");
451
+ cancelSignal?.throwIfAborted();
452
+ if (!streamer.voiceConnection)
453
+ throw new Error("Bot is not connected to a voice channel");
454
+
455
+ const defaultOptions = {
456
+ type: "go-live",
457
+ format: "nut",
458
+ width: (video) => video.width,
459
+ height: (video) => video.height,
460
+ frameRate: (video) => video.framerate_num / video.framerate_den,
461
+ readrateInitialBurst: undefined,
462
+ streamPreview: false,
463
+ } satisfies PlayStreamOptions;
464
+
465
+ function mergeOptions(opts: Partial<PlayStreamOptions>) {
466
+ return {
467
+ type: opts.type ?? defaultOptions.type,
468
+
469
+ format: opts.format ?? defaultOptions.format,
470
+
471
+ width:
472
+ typeof opts.width === "function" ||
473
+ (isFiniteNonZero(opts.width) && opts.width > 0)
474
+ ? opts.width
475
+ : defaultOptions.width,
476
+
477
+ height:
478
+ typeof opts.height === "function" ||
479
+ (isFiniteNonZero(opts.height) && opts.height > 0)
480
+ ? opts.height
481
+ : defaultOptions.height,
482
+
483
+ frameRate:
484
+ typeof opts.frameRate === "function" ||
485
+ (isFiniteNonZero(opts.frameRate) && opts.frameRate > 0)
486
+ ? opts.frameRate
487
+ : defaultOptions.frameRate,
488
+
489
+ readrateInitialBurst:
490
+ isFiniteNonZero(opts.readrateInitialBurst) &&
491
+ opts.readrateInitialBurst > 0
492
+ ? opts.readrateInitialBurst
493
+ : defaultOptions.readrateInitialBurst,
494
+
495
+ streamPreview: opts.streamPreview ?? defaultOptions.streamPreview,
496
+ } satisfies PlayStreamOptions;
497
+ }
498
+
499
+ const mergedOptions = mergeOptions(options);
500
+ logger.debug({ options: mergedOptions }, "Merged options");
501
+
502
+ logger.debug("Initializing demuxer");
503
+ const { video, audio } = await demux(input, {
504
+ format: mergedOptions.format,
505
+ });
506
+ cancelSignal?.throwIfAborted();
507
+
508
+ if (!video) throw new Error("No video stream in media");
509
+
510
+ const cleanupFuncs: (() => unknown)[] = [];
511
+ const videoCodecMap: Record<number, SupportedVideoCodec> = {
512
+ [AVCodecID.AV_CODEC_ID_H264]: "H264",
513
+ [AVCodecID.AV_CODEC_ID_H265]: "H265",
514
+ [AVCodecID.AV_CODEC_ID_VP8]: "VP8",
515
+ [AVCodecID.AV_CODEC_ID_VP9]: "VP9",
516
+ [AVCodecID.AV_CODEC_ID_AV1]: "AV1",
517
+ };
518
+
519
+ let conn: WebRtcConnWrapper;
520
+ let stopStream: () => unknown;
521
+ if (mergedOptions.type === "go-live") {
522
+ conn = await streamer.createStream();
523
+ stopStream = () => streamer.stopStream();
524
+ } else {
525
+ conn = streamer.voiceConnection.webRtcConn;
526
+ streamer.signalVideo(true);
527
+ stopStream = () => streamer.signalVideo(false);
528
+ }
529
+ conn.setPacketizer(videoCodecMap[video.codec]);
530
+ conn.mediaConnection.setSpeaking(true);
531
+ const { width, height, frameRate } = mergedOptions;
532
+ conn.mediaConnection.setVideoAttributes(true, {
533
+ width: Math.round(typeof width === "function" ? width(video) : width),
534
+ height: Math.round(typeof height === "function" ? height(video) : height),
535
+ fps: Math.round(
536
+ typeof frameRate === "function" ? frameRate(video) : frameRate,
537
+ ),
538
+ });
539
+
540
+ const vStream = new VideoStream(conn);
541
+ video.stream.pipe(vStream);
542
+ if (audio) {
543
+ const aStream = new AudioStream(conn);
544
+ audio.stream.pipe(aStream);
545
+
546
+ // Community Patch by Piixish:
547
+ // Disabled the aggressive audio-catchup logic to prevent WebRTC UDP socket flooding
548
+ // and sudden 5-second Voice disconnects on intensive feeds.
549
+ // A/V synchronization is naturally and flawlessly preserved by FFmpeg's native `.nut` multiplexing!
550
+ // vStream.syncStream = aStream;
551
+
552
+ const burstTime = mergedOptions.readrateInitialBurst;
553
+ if (typeof burstTime === "number") {
554
+ vStream.sync = false;
555
+ vStream.noSleep = aStream.noSleep = true;
556
+ const stopBurst = (pts: number) => {
557
+ if (pts < burstTime * 1000) return;
558
+ vStream.sync = true;
559
+ vStream.noSleep = aStream.noSleep = false;
560
+ vStream.off("pts", stopBurst);
561
+ };
562
+ vStream.on("pts", stopBurst);
563
+ }
564
+ }
565
+ if (mergedOptions.streamPreview && mergedOptions.type === "go-live") {
566
+ (async () => {
567
+ const logger = new Log("playStream:preview");
568
+ logger.debug("Initializing decoder for stream preview");
569
+ const decoder = await createDecoder(video.avStream);
570
+ if (!decoder) {
571
+ logger.warn(
572
+ "Failed to initialize decoder. Stream preview will be disabled",
573
+ );
574
+ return;
575
+ }
576
+ cleanupFuncs.push(() => {
577
+ logger.debug("Freeing decoder");
578
+ decoder.free();
579
+ });
580
+ const updatePreview = pDebounce.promise(async (packet: Packet) => {
581
+ if (!(packet.flags !== undefined && packet.flags & AV_PKT_FLAG_KEY))
582
+ return;
583
+ const decodeStart = performance.now();
584
+ const frames = await decoder.decode(packet).catch((e) => {
585
+ logger.error(e, "Failed to decode the frame");
586
+ return [];
587
+ });
588
+ if (!frames.length) return;
589
+
590
+ const decodeEnd = performance.now();
591
+ logger.debug(`Decoding a frame took ${decodeEnd - decodeStart}ms`);
592
+ const frame = frames[0];
593
+
594
+ return sharp(frame.toBuffer(), {
595
+ raw: {
596
+ width: frame.width ?? 0,
597
+ height: frame.height ?? 0,
598
+ channels: 4,
599
+ },
600
+ })
601
+ .resize(1024, 576, { fit: "inside" })
602
+ .jpeg()
603
+ .toBuffer()
604
+ .then((image) => streamer.setStreamPreview(image))
605
+ .catch(() => { })
606
+ .finally(() => {
607
+ frames.forEach((frame) => {
608
+ frame.free();
609
+ });
610
+ });
611
+ });
612
+ video.stream.on("data", updatePreview);
613
+ cleanupFuncs.push(() => video.stream.off("data", updatePreview));
614
+ })();
615
+ }
616
+ return new Promise<void>((resolve, reject) => {
617
+ cleanupFuncs.push(() => {
618
+ stopStream();
619
+ conn.mediaConnection.setSpeaking(false);
620
+ conn.mediaConnection.setVideoAttributes(false);
621
+ });
622
+ let cleanedUp = false;
623
+ const cleanup = () => {
624
+ if (cleanedUp) return;
625
+ cleanedUp = true;
626
+ for (const f of cleanupFuncs) f();
627
+ };
628
+ cancelSignal?.addEventListener(
629
+ "abort",
630
+ () => {
631
+ cleanup();
632
+ reject(cancelSignal.reason);
633
+ },
634
+ { once: true },
635
+ );
636
+ vStream.once("finish", () => {
637
+ if (cancelSignal?.aborted) return;
638
+ cleanup();
639
+ resolve();
640
+ });
641
+ }).catch(() => { });
642
+ }