From 9537628eb3936aff7deaecc1172f9e2d77c616ea Mon Sep 17 00:00:00 2001 From: Long Nguyen Date: Thu, 6 Feb 2025 23:30:41 +0700 Subject: [PATCH] Cleanup various parts of the library (#150) * Remove `streamLivestreamVideo` * Stop exposing `(Audio|Video)Stream` * Move `setProtocols` call back into `BaseMediaConnection` User shouldn't be calling this anyway * Remove problematic `streamOptions`, move force Chacha20 options to `Streamer` Moving the Chacha20 options to the Streamer class is probably a good move, since it's unlikely that anyone would want 2 separate encryption methods for the 2 streams * Remove the `forceChacha20Encryption` option in `playStream` * Expose new API as top level exports * Pass SSRC through constructor * Move `rtcpSenderReportEnabled` option to Streamer, make RTCP interval time-based * Fix README * Remove old basic example * Add `noTranscoding` option to README * Remove `custom-stream-copy-codec` example The functionality is now built in to the library * Update `puppeteer-stream` example Untested, but I don't see why it shouldn't work * Add newlines to fix IDE docs * Document `playStream` options, specify where to change streamer options * Add optional specifier to `playStream` options * Move `sendOpcode` call outside of promise creation * Fix incorrect logic * Revert "Move `sendOpcode` call outside of promise creation" Due to some unknown causes, the event ends up firing before the handler is attached * How did this happen... * Update streamer options * Add performance tips * Remove leftover `srInterval` override * Use `crypto` global instead of importing `node:crypto` --- PERFORMANCE.md | 81 ++++++++ README.md | 188 ++++++++++++++---- examples/basic-new-api/README.md | 3 - examples/basic-new-api/package.json | 23 --- examples/basic-new-api/src/config.json | 13 -- examples/basic-new-api/src/index.ts | 105 ---------- examples/basic-new-api/tsconfig.json | 106 ---------- examples/basic/README.md | 2 +- examples/basic/package.json | 4 +- examples/basic/src/index.ts | 108 ++++------ examples/custom-stream-copy-codec/README.md | 3 - .../custom-stream-copy-codec/package.json | 28 --- .../custom-stream-copy-codec/src/config.json | 8 - .../src/customStream.ts | 120 ----------- .../custom-stream-copy-codec/src/index.ts | 98 --------- .../custom-stream-copy-codec/tsconfig.json | 106 ---------- examples/puppeteer-stream/package.json | 13 +- examples/puppeteer-stream/src/index.ts | 85 ++++---- src/client/Streamer.ts | 36 +++- src/client/encryptor/TransportEncryptor.ts | 7 +- src/client/packet/AudioPacketizer.ts | 5 +- src/client/packet/BaseMediaPacketizer.ts | 65 +++--- src/client/packet/VideoPacketizerAnnexB.ts | 13 +- src/client/packet/VideoPacketizerVP8.ts | 5 +- src/client/voice/BaseMediaConnection.ts | 172 ++++++---------- src/client/voice/MediaUdp.ts | 15 +- src/index.ts | 1 + src/media/index.ts | 3 - src/media/newApi.ts | 39 +--- src/media/streamLivestreamVideo.ts | 182 ----------------- 30 files changed, 467 insertions(+), 1170 deletions(-) create mode 100644 PERFORMANCE.md delete mode 100644 examples/basic-new-api/README.md delete mode 100644 examples/basic-new-api/package.json delete mode 100644 examples/basic-new-api/src/config.json delete mode 100644 examples/basic-new-api/src/index.ts delete mode 100644 examples/basic-new-api/tsconfig.json delete mode 100644 examples/custom-stream-copy-codec/README.md delete mode 100644 examples/custom-stream-copy-codec/package.json delete mode 100644 examples/custom-stream-copy-codec/src/config.json delete mode 100644 examples/custom-stream-copy-codec/src/customStream.ts delete mode 100644 examples/custom-stream-copy-codec/src/index.ts delete mode 100644 examples/custom-stream-copy-codec/tsconfig.json delete mode 100644 src/media/streamLivestreamVideo.ts diff --git a/PERFORMANCE.md b/PERFORMANCE.md new file mode 100644 index 0000000..3e89f93 --- /dev/null +++ b/PERFORMANCE.md @@ -0,0 +1,81 @@ +# Performance related tweaks + +## Transport encryption methods + +On CPUs without AES acceleration (very old x86 CPUs, certain ARM SoCs on single board computers, certain VMs that don't expose AES acceleration capability), the default encryption method (AES-256-GCM) might not be fast enough to handle high frame-rate + high bitrate streams. + +In such cases, you can enable the `forceChacha20Encryption` option on the `Streamer` instance (`streamer.opts.forceChacha20Encryption = true`) before starting a stream, to force the use of the faster Chacha20-Poly1305 encryption method. For even higher performance, also install the optional [`sodium-native`](https://www.npmjs.com/package/sodium-native) package to use the faster native version instead of the WASM version. + +Below are some benchmark results of the two encryption methods in various circumstances, for reference purposes only. All benchmarks are performed on a Ryzen 5 5600H. + +
+AES-256-GCM, with AES acceleration + +``` +PS C:\> openssl speed -elapsed -aead -evp aes-256-gcm +You have chosen to measure elapsed time instead of user CPU time. +Doing AES-256-GCM ops for 3s on 2 size blocks: 19046296 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 31 size blocks: 15299030 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 136 size blocks: 13580376 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 1024 size blocks: 7691855 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 8192 size blocks: 1648811 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 16384 size blocks: 863115 AES-256-GCM ops in 3.00s +version: 3.4.0 +built on: Tue Oct 22 23:27:41 2024 UTC +options: bn(64,64) +compiler: cl /Z7 /Fdossl_static.pdb /Gs0 /GF /Gy /MD /W3 /wd4090 /nologo /O2 -DL_ENDIAN -DOPENSSL_PIC -D"OPENSSL_BUILDING_OPENSSL" -D"OPENSSL_SYS_WIN32" -D"WIN32_LEAN_AND_MEAN" -D"UNICODE" -D"_UNICODE" -D"_CRT_SECURE_NO_DEPRECATE" -D"_WINSOCK_DEPRECATED_NO_WARNINGS" -D"NDEBUG" -D_WINSOCK_DEPRECATED_NO_WARNINGS -D_WIN32_WINNT=0x0502 +CPUINFO: OPENSSL_ia32cap=0xfed8320b078bffff:0x400684219c97a9 +The 'numbers' are in 1000s of bytes per second processed. +type 2 bytes 31 bytes 136 bytes 1024 bytes 8192 bytes 16384 bytes +AES-256-GCM 12693.30k 158089.98k 615233.56k 2625486.51k 4500852.95k 4712187.99k +``` + +
+ +
+AES-256-GCM, without AES acceleration + +``` +PS C:\> openssl speed -elapsed -aead -evp aes-256-gcm +You have chosen to measure elapsed time instead of user CPU time. +Doing AES-256-GCM ops for 3s on 2 size blocks: 6947831 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 31 size blocks: 4875037 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 136 size blocks: 3132696 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 1024 size blocks: 821006 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 8192 size blocks: 113769 AES-256-GCM ops in 3.00s +Doing AES-256-GCM ops for 3s on 16384 size blocks: 57074 AES-256-GCM ops in 3.00s +version: 3.4.0 +built on: Tue Oct 22 23:27:41 2024 UTC +options: bn(64,64) +compiler: cl /Z7 /Fdossl_static.pdb /Gs0 /GF /Gy /MD /W3 /wd4090 /nologo /O2 -DL_ENDIAN -DOPENSSL_PIC -D"OPENSSL_BUILDING_OPENSSL" -D"OPENSSL_SYS_WIN32" -D"WIN32_LEAN_AND_MEAN" -D"UNICODE" -D"_UNICODE" -D"_CRT_SECURE_NO_DEPRECATE" -D"_WINSOCK_DEPRECATED_NO_WARNINGS" -D"NDEBUG" -D_WINSOCK_DEPRECATED_NO_WARNINGS -D_WIN32_WINNT=0x0502 +CPUINFO: OPENSSL_ia32cap=0xfcd83209078bffff:0x0 env:~0x200000200000000 +The 'numbers' are in 1000s of bytes per second processed. +type 2 bytes 31 bytes 136 bytes 1024 bytes 8192 bytes 16384 bytes +AES-256-GCM 4630.34k 50358.60k 142015.55k 280143.33k 310561.70k 311596.27k +``` + +
+ +
+Chacha20-Poly1305 + +``` +PS C:\> openssl speed -elapsed -aead -evp chacha20-poly1305 +You have chosen to measure elapsed time instead of user CPU time. +Doing ChaCha20-Poly1305 ops for 3s on 2 size blocks: 8312139 ChaCha20-Poly1305 ops in 3.00s +Doing ChaCha20-Poly1305 ops for 3s on 31 size blocks: 7801222 ChaCha20-Poly1305 ops in 3.00s +Doing ChaCha20-Poly1305 ops for 3s on 136 size blocks: 5436377 ChaCha20-Poly1305 ops in 3.00s +Doing ChaCha20-Poly1305 ops for 3s on 1024 size blocks: 4182141 ChaCha20-Poly1305 ops in 3.00s +Doing ChaCha20-Poly1305 ops for 3s on 8192 size blocks: 903567 ChaCha20-Poly1305 ops in 3.00s +Doing ChaCha20-Poly1305 ops for 3s on 16384 size blocks: 472556 ChaCha20-Poly1305 ops in 3.00s +version: 3.4.0 +built on: Tue Oct 22 23:27:41 2024 UTC +options: bn(64,64) +compiler: cl /Z7 /Fdossl_static.pdb /Gs0 /GF /Gy /MD /W3 /wd4090 /nologo /O2 -DL_ENDIAN -DOPENSSL_PIC -D"OPENSSL_BUILDING_OPENSSL" -D"OPENSSL_SYS_WIN32" -D"WIN32_LEAN_AND_MEAN" -D"UNICODE" -D"_UNICODE" -D"_CRT_SECURE_NO_DEPRECATE" -D"_WINSOCK_DEPRECATED_NO_WARNINGS" -D"NDEBUG" -D_WINSOCK_DEPRECATED_NO_WARNINGS -D_WIN32_WINNT=0x0502 +CPUINFO: OPENSSL_ia32cap=0xfed8320b078bffff:0x400684219c97a9 +The 'numbers' are in 1000s of bytes per second processed. +type 2 bytes 31 bytes 136 bytes 1024 bytes 8192 bytes 16384 bytes +ChaCha20-Poly1305 5539.58k 80585.77k 246284.90k 1427504.13k 2465696.49k 2580785.83k +``` + +
diff --git a/README.md b/README.md index d11807f..15c0261 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # Discord self-bot video + Fork: [Discord-video-experiment](https://github.com/mrjvs/Discord-video-experiment) > [!CAUTION] @@ -9,34 +10,42 @@ This project implements the custom Discord UDP protocol for sending media. Since For better stability it is recommended to use WebRTC protocol instead since Discord is forced to adhere to spec, which means that the non-signaling portion of the code is guaranteed to work. ## Features - - Playing video & audio in a voice channel (`Go Live`, or webcam video) + +- Playing video & audio in a voice channel (`Go Live`, or webcam video) ## Implementation + What I implemented and what I did not. -#### Video codecs - - [X] VP8 - - [ ] VP9 - - [X] H.264 - - [X] H.265 - - [ ] AV1 +### Video codecs + +- [X] VP8 +- [ ] VP9 +- [X] H.264 +- [X] H.265 +- [ ] AV1 + +### Packet types -#### Packet types - - [X] RTP (sending of realtime data) - - [ ] RTX (retransmission) +- [X] RTP (sending of realtime data) +- [ ] RTX (retransmission) -#### Connection types - - [X] Regular Voice Connection - - [X] Go live +### Connection types - ### Encryption - - [X] Transport Encryption - - [ ] https://github.com/dank074/Discord-video-stream/issues/102 +- [X] Regular Voice Connection +- [X] Go live -#### Extras - - [X] Figure out rtp header extensions (discord specific) (discord seems to use one-byte RTP header extension https://www.rfc-editor.org/rfc/rfc8285.html#section-4.2) +### Encryption + +- [X] Transport Encryption +- [ ] [End-to-end Encryption](https://github.com/dank074/Discord-video-stream/issues/102) + +### Extras + +- [X] Figure out rtp header extensions (discord specific) (discord seems to use one-byte RTP header extension https://www.rfc-editor.org/rfc/rfc8285.html#section-4.2) Extensions supported by Discord (taken from the webrtc sdp exchange) + ``` "a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level" "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time" @@ -51,19 +60,24 @@ Extensions supported by Discord (taken from the webrtc sdp exchange) "a=extmap:13 urn:3gpp:video-orientation" "a=extmap:14 urn:ietf:params:rtp-hdrext:toffset" ``` + ## Requirements + Ffmpeg is required for the usage of this package. If you are on linux you can easily install ffmpeg from your distribution's package manager. If you are on Windows, you can download it from the official ffmpeg website: https://ffmpeg.org/download.html ## Usage + Install the package, alongside its peer-dependency discord.js-selfbot-v13: + ``` npm install @dank074/discord-video-stream@latest npm install discord.js-selfbot-v13@latest ``` Create a new Streamer, and pass it a selfbot Client + ```typescript import { Client } from "discord.js-selfbot-v13"; import { Streamer } from '@dank074/discord-video-stream'; @@ -74,6 +88,7 @@ await streamer.client.login('TOKEN HERE'); ``` Make client join a voice channel and create a stream: + ```typescript await streamer.joinVoice("GUILD ID HERE", "CHANNEL ID HERE"); @@ -83,29 +98,47 @@ const udp = await streamer.createStream({ ``` Start sending media over the udp connection: + ```typescript -udp.mediaConnection.setSpeaking(true); -udp.mediaConnection.setVideoStatus(true); +import { prepareStream, playStream, Util } from "@dank074/discord-video-stream" try { - const cancellableCommand = await streamLivestreamVideo("DIRECT VIDEO URL OR READABLE STREAM HERE", udp); + const { command, output } = prepareStream("DIRECT VIDEO URL OR READABLE STREAM HERE", { + // Specify either width or height for aspect ratio aware scaling + // Specify both for stretched output + height: 1080, + + // Force frame rate, or leave blank to use source frame rate + frameRate: 30, + bitrateVideo: 5000, + bitrateVideoMax: 7500, + videoCodec: Utils.normalizeVideoCodec("H264" /* or H265, VP9 */), + h26xPreset: "veryfast" // or superfast, ultrafast, ... + }); + command.on("error", (err, stdout, stderr) => { + // Handle ffmpeg errors here + }); - const result = await cancellableCommand; - console.log("Finished playing video " + res); + await playStream(output, streamer, { + type: "go-live" // use "camera" for camera stream + }); + + console.log("Finished playing video"); } catch (e) { - if (command.isCanceled) { - // Handle the cancelation here - console.log('Ffmpeg command was cancelled'); - } else { - console.log(e); - } -} finally { - udp.mediaConnection.setSpeaking(false); - udp.mediaConnection.setVideoStatus(false); + console.log(e); } ``` -## Stream options available +## Encoder options available + ```typescript +/** + * Disable transcoding of the video stream. If specified, all video related + * options have no effects + * + * Only use this if your video stream is Discord streaming friendly, otherwise + * you'll get a glitchy output + */ +noTranscoding?: boolean; /** * Video output width */ @@ -119,10 +152,21 @@ height?: number; */ fps?: number; /** - * Video output bitrate in kbps + * Video average bitrate in kbps */ -bitrateKbps?: number; -maxBitrateKbps?: number; +bitrateVideo?: number; +/** + * Video max bitrate in kbps + */ +bitrateVideoMax?: number; +/** + * Audio bitrate in kbps + */ +bitrateAudio?: number; +/** + * Enable audio output + */ +includeAudio?: boolean; /** * Enables hardware accelerated video decoding. Enabling this option might result in an exception * being thrown by Ffmpeg process if your system does not support hardware acceleration @@ -132,11 +176,6 @@ hardwareAcceleratedDecoding?: boolean; * Output video codec. **Only** supports H264, H265, and VP8 currently */ videoCodec?: SupportedVideoCodec; -/** - * Enables sending RTCP sender reports. Helps the receiver synchronize the audio/video frames, except in some weird - * cases which is why you can disable it - */ -rtcpSenderReportEnabled?: boolean; /** * Encoding preset for H264 or H265. The faster it is, the lower the quality */ @@ -146,13 +185,73 @@ h26xPreset?: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'mediu * Might create lag in video output in some rare cases */ minimizeLatency?: boolean; +``` + +## `playStream` options available + +```typescript +/** + * Set stream type as "Go Live" or camera stream + */ +type?: "go-live" | "camera", + +/** + * Override video width sent to Discord. + * + * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! + */ +width?: number, + +/** + * Override video height sent to Discord. + * + * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! + */ +height?: number, + +/** + * Override video frame rate sent to Discord. + * + * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! + */ +frameRate?: number, + +/** + * Same as ffmpeg's `readrate_initial_burst` command line flag + * + * See https://ffmpeg.org/ffmpeg.html#:~:text=%2Dreadrate_initial_burst + */ +readrateInitialBurst?: number, +``` + +## Streamer options available + +These control internal operations of the library, and can be changed through the `opts` property on the `Streamer` class. You probably shouldn't change it without a good reason + +```typescript +/** + * Enables sending RTCP sender reports. Helps the receiver synchronize the + * audio/video frames, except in some weird cases which is why you can disable it + */ +rtcpSenderReportEnabled?: boolean; /** * ChaCha20-Poly1305 Encryption is faster than AES-256-GCM, except when using AES-NI */ forceChacha20Encryption?: boolean; +/** + * Custom headers for HTTP requests + */ +customHeaders?: Record ``` + +## Performance tips + +See [this page](./PERFORMANCE.md) for some tips on improving performance + ## Running example + `examples/basic/src/config.json`: + ```json "token": "SELF TOKEN HERE", "acceptedAuthors": ["USER_ID_HERE"], @@ -162,23 +261,28 @@ forceChacha20Encryption?: boolean; 2. Generate js files with ```npm run build``` 3. Start program with: ```npm run start``` 4. Join a voice channel -5. Start streaming with commands: +5. Start streaming with commands: for go-live + ``` $play-live ``` + or for cam + ``` $play-cam ``` for example: + ``` $play-live http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 ``` ## FAQS + - Can I stream on existing voice connection (CAM) and in a go-live connection simultaneously? Yes, just send the media packets over both udp connections. The voice gateway expects you to signal when a user turns on their camera, so make sure you signal using `client.signalVideo(guildId, channelId, true)` before you start sending cam media packets. diff --git a/examples/basic-new-api/README.md b/examples/basic-new-api/README.md deleted file mode 100644 index 39f8014..0000000 --- a/examples/basic-new-api/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# basic-new-api example - -This example shows how to stream a video, both using the existing voice connection or with a Go Live connection, using the new API introduced in v4.1.3 \ No newline at end of file diff --git a/examples/basic-new-api/package.json b/examples/basic-new-api/package.json deleted file mode 100644 index e5314ff..0000000 --- a/examples/basic-new-api/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@dank074/discord-video-stream-example", - "version": "1.0.0", - "description": "", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "type": "module", - "dependencies": { - "@dank074/discord-video-stream": "^4.1.3", - "discord.js-selfbot-v13": "^3.4.5" - }, - "devDependencies": { - "@types/node": "^22.10.1", - "typescript": "^5.7.2" - }, - "scripts": { - "build": "tsc", - "start": "node ./dist/index.js", - "yeet": "npm run build && npm run start" - }, - "author": "", - "license": "ISC" -} diff --git a/examples/basic-new-api/src/config.json b/examples/basic-new-api/src/config.json deleted file mode 100644 index 569612e..0000000 --- a/examples/basic-new-api/src/config.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "token": "SELF TOKEN HERE", - "acceptedAuthors": ["USER_ID_HERE"], - "streamOpts": { - "width": 1280, - "height": 720, - "fps": 30, - "bitrateKbps": 1000, - "maxBitrateKbps": 2500, - "hardware_acceleration": false, - "videoCodec": "H264" - } -} \ No newline at end of file diff --git a/examples/basic-new-api/src/index.ts b/examples/basic-new-api/src/index.ts deleted file mode 100644 index 03a44b6..0000000 --- a/examples/basic-new-api/src/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { Client, StageChannel } from "discord.js-selfbot-v13"; -import { Streamer, Utils, NewApi } from "@dank074/discord-video-stream"; -import config from "./config.json" with {type: "json"}; - -const streamer = new Streamer(new Client()); -let current: ReturnType["command"]; - -// ready event -streamer.client.on("ready", () => { - console.log(`--- ${streamer.client.user.tag} is ready ---`); -}); - -// message event -streamer.client.on("messageCreate", async (msg) => { - if (msg.author.bot) return; - - if (!config.acceptedAuthors.includes(msg.author.id)) return; - - if (!msg.content) return; - - if (msg.content.startsWith(`$play-live`)) { - const args = parseArgs(msg.content) - if (!args) return; - - const channel = msg.author.voice.channel; - - if(!channel) return; - - console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`); - await streamer.joinVoice(msg.guildId, channel.id); - - if(channel instanceof StageChannel) - { - await streamer.client.user.voice.setSuppressed(false); - } - - const { command, output } = NewApi.prepareStream(args.url, { - width: config.streamOpts.width, - height: config.streamOpts.height, - frameRate: config.streamOpts.fps, - bitrateVideo: config.streamOpts.bitrateKbps, - bitrateVideoMax: config.streamOpts.maxBitrateKbps, - hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, - videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec) - }) - - current = command; - await NewApi.playStream(output, streamer) - .catch(() => current?.kill("SIGTERM")); - return; - } else if (msg.content.startsWith("$play-cam")) { - const args = parseArgs(msg.content); - if (!args) return; - - const channel = msg.author.voice.channel; - - if(!channel) return; - - console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`); - const vc = await streamer.joinVoice(msg.guildId, channel.id); - - if(channel instanceof StageChannel) - { - await streamer.client.user.voice.setSuppressed(false); - } - - const { command, output } = NewApi.prepareStream(args.url, { - width: config.streamOpts.width, - height: config.streamOpts.height, - frameRate: config.streamOpts.fps, - bitrateVideo: config.streamOpts.bitrateKbps, - bitrateVideoMax: config.streamOpts.maxBitrateKbps, - hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, - videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec) - }) - - current = command; - await NewApi.playStream(output, streamer, { - type: "camera" - }).catch(() => current?.kill("SIGTERM")); - - return; - } else if (msg.content.startsWith("$disconnect")) { - current?.kill("SIGTERM"); - streamer.leaveVoice(); - } else if(msg.content.startsWith("$stop-stream")) { - current?.kill("SIGTERM"); - } -}); - -// login -streamer.client.login(config.token); - -function parseArgs(message: string): Args | undefined { - const args = message.split(" "); - if (args.length < 2) return; - - const url = args[1]; - - return { url } -} - -type Args = { - url: string; -} diff --git a/examples/basic-new-api/tsconfig.json b/examples/basic-new-api/tsconfig.json deleted file mode 100644 index 3d5629f..0000000 --- a/examples/basic-new-api/tsconfig.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "NodeNext", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": false, /* Enable all strict type-checking options. */ - "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - }, - "include": [ - "src/**/*" - ] -} \ No newline at end of file diff --git a/examples/basic/README.md b/examples/basic/README.md index b188eaa..9c55934 100644 --- a/examples/basic/README.md +++ b/examples/basic/README.md @@ -1,3 +1,3 @@ # basic example -This example shows how to stream a video, both using the existing voice connection or with a Go Live connection \ No newline at end of file +This example shows how to stream a video, both using the existing voice connection or with a Go Live connection, using the new API introduced in v4.1.3 \ No newline at end of file diff --git a/examples/basic/package.json b/examples/basic/package.json index e5314ff..84ac597 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -6,8 +6,8 @@ "types": "dist/index.d.ts", "type": "module", "dependencies": { - "@dank074/discord-video-stream": "^4.1.3", - "discord.js-selfbot-v13": "^3.4.5" + "@dank074/discord-video-stream": "https://pkg.pr.new/Discord-RE/Discord-video-stream/@dank074/discord-video-stream@150", + "discord.js-selfbot-v13": "^3.5.1" }, "devDependencies": { "@types/node": "^22.10.1", diff --git a/examples/basic/src/index.ts b/examples/basic/src/index.ts index e09564d..566d1ee 100644 --- a/examples/basic/src/index.ts +++ b/examples/basic/src/index.ts @@ -1,10 +1,9 @@ import { Client, StageChannel } from "discord.js-selfbot-v13"; -import { streamLivestreamVideo, MediaUdp, getInputMetadata, inputHasAudio, Streamer, Utils } from "@dank074/discord-video-stream"; +import { Streamer, Utils, prepareStream, playStream } from "@dank074/discord-video-stream"; import config from "./config.json" with {type: "json"}; -import PCancelable from "p-cancelable"; const streamer = new Streamer(new Client()); -let command: PCancelable; +let current: ReturnType["command"]; // ready event streamer.client.on("ready", () => { @@ -30,24 +29,28 @@ streamer.client.on("messageCreate", async (msg) => { console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`); await streamer.joinVoice(msg.guildId, channel.id); - if(channel instanceof StageChannel) + if (channel instanceof StageChannel) { await streamer.client.user.voice.setSuppressed(false); } - const streamUdpConn = await streamer.createStream({ - width: config.streamOpts.width, - height: config.streamOpts.height, - fps: config.streamOpts.fps, - bitrateKbps: config.streamOpts.bitrateKbps, - maxBitrateKbps: config.streamOpts.maxBitrateKbps, + current?.kill("SIGTERM"); + const { command, output } = prepareStream(args.url, { + width: config.streamOpts.width, + height: config.streamOpts.height, + frameRate: config.streamOpts.fps, + bitrateVideo: config.streamOpts.bitrateKbps, + bitrateVideoMax: config.streamOpts.maxBitrateKbps, hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec) - }); - - await playVideo(args.url, streamUdpConn); - - streamer.stopStream(); + }) + command.on("error", (err) => { + console.log("An error happened with ffmpeg"); + console.log(err); + }) + current = command; + await playStream(output, streamer) + .catch(() => command.kill("SIGTERM")); return; } else if (msg.content.startsWith("$play-cam")) { const args = parseArgs(msg.content); @@ -55,81 +58,42 @@ streamer.client.on("messageCreate", async (msg) => { const channel = msg.author.voice.channel; - if(!channel) return; + if (!channel) return; console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`); - const vc = await streamer.joinVoice(msg.guildId, channel.id, { - width: config.streamOpts.width, - height: config.streamOpts.height, - fps: config.streamOpts.fps, - bitrateKbps: config.streamOpts.bitrateKbps, - maxBitrateKbps: config.streamOpts.maxBitrateKbps, - hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, - videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec) - }); + const vc = await streamer.joinVoice(msg.guildId, channel.id); - if(channel instanceof StageChannel) + if (channel instanceof StageChannel) { await streamer.client.user.voice.setSuppressed(false); } - streamer.signalVideo(true); - - playVideo(args.url, vc); + current?.kill("SIGTERM"); + const { command, output } = prepareStream(args.url, { + width: config.streamOpts.width, + height: config.streamOpts.height, + frameRate: config.streamOpts.fps, + bitrateVideo: config.streamOpts.bitrateKbps, + bitrateVideoMax: config.streamOpts.maxBitrateKbps, + hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, + videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec) + }) + current = command; + await playStream(output, streamer) + .catch(() => command.kill("SIGTERM")); return; } else if (msg.content.startsWith("$disconnect")) { - command?.cancel() - + current?.kill("SIGTERM"); streamer.leaveVoice(); } else if(msg.content.startsWith("$stop-stream")) { - command?.cancel() - - const stream = streamer.voiceConnection?.streamConnection; - - if(!stream) return; - - streamer.stopStream(); + current?.kill("SIGTERM"); } }); // login streamer.client.login(config.token); -async function playVideo(video: string, udpConn: MediaUdp) { - let includeAudio = true; - - try { - const metadata = await getInputMetadata(video); - //console.log(JSON.stringify(metadata.streams)); - includeAudio = inputHasAudio(metadata); - } catch(e) { - console.log(e); - return; - } - - console.log("Started playing video"); - - udpConn.mediaConnection.setSpeaking(true); - udpConn.mediaConnection.setVideoStatus(true); - try { - command = streamLivestreamVideo(video, udpConn, includeAudio); - - const res = await command; - console.log("Finished playing video " + res); - } catch (e) { - if (command.isCanceled) { - // Handle the cancelation here - console.log('Operation was canceled'); - } else { - console.log(e); - } - } finally { - udpConn.mediaConnection.setSpeaking(false); - udpConn.mediaConnection.setVideoStatus(false); - } -} - function parseArgs(message: string): Args | undefined { const args = message.split(" "); if (args.length < 2) return; diff --git a/examples/custom-stream-copy-codec/README.md b/examples/custom-stream-copy-codec/README.md deleted file mode 100644 index c25a5ad..0000000 --- a/examples/custom-stream-copy-codec/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# copy codec example - -This example shows how to use custom ffmpeg parameters to copy the video input directly to output, without any transcoding. It **only works for h264 input video** \ No newline at end of file diff --git a/examples/custom-stream-copy-codec/package.json b/examples/custom-stream-copy-codec/package.json deleted file mode 100644 index b7df486..0000000 --- a/examples/custom-stream-copy-codec/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@dank074/discord-video-stream-example-custom", - "version": "1.0.0", - "description": "", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "type": "module", - "dependencies": { - "@dank074/discord-video-stream": "4.0.0", - "@discordjs/opus": "^0.9.0", - "discord.js-selfbot-v13": "^3.1.4", - "@dank074/fluent-ffmpeg-multistream-ts": "^1.0.2", - "fluent-ffmpeg": "^2.1.2", - "prism-media": "^1.3.5" - }, - "devDependencies": { - "@types/node": "^18.14.1", - "@types/fluent-ffmpeg": "^2.1.21", - "typescript": "^5.6.2" - }, - "scripts": { - "build": "tsc", - "start": "node ./dist/index.js", - "yeet": "npm run build && npm run start" - }, - "author": "", - "license": "ISC" -} \ No newline at end of file diff --git a/examples/custom-stream-copy-codec/src/config.json b/examples/custom-stream-copy-codec/src/config.json deleted file mode 100644 index 546b8d4..0000000 --- a/examples/custom-stream-copy-codec/src/config.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "token": "SELF TOKEN HERE", - "acceptedAuthors": ["USER_ID_HERE"], - "streamOpts": { - "bitrateKbps": 1000, - "maxBitrateKbps": 2500 - } -} \ No newline at end of file diff --git a/examples/custom-stream-copy-codec/src/customStream.ts b/examples/custom-stream-copy-codec/src/customStream.ts deleted file mode 100644 index d11f255..0000000 --- a/examples/custom-stream-copy-codec/src/customStream.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { - AudioStream, - H264NalSplitter, - MediaUdp, - VideoStream, -} from "@dank074/discord-video-stream"; -import { Readable } from "node:stream"; -import ffmpeg from "fluent-ffmpeg"; -import prism from "prism-media"; -import { StreamOutput } from "@dank074/fluent-ffmpeg-multistream-ts"; - -export let customFfmpegCommand: ffmpeg.FfmpegCommand; - -export function customStreamVideo( - input: string | Readable, - mediaUdp: MediaUdp, - includeAudio = true, -) { - return new Promise((resolve, reject) => { - const streamOpts = mediaUdp.mediaConnection.streamOptions; - - const videoStream: VideoStream = new VideoStream( - mediaUdp, - streamOpts.fps - ); - - const videoOutput = new H264NalSplitter(); - - const headers: map = { - "User-Agent": - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3", - Connection: "keep-alive", - }; - - let isHttpUrl = false; - let isHls = false; - - if (typeof input === "string") { - isHttpUrl = input.startsWith("http") || input.startsWith("https"); - isHls = input.includes("m3u"); - } - - try { - customFfmpegCommand = ffmpeg(input) - .addOption("-loglevel", "0") - .addOption("-fflags", "nobuffer") - .addOption("-analyzeduration", "0") - .on("end", () => { - customFfmpegCommand = undefined; - resolve("video ended"); - }) - .on("error", (err, stdout, stderr) => { - customFfmpegCommand = undefined; - reject("cannot play video " + err.message); - }) - .on("stderr", console.error); - - customFfmpegCommand - .output(StreamOutput(videoOutput).url, { end: false }) - .noAudio() - .videoCodec("copy") - .format("h264") - .outputOptions(["-bsf:v h264_metadata=aud=insert"]); - - videoOutput.pipe(videoStream, { end: false }); - - if (includeAudio) { - const audioStream: AudioStream = new AudioStream(mediaUdp); - - // make opus stream - const opus = new prism.opus.Encoder({ - channels: 2, - rate: 48000, - frameSize: 960, - }); - - customFfmpegCommand - .output(StreamOutput(opus).url, { end: false }) - .noVideo() - .audioChannels(2) - .audioFrequency(48000) - //.audioBitrate('128k') - .format("s16le"); - - opus.pipe(audioStream, { end: false }); - } - - if (streamOpts.hardwareAcceleratedDecoding) - customFfmpegCommand.inputOption("-hwaccel", "auto"); - - if (isHttpUrl) { - customFfmpegCommand.inputOption( - "-headers", - Object.keys(headers) - .map((key) => key + ": " + headers[key]) - .join("\r\n") - ); - if (!isHls) { - customFfmpegCommand.inputOptions([ - "-reconnect 1", - "-reconnect_at_eof 1", - "-reconnect_streamed 1", - "-reconnect_delay_max 4294", - ]); - } - } - - customFfmpegCommand.run(); - } catch (e) { - //audioStream.end(); - //videoStream.end(); - customFfmpegCommand = undefined; - reject("cannot play video " + e.message); - } - }); -} - -type map = { - [key: string]: string; -}; diff --git a/examples/custom-stream-copy-codec/src/index.ts b/examples/custom-stream-copy-codec/src/index.ts deleted file mode 100644 index 53b43d0..0000000 --- a/examples/custom-stream-copy-codec/src/index.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { MediaUdp, Streamer, getInputMetadata, inputHasAudio } from "@dank074/discord-video-stream"; -import config from "./config.json" with {type: "json"}; -import { Client, StageChannel } from "discord.js-selfbot-v13"; -import { customFfmpegCommand, customStreamVideo } from "./customStream.js"; - -const streamer = new Streamer(new Client()); - -// ready event -streamer.client.on("ready", () => { - console.log(`--- ${streamer.client.user.tag} is ready ---`); -}); - -// message event -streamer.client.on("messageCreate", async (msg) => { - if (msg.author.bot) return; - - if (!config.acceptedAuthors.includes(msg.author.id)) return; - - if (!msg.content) return; - - if (msg.content.startsWith(`$play-live`)) { - const args = msg.content.split(" "); - if (args.length < 2) return; - - const url = args[1]; - - if (!url) return; - - const channel = msg.author.voice.channel; - - if(!channel) return; - - console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`); - await streamer.joinVoice(msg.guildId, channel.id); - - if(channel instanceof StageChannel) - { - await streamer.client.user.voice.setSuppressed(false); - } - - const streamUdpConn = await streamer.createStream(); - - await playVideo(url, streamUdpConn); - - streamer.stopStream(); - return; - } else if (msg.content.startsWith("$disconnect")) { - customFfmpegCommand?.kill("SIGINT"); - - streamer.leaveVoice(); - } -}); - -// login -streamer.client.login(config.token); - -// custom code to make it copy the video stream. First we need to get the fps and resolution of existing stream -async function playVideo(video: string, udpConn: MediaUdp) { - let includeAudio = true; - - try { - const metadata = await getInputMetadata(video); - console.log(metadata) - const videoStream = metadata.streams.find( (value) => value.codec_type === 'video' && value.codec_name === "h264" && value.pix_fmt === 'yuv420p') - - if(!videoStream) { - console.log("Unable to copy the codec: No suitable stream found") - return; - } - console.log('copying h264 video directly to output') - const fps = parseInt(videoStream.avg_frame_rate.split('/')[0])/parseInt(videoStream.avg_frame_rate.split('/')[1]) - const width = videoStream.width - const height = videoStream.height - console.log({fps, width, height, "profile": videoStream.profile}) - udpConn.mediaConnection.streamOptions = { fps, width, height } - includeAudio = inputHasAudio(metadata); - } catch(e) { - console.log(e); - return; - } - - console.log("Started playing video"); - - udpConn.mediaConnection.setSpeaking(true); - udpConn.mediaConnection.setVideoStatus(true); - try { - const res = await customStreamVideo(video, udpConn, includeAudio); - - console.log("Finished playing video " + res); - } catch (e) { - console.log(e); - } finally { - udpConn.mediaConnection.setSpeaking(false); - udpConn.mediaConnection.setVideoStatus(false); - } - customFfmpegCommand?.kill("SIGINT"); -} - diff --git a/examples/custom-stream-copy-codec/tsconfig.json b/examples/custom-stream-copy-codec/tsconfig.json deleted file mode 100644 index 3d5629f..0000000 --- a/examples/custom-stream-copy-codec/tsconfig.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "ESNext", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "NodeNext", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - "resolveJsonModule": true, /* Enable importing .json files. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - "outDir": "./dist", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": false, /* Enable all strict type-checking options. */ - "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - }, - "include": [ - "src/**/*" - ] -} \ No newline at end of file diff --git a/examples/puppeteer-stream/package.json b/examples/puppeteer-stream/package.json index 7135f40..9ab397d 100644 --- a/examples/puppeteer-stream/package.json +++ b/examples/puppeteer-stream/package.json @@ -4,17 +4,16 @@ "description": "", "main": "dist/index.js", "types": "dist/index.d.ts", - "type":"module", + "type": "module", "dependencies": { - "@dank074/discord-video-stream": "4.0.0", - "@discordjs/opus": "^0.9.0", - "discord.js-selfbot-v13": "^3.1.4", - "puppeteer": "^23.5.3", + "@dank074/discord-video-stream": "https://pkg.pr.new/Discord-RE/Discord-video-stream/@dank074/discord-video-stream@150", + "discord.js-selfbot-v13": "^3.5.1", + "puppeteer": "^24.1.1", "puppeteer-stream": "^3.0.19" }, "devDependencies": { - "@types/node": "^18.19.8", - "typescript": "^5.6.2" + "@types/node": "^22.13.1", + "typescript": "^5.7.3" }, "scripts": { "build": "tsc", diff --git a/examples/puppeteer-stream/src/index.ts b/examples/puppeteer-stream/src/index.ts index 8e145ca..4c16c47 100644 --- a/examples/puppeteer-stream/src/index.ts +++ b/examples/puppeteer-stream/src/index.ts @@ -1,13 +1,16 @@ -import { MediaUdp, Streamer, streamLivestreamVideo, Utils } from '@dank074/discord-video-stream'; import { Client, StageChannel } from 'discord.js-selfbot-v13'; +import { Streamer, Utils, prepareStream, playStream } from "@dank074/discord-video-stream"; import { executablePath } from 'puppeteer'; import { launch, getStream } from 'puppeteer-stream'; import config from "./config.json" with {type: "json"}; -import { Readable } from 'node:stream'; -import PCancelable from "p-cancelable"; + +type BrowserOptions = { + width: number, + height: number +} const streamer = new Streamer(new Client()); -let command: PCancelable; +let browser: Awaited>; // ready event streamer.client.on("ready", () => { @@ -22,7 +25,7 @@ streamer.client.on("messageCreate", async (msg) => { if (!msg.content) return; - if(msg.content.startsWith("$play-screen")) { + if (msg.content.startsWith("$play-screen")) { const args = msg.content.split(" "); if (args.length < 2) return; @@ -32,34 +35,23 @@ streamer.client.on("messageCreate", async (msg) => { const channel = msg.author.voice.channel; - if(!channel) return; + if (!channel) return; console.log(`Attempting to join voice channel ${msg.guildId}/${channel.id}`); await streamer.joinVoice(msg.guildId, channel.id); - if(channel instanceof StageChannel) + if (channel instanceof StageChannel) { await streamer.client.user.voice.setSuppressed(false); } - - const streamUdpConn = await streamer.createStream({ - width: config.streamOpts.width, - height: config.streamOpts.height, - fps: config.streamOpts.fps, - bitrateKbps: config.streamOpts.bitrateKbps, - maxBitrateKbps: config.streamOpts.maxBitrateKbps, - hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, - videoCodec: "VP8" // puppeteer only supports this video codec - }); - - await streamPuppeteer(url, streamUdpConn); - - streamer.stopStream(); + await streamPuppeteer(url, streamer, { + width: config.streamOpts.width, + height: config.streamOpts.height + }); return; } else if (msg.content.startsWith("$disconnect")) { - command?.cancel(); - + browser?.close(); streamer.leaveVoice(); } }) @@ -67,13 +59,11 @@ streamer.client.on("messageCreate", async (msg) => { // login streamer.client.login(config.token); -async function streamPuppeteer(url: string, udpConn: MediaUdp) { - const streamOpts = udpConn.mediaConnection.streamOptions; - - const browser = await launch({ +async function streamPuppeteer(url: string, streamer: Streamer, opts: BrowserOptions) { + browser = await launch({ defaultViewport: { - width: streamOpts.width, - height: streamOpts.height, + width: opts.width, + height: opts.height, }, executablePath: executablePath() }); @@ -81,26 +71,27 @@ async function streamPuppeteer(url: string, udpConn: MediaUdp) { const page = await browser.newPage(); await page.goto(url); - // node typings are fucked, not sure why - const stream: any = await getStream(page, { audio: true, video: true, mimeType: "video/webm;codecs=vp8,opus" }); + const stream = await getStream(page, { audio: true, video: true, mimeType: "video/webm;codecs=vp8,opus" }); - udpConn.mediaConnection.setSpeaking(true); - udpConn.mediaConnection.setVideoStatus(true); try { - // is there a way to distinguish audio from video chunks so we dont have to use ffmpeg ??? - command = streamLivestreamVideo((stream as Readable), udpConn); - - const res = await command; - console.log("Finished playing video " + res); + const { command, output } = prepareStream(stream, { + frameRate: config.streamOpts.fps, + bitrateVideo: config.streamOpts.bitrateKbps, + bitrateVideoMax: config.streamOpts.maxBitrateKbps, + hardwareAcceleratedDecoding: config.streamOpts.hardware_acceleration, + videoCodec: Utils.normalizeVideoCodec(config.streamOpts.videoCodec) + }) + command.on("error", (err, stdout, stderr) => { + console.log("An error occurred with ffmpeg"); + console.log(err) + }); + + await playStream(output, streamer, { + // Use this to catch up with ffmpeg + readrateInitialBurst: 10 + }); + console.log("Finished playing video"); } catch (e) { - if (command.isCanceled) { - // Handle the cancelation here - console.log('Operation was canceled'); - } else { - console.log(e); - } - } finally { - udpConn.mediaConnection.setSpeaking(false); - udpConn.mediaConnection.setVideoStatus(false); + console.log(e); } } \ No newline at end of file diff --git a/src/client/Streamer.ts b/src/client/Streamer.ts index 4d92c72..3e27e09 100644 --- a/src/client/Streamer.ts +++ b/src/client/Streamer.ts @@ -5,20 +5,36 @@ import { GatewayOpCodes } from "./GatewayOpCodes.js"; import type TypedEmitter from "typed-emitter"; import type { Client } from 'discord.js-selfbot-v13'; import type { MediaUdp } from "./voice/MediaUdp.js"; -import type { StreamOptions } from "./voice/index.js"; import type { GatewayEvent } from "./GatewayEvents.js"; type EmitterEvents = { [K in GatewayEvent["t"]]: (data: Extract["d"]) => void } +export type StreamerOptions = { + /** + * Force the use of ChaCha20 encryption. Faster on CPUs without AES-NI + */ + forceChacha20Encryption: boolean; + /** + * Enable RTCP Sender Report for synchronization + */ + rtcpSenderReportEnabled: boolean +} + export class Streamer { private _voiceConnection?: VoiceConnection; private _client: Client; + private _opts: StreamerOptions; private _gatewayEmitter = new EventEmitter() as TypedEmitter.default - constructor(client: Client) { + constructor(client: Client, opts?: Partial) { this._client = client; + this._opts = { + forceChacha20Encryption: false, + rtcpSenderReportEnabled: true, + ...opts + }; //listen for messages this.client.on('raw', (packet: GatewayEvent) => { @@ -31,6 +47,10 @@ export class Streamer { return this._client; } + public get opts(): StreamerOptions { + return this._opts; + } + public get voiceConnection(): VoiceConnection | undefined { return this._voiceConnection; } @@ -43,7 +63,7 @@ export class Streamer { }); } - public joinVoice(guild_id: string, channel_id: string, options?: Partial): Promise { + public joinVoice(guild_id: string, channel_id: string): Promise { return new Promise((resolve, reject) => { if (!this.client.user) { reject("Client not logged in"); @@ -51,12 +71,12 @@ export class Streamer { } const user_id = this.client.user.id; const voiceConn = new VoiceConnection( + this, guild_id, user_id, channel_id, - options ?? {}, (udp) => { - udp.mediaConnection.setProtocols().then(() => resolve(udp)) + resolve(udp) } ); this._voiceConnection = voiceConn; @@ -72,7 +92,7 @@ export class Streamer { }); } - public createStream(options?: Partial): Promise { + public createStream(): Promise { return new Promise((resolve, reject) => { if (!this.client.user) { reject("Client not logged in"); @@ -96,12 +116,12 @@ export class Streamer { if (!session_id) throw new Error("Session doesn't exist yet"); const streamConn = new StreamConnection( + this, clientGuildId, clientUserId, clientChannelId, - options ?? {}, (udp) => { - udp.mediaConnection.setProtocols().then(() => resolve(udp)) + resolve(udp) } ); this.voiceConnection.streamConnection = streamConn; diff --git a/src/client/encryptor/TransportEncryptor.ts b/src/client/encryptor/TransportEncryptor.ts index 1c86218..46fd920 100644 --- a/src/client/encryptor/TransportEncryptor.ts +++ b/src/client/encryptor/TransportEncryptor.ts @@ -1,5 +1,4 @@ import sp from "sodium-plus"; -import { webcrypto } from "node:crypto"; import { max_int32bit } from "../../utils.js"; const { SodiumPlus } = sp; @@ -10,10 +9,10 @@ export interface TransportEncryptor { export class AES256TransportEncryptor implements TransportEncryptor { private _nonce = 0; - private _secretKey: Promise; + private _secretKey: Promise; constructor(secretKey: Buffer) { - this._secretKey = webcrypto.subtle.importKey("raw", + this._secretKey = crypto.subtle.importKey("raw", secretKey, { name: "AES-GCM", @@ -27,7 +26,7 @@ export class AES256TransportEncryptor implements TransportEncryptor nonceBuffer.writeUInt32BE(this._nonce); this._nonce = (this._nonce + 1) % max_int32bit; - const ciphertext = Buffer.from(await webcrypto.subtle.encrypt({ + const ciphertext = Buffer.from(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonceBuffer, additionalData, diff --git a/src/client/packet/AudioPacketizer.ts b/src/client/packet/AudioPacketizer.ts index d4ae51e..f2cf2e2 100644 --- a/src/client/packet/AudioPacketizer.ts +++ b/src/client/packet/AudioPacketizer.ts @@ -3,9 +3,8 @@ import { BaseMediaPacketizer } from "./BaseMediaPacketizer.js"; import { CodecPayloadType } from "../voice/BaseMediaConnection.js"; export class AudioPacketizer extends BaseMediaPacketizer { - constructor(connection: MediaUdp) { - super(connection, CodecPayloadType.opus.payload_type); - this.srInterval = 5 * 1000 / 20; // ~5 seconds for 20ms frame time + constructor(connection: MediaUdp, ssrc: number) { + super(connection, ssrc, CodecPayloadType.opus.payload_type); } public override async sendFrame(frame: Buffer, frametime: number): Promise { diff --git a/src/client/packet/BaseMediaPacketizer.ts b/src/client/packet/BaseMediaPacketizer.ts index ed9f321..2d1c469 100644 --- a/src/client/packet/BaseMediaPacketizer.ts +++ b/src/client/packet/BaseMediaPacketizer.ts @@ -13,7 +13,7 @@ let sodium: Promise | undefined; export class BaseMediaPacketizer { private _loggerRtcpSr = new Log("packetizer:rtcp-sr"); - private _ssrc?: number; + private _ssrc: number; private _payloadType: number; private _mtu: number; private _sequence: number; @@ -21,26 +21,29 @@ export class BaseMediaPacketizer { private _totalBytes: number; private _totalPackets: number; - private _prevTotalPackets: number; private _lastPacketTime: number; + private _lastRtcpTime: number; + private _currentMediaTimestamp: number; private _srInterval: number; private _mediaUdp: MediaUdp; private _extensionEnabled: boolean; - constructor(connection: MediaUdp, payloadType: number, extensionEnabled = false) { + constructor(connection: MediaUdp, ssrc: number, payloadType: number, extensionEnabled = false) { this._mediaUdp = connection; this._payloadType = payloadType; + this._ssrc = ssrc; this._sequence = 0; this._timestamp = 0; this._totalBytes = 0; this._totalPackets = 0; - this._prevTotalPackets = 0; this._lastPacketTime = 0; + this._lastRtcpTime = 0; + this._currentMediaTimestamp = 0; this._mtu = 1200; this._extensionEnabled = extensionEnabled; - this._srInterval = 512; // Sane fallback value for interval + this._srInterval = 1000; } public get ssrc(): number | undefined @@ -51,12 +54,11 @@ export class BaseMediaPacketizer { public set ssrc(value: number) { this._ssrc = value; - this._totalBytes = this._totalPackets = this._prevTotalPackets = 0; + this._totalBytes = this._totalPackets = 0; } /** - * The interval (number of packets) between 2 consecutive RTCP Sender - * Report packets + * The interval between 2 consecutive RTCP Sender Report packets in ms */ public get srInterval(): number { @@ -74,27 +76,32 @@ export class BaseMediaPacketizer { } public async onFrameSent(packetsSent: number, bytesSent: number, frametime: number): Promise { - if(!this._mediaUdp.mediaConnection.streamOptions.rtcpSenderReportEnabled) return; - this._totalPackets = this._totalPackets + packetsSent; - this._totalBytes = (this._totalBytes + bytesSent) % max_int32bit; - - // Not using modulo here, since the number of packet sent might not be - // exactly a multiple of the interval - if (Math.floor(this._totalPackets / this._srInterval) - Math.floor(this._prevTotalPackets / this._srInterval) > 0) + if (this._mediaUdp.mediaConnection.streamer.opts.rtcpSenderReportEnabled) { - const senderReport = await this.makeRtcpSenderReport(); - this._mediaUdp.sendPacket(senderReport); - this._prevTotalPackets = this._totalPackets; - this._loggerRtcpSr.debug({ - stats: { - ssrc: this._ssrc, - timestamp: this._timestamp, - totalPackets: this._totalPackets, - totalBytes: this._totalBytes - } - }, `Sent RTCP sender report for SSRC ${this._ssrc}`); + this._totalPackets = this._totalPackets + packetsSent; + this._totalBytes = (this._totalBytes + bytesSent) % max_int32bit; + + /** + * Not using modulo here, since the timestamp might not be an exact + * multiple of the interval + */ + if (Math.floor(this._currentMediaTimestamp / this._srInterval) - Math.floor(this._lastRtcpTime / this._srInterval) > 0) + { + const senderReport = await this.makeRtcpSenderReport(); + this._mediaUdp.sendPacket(senderReport); + this._lastRtcpTime = this._currentMediaTimestamp; + this._loggerRtcpSr.debug({ + stats: { + ssrc: this._ssrc, + timestamp: this._timestamp, + totalPackets: this._totalPackets, + totalBytes: this._totalBytes + } + }, `Sent RTCP sender report for SSRC ${this._ssrc}`); + } } + this._currentMediaTimestamp += frametime; } /** @@ -128,9 +135,6 @@ export class BaseMediaPacketizer { } public makeRtpHeader(isLastPacket = true): Buffer { - if (!this._ssrc) - throw new Error("SSRC is not set"); - const packetHeader = Buffer.alloc(12); packetHeader[0] = 2 << 6 | ((this._extensionEnabled ? 1 : 0) << 4); // set version and flags @@ -145,9 +149,6 @@ export class BaseMediaPacketizer { } public async makeRtcpSenderReport(): Promise { - if (!this._ssrc) - throw new Error("SSRC is not set"); - const packetHeader = Buffer.allocUnsafe(8); packetHeader[0] = 0x80; // RFC1889 v2, no padding, no reception report count diff --git a/src/client/packet/VideoPacketizerAnnexB.ts b/src/client/packet/VideoPacketizerAnnexB.ts index 81285f8..ebee395 100644 --- a/src/client/packet/VideoPacketizerAnnexB.ts +++ b/src/client/packet/VideoPacketizerAnnexB.ts @@ -61,9 +61,8 @@ import { CodecPayloadType } from "../voice/BaseMediaConnection.js"; class VideoPacketizerAnnexB extends BaseMediaPacketizer { private _nalFunctions: AnnexBHelpers; - constructor(connection: MediaUdp, payloadType: number, nalFunctions: AnnexBHelpers) { - super(connection, payloadType, true); - this.srInterval = 5 * connection.mediaConnection.streamOptions.fps * 3; // ~5 seconds, assuming ~3 packets per frame + constructor(connection: MediaUdp, ssrc: number, payloadType: number, nalFunctions: AnnexBHelpers) { + super(connection, ssrc, payloadType, true); this._nalFunctions = nalFunctions; } @@ -154,8 +153,8 @@ class VideoPacketizerAnnexB extends BaseMediaPacketizer { } export class VideoPacketizerH264 extends VideoPacketizerAnnexB { - constructor(connection: MediaUdp) { - super(connection, CodecPayloadType.H264.payload_type, H264Helpers); + constructor(connection: MediaUdp, ssrc: number) { + super(connection, ssrc, CodecPayloadType.H264.payload_type, H264Helpers); } /** * The FU indicator octet has the following format: @@ -210,8 +209,8 @@ export class VideoPacketizerH264 extends VideoPacketizerAnnexB { } export class VideoPacketizerH265 extends VideoPacketizerAnnexB { - constructor(connection: MediaUdp) { - super(connection, CodecPayloadType.H265.payload_type, H265Helpers); + constructor(connection: MediaUdp, ssrc: number) { + super(connection, ssrc, CodecPayloadType.H265.payload_type, H265Helpers); } /** * The FU indicator octet has the following format: diff --git a/src/client/packet/VideoPacketizerVP8.ts b/src/client/packet/VideoPacketizerVP8.ts index 82f4e2b..60d0519 100644 --- a/src/client/packet/VideoPacketizerVP8.ts +++ b/src/client/packet/VideoPacketizerVP8.ts @@ -10,10 +10,9 @@ import { CodecPayloadType } from "../voice/BaseMediaConnection.js"; export class VideoPacketizerVP8 extends BaseMediaPacketizer { private _pictureId: number; - constructor(connection: MediaUdp) { - super(connection, CodecPayloadType.VP8.payload_type, true); + constructor(connection: MediaUdp, ssrc: number) { + super(connection, ssrc, CodecPayloadType.VP8.payload_type, true); this._pictureId = 0; - this.srInterval = 5 * connection.mediaConnection.streamOptions.fps * 3; // ~5 seconds, assuming ~3 packets per frame } private incrementPictureId(): void { diff --git a/src/client/voice/BaseMediaConnection.ts b/src/client/voice/BaseMediaConnection.ts index ece8183..93c982b 100644 --- a/src/client/voice/BaseMediaConnection.ts +++ b/src/client/voice/BaseMediaConnection.ts @@ -5,10 +5,11 @@ import { Chacha20TransportEncryptor, type TransportEncryptor } from "../encryptor/TransportEncryptor.js"; -import { STREAMS_SIMULCAST, SupportedEncryptionModes, type SupportedVideoCodec } from "../../utils.js"; +import { STREAMS_SIMULCAST, SupportedEncryptionModes } from "../../utils.js"; import WebSocket from 'ws'; import EventEmitter from "node:events"; import type { Message, GatewayRequest, GatewayResponse } from "./VoiceMessageTypes.js"; +import type { Streamer } from "../Streamer.js"; type VoiceConnectionStatus = { @@ -32,6 +33,12 @@ type ValueOf = T extends Record ? U : never +export type VideoAttributes = { + width: number, + height: number, + fps: number +} + export const CodecPayloadType = { "opus": { name: "opus", type: "audio", priority: 1000, payload_type: 120 @@ -53,68 +60,6 @@ export const CodecPayloadType = { } } as const; -export interface StreamOptions { - /** - * Video output width - */ - width: number; - /** - * Video output height - */ - height: number; - /** - * Video output frames per second - */ - fps: number; - /** - * Video output bitrate in kbps - */ - bitrateKbps: number; - maxBitrateKbps: number; - /** - * Enables hardware accelerated video decoding. Enabling this option might result in an exception - * being thrown by Ffmpeg process if your system does not support hardware acceleration - */ - hardwareAcceleratedDecoding: boolean; - /** - * Output video codec. **Only** supports H264, H265, and VP8 currently - */ - videoCodec: SupportedVideoCodec; - /** - * Enables sending RTCP sender reports. Helps the receiver synchronize the audio/video frames, except in some weird - * cases which is why you can disable it - */ - rtcpSenderReportEnabled: boolean; - /** - * Encoding preset for H264 or H265. The faster it is, the lower the quality - */ - h26xPreset: 'ultrafast' | 'superfast' | 'veryfast' | 'faster' | 'fast' | 'medium' | 'slow' | 'slower' | 'veryslow'; - /** - * Adds ffmpeg params to minimize latency and start outputting video as fast as possible. - * Might create lag in video output in some rare cases - */ - minimizeLatency: boolean; - - /** - * ChaCha20-Poly1305 Encryption is faster than AES-256-GCM, except when using AES-NI - */ - forceChacha20Encryption: boolean; -} - -const defaultStreamOptions: StreamOptions = { - width: 1080, - height: 720, - fps: 30, - bitrateKbps: 1000, - maxBitrateKbps: 2500, - hardwareAcceleratedDecoding: false, - videoCodec: 'H264', - rtcpSenderReportEnabled: true, - h26xPreset: 'ultrafast', - minimizeLatency: true, - forceChacha20Encryption: false, -} - export abstract class BaseMediaConnection extends EventEmitter { private interval: NodeJS.Timeout | null = null; public udp: MediaUdp; @@ -129,12 +74,19 @@ export abstract class BaseMediaConnection extends EventEmitter { public session_id: string | null = null; public webRtcParams: WebRtcParameters | null = null; - private _streamOptions: StreamOptions; + private _streamer: Streamer; private _transportEncryptor?: TransportEncryptor; private _sequenceNumber = -1; - constructor(guildId: string, botId: string, channelId: string, options: Partial, callback: (udp: MediaUdp) => void) { + constructor( + streamer: Streamer, + guildId: string, + botId: string, + channelId: string, + callback: (udp: MediaUdp) => void + ) { super(); + this._streamer = streamer; this.status = { hasSession: false, hasToken: false, @@ -142,8 +94,6 @@ export abstract class BaseMediaConnection extends EventEmitter { resuming: false } - this._streamOptions = { ...defaultStreamOptions, ...options } - // make udp client this.udp = new MediaUdp(this); @@ -155,18 +105,14 @@ export abstract class BaseMediaConnection extends EventEmitter { public abstract get serverId(): string | null; - public get streamOptions(): StreamOptions { - return this._streamOptions; - } - - public set streamOptions(options: Partial) { - this._streamOptions = { ...this._streamOptions, ...options } - } - public get transportEncryptor() { return this._transportEncryptor; } + public get streamer() { + return this._streamer; + } + stop(): void { this.interval && clearInterval(this.interval); this.status.started = false; @@ -241,7 +187,6 @@ export abstract class BaseMediaConnection extends EventEmitter { rtxSsrc: stream.rtx_ssrc, supportedEncryptionModes: d.modes } - this.udp.updatePacketizer(); } handleProtocolAck(d: Message.SelectProtocolAck): void { @@ -269,7 +214,7 @@ export abstract class BaseMediaConnection extends EventEmitter { if (op === VoiceOpCodes.READY) { // ready this.handleReady(d); this.sendVoice().then(() => this.ready(this.udp)); - this.setVideoStatus(false); + this.setVideoAttributes(false); } else if (op >= 4000) { console.error(`Error ${this.constructor.name} connection`, d); @@ -355,7 +300,7 @@ export abstract class BaseMediaConnection extends EventEmitter { ** Uses vp8 for video ** Uses opus for audio */ - setProtocols(): Promise { + private setProtocols(): Promise { const { ip, port } = this.udp; if (!ip || !port) throw new Error("IP or port is undefined (this shouldn't happen!!!)"); @@ -367,7 +312,7 @@ export abstract class BaseMediaConnection extends EventEmitter { throw new Error("WebRTC connection not ready"); if ( this.webRtcParams.supportedEncryptionModes.includes(SupportedEncryptionModes.AES256) && - !this.streamOptions.forceChacha20Encryption + !this._streamer.opts.forceChacha20Encryption ) { encryptionMode = SupportedEncryptionModes.AES256 } else { @@ -388,36 +333,51 @@ export abstract class BaseMediaConnection extends EventEmitter { } /* - ** Sets video status. - ** bool -> video on or off - ** video and rtx sources are set to ssrc + 1 and ssrc + 2 - */ - public setVideoStatus(bool: boolean): void { + * Sets video attributes (width, height, frame rate). + * enabled -> video on or off + * attr -> video attributes + * video and rtx sources are set to ssrc + 1 and ssrc + 2 + */ + public setVideoAttributes(enabled: false): void + public setVideoAttributes(enabled: true, attr: VideoAttributes): void + public setVideoAttributes(enabled: boolean, attr?: VideoAttributes): void { if (!this.webRtcParams) throw new Error("WebRTC connection not ready"); const { audioSsrc, videoSsrc, rtxSsrc } = this.webRtcParams; - this.sendOpcode(VoiceOpCodes.VIDEO, { - audio_ssrc: audioSsrc, - video_ssrc: bool ? videoSsrc : 0, - rtx_ssrc: bool ? rtxSsrc : 0, - streams: [ - { - type:"video", - rid:"100", - ssrc: bool ? videoSsrc : 0, - active:true, - quality:100, - rtx_ssrc:bool ? rtxSsrc : 0, - max_bitrate: this.streamOptions.maxBitrateKbps * 1000, - max_framerate: this.streamOptions.fps, - max_resolution: { - type:"fixed", - width: this.streamOptions.width, - height: this.streamOptions.height + if (!enabled) { + this.sendOpcode(VoiceOpCodes.VIDEO, { + audio_ssrc: audioSsrc, + video_ssrc: 0, + rtx_ssrc: 0, + streams: [] + }) + } else { + if (!attr) + throw new Error("Need to specify video attributes") + this.sendOpcode(VoiceOpCodes.VIDEO, { + audio_ssrc: audioSsrc, + video_ssrc: videoSsrc, + rtx_ssrc: rtxSsrc, + streams: [ + { + type:"video", + rid:"100", + ssrc: videoSsrc, + active: true, + quality: 100, + rtx_ssrc: rtxSsrc, + // hardcode the max bitrate because we don't really know anyway + max_bitrate: 10000 * 1000, + max_framerate: enabled ? attr.fps : 0, + max_resolution: { + type: "fixed", + width: attr.width, + height: attr.height + } } - } - ] - }); + ] + }); + } } /* @@ -438,6 +398,6 @@ export abstract class BaseMediaConnection extends EventEmitter { ** Start media connection */ public sendVoice(): Promise { - return this.udp.createUdp(); + return this.udp.createUdp().then(() => this.setProtocols()); } } \ No newline at end of file diff --git a/src/client/voice/MediaUdp.ts b/src/client/voice/MediaUdp.ts index 3859d43..2fdf1f2 100644 --- a/src/client/voice/MediaUdp.ts +++ b/src/client/voice/MediaUdp.ts @@ -71,28 +71,25 @@ export class MediaUdp { await this.videoPacketizer?.sendFrame(frame, frametime); } - public updatePacketizer(): void { + public setPacketizer(videoCodec: string): void { if (!this.mediaConnection.webRtcParams) throw new Error("WebRTC connection not ready"); const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams; - this._audioPacketizer = new AudioPacketizer(this); - this._audioPacketizer.ssrc = audioSsrc; - const videoCodec = normalizeVideoCodec(this.mediaConnection.streamOptions.videoCodec); - switch (videoCodec) + this._audioPacketizer = new AudioPacketizer(this, audioSsrc); + switch (normalizeVideoCodec(videoCodec)) { case "H264": - this._videoPacketizer = new VideoPacketizerH264(this); + this._videoPacketizer = new VideoPacketizerH264(this, videoSsrc); break; case "H265": - this._videoPacketizer = new VideoPacketizerH265(this); + this._videoPacketizer = new VideoPacketizerH265(this, videoSsrc); break; case "VP8": - this._videoPacketizer = new VideoPacketizerVP8(this); + this._videoPacketizer = new VideoPacketizerVP8(this, videoSsrc); break; default: throw new Error(`Packetizer not implemented for ${videoCodec}`) } - this._videoPacketizer.ssrc = videoSsrc; } public sendPacket(packet: Buffer): Promise { diff --git a/src/index.ts b/src/index.ts index a35cfce..fc3c666 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from './client/index.js'; export * from './media/index.js'; +export * from './media/newApi.js'; export * as NewApi from './media/newApi.js'; export * as Utils from './utils.js'; diff --git a/src/media/index.ts b/src/media/index.ts index a7cce6b..a0c1363 100644 --- a/src/media/index.ts +++ b/src/media/index.ts @@ -1,4 +1 @@ -export * from './AudioStream.js'; -export * from './VideoStream.js'; -export * from './streamLivestreamVideo.js'; export * from './LibavDemuxer.js'; diff --git a/src/media/newApi.ts b/src/media/newApi.ts index 9912c45..736fee8 100644 --- a/src/media/newApi.ts +++ b/src/media/newApi.ts @@ -293,37 +293,31 @@ export type PlayStreamOptions = { /** * Override video width sent to Discord. + * * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! */ width: number, /** * Override video height sent to Discord. + * * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! */ height: number, /** * Override video frame rate sent to Discord. + * * DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING! */ frameRate: number, /** * Same as ffmpeg's `readrate_initial_burst` command line flag + * * See https://ffmpeg.org/ffmpeg.html#:~:text=%2Dreadrate_initial_burst */ readrateInitialBurst: number | undefined, - - /** - * Enable RTCP Sender Report for synchronization - */ - rtcpSenderReportEnabled: boolean, - - /** - * Force the use of ChaCha20 encryption. Faster on CPUs without AES-NI - */ - forceChacha20Encryption: boolean } export async function playStream( @@ -349,8 +343,6 @@ export async function playStream( height: video.height, frameRate: video.framerate_num / video.framerate_den, readrateInitialBurst: undefined, - rtcpSenderReportEnabled: true, - forceChacha20Encryption: false } satisfies PlayStreamOptions; function mergeOptions(opts: Partial) @@ -379,12 +371,6 @@ export async function playStream( isFiniteNonZero(opts.readrateInitialBurst) && opts.readrateInitialBurst > 0 ? opts.readrateInitialBurst : defaultOptions.readrateInitialBurst, - - rtcpSenderReportEnabled: - opts.rtcpSenderReportEnabled ?? defaultOptions.rtcpSenderReportEnabled, - - forceChacha20Encryption: - opts.forceChacha20Encryption ?? defaultOptions.forceChacha20Encryption } satisfies PlayStreamOptions } @@ -403,18 +389,13 @@ export async function playStream( streamer.signalVideo(true); stopStream = () => streamer.signalVideo(false); } - udp.mediaConnection.streamOptions = { + udp.setPacketizer(videoCodecMap[video.codec]); + udp.mediaConnection.setSpeaking(true); + udp.mediaConnection.setVideoAttributes(true, { width: mergedOptions.width, height: mergedOptions.height, - videoCodec: videoCodecMap[video.codec], - fps: mergedOptions.frameRate, - rtcpSenderReportEnabled: mergedOptions.rtcpSenderReportEnabled, - forceChacha20Encryption: mergedOptions.forceChacha20Encryption - } - await udp.mediaConnection.setProtocols(); - udp.updatePacketizer(); // TODO: put all packetizers here when we remove the old API - udp.mediaConnection.setSpeaking(true); - udp.mediaConnection.setVideoStatus(true); + fps: mergedOptions.frameRate + }); const vStream = new VideoStream(udp); video.stream.pipe(vStream); @@ -444,7 +425,7 @@ export async function playStream( vStream.once("finish", () => { stopStream(); udp.mediaConnection.setSpeaking(false); - udp.mediaConnection.setVideoStatus(false); + udp.mediaConnection.setVideoAttributes(false); resolve(); }); }); diff --git a/src/media/streamLivestreamVideo.ts b/src/media/streamLivestreamVideo.ts deleted file mode 100644 index e0a2dd4..0000000 --- a/src/media/streamLivestreamVideo.ts +++ /dev/null @@ -1,182 +0,0 @@ -import ffmpeg from 'fluent-ffmpeg'; -import PCancelable from 'p-cancelable'; -import { AudioStream } from "./AudioStream.js"; -import { type Readable, PassThrough } from 'node:stream'; -import { VideoStream } from './VideoStream.js'; -import { normalizeVideoCodec } from '../utils.js'; -import { demux } from './LibavDemuxer.js'; -import type { MediaUdp } from '../client/voice/MediaUdp.js'; - -/** - * @deprecated This API has a number of design issues that makes it error-prone - * and hard to customize. Please use the new API instead. - * - * See https://github.com/dank074/Discord-video-stream/pull/125 for information - * on the new API and example usage. - */ -export function streamLivestreamVideo( - input: string | Readable, - mediaUdp: MediaUdp, - includeAudio = true, - customHeaders?: Record -) { - return new PCancelable(async (resolve, reject, onCancel) => { - const streamOpts = mediaUdp.mediaConnection.streamOptions; - const videoCodec = normalizeVideoCodec(streamOpts.videoCodec); - - // ffmpeg setup - let headers: Record = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.3", - "Connection": "keep-alive" - } - - headers = { ...headers, ...(customHeaders ?? {}) }; - - let isHttpUrl = false; - let isHls = false; - - if (typeof input === "string") { - isHttpUrl = input.startsWith('http') || input.startsWith('https'); - isHls = input.includes('m3u'); - } - - const ffmpegOutput = new PassThrough(); - try { - // command creation - const command = ffmpeg(input) - .addOption('-loglevel', '0') - .on('end', () => { - resolve("video ended") - }) - .on("error", (err, stdout, stderr) => { - reject(`cannot play video ${err.message}`) - }) - .on('stderr', console.error); - - // general output options - command - .output(ffmpegOutput) - .size(`${streamOpts.width}x${streamOpts.height}`) - .fpsOutput(streamOpts.fps) - .videoBitrate(`${streamOpts.bitrateKbps}k`) - .outputFormat("matroska"); - - // video setup - command.outputOption('-bf', '0'); - switch (videoCodec) { - case 'AV1': - command - .videoCodec("libsvtav1") - break; - case 'VP8': - command - .videoCodec("libvpx") - .outputOption('-deadline', 'realtime'); - break; - case 'VP9': - command - .videoCodec("libvpx-vp9") - .outputOption('-deadline', 'realtime'); - break; - case 'H264': - command - .videoCodec("libx264") - .outputOptions([ - '-tune zerolatency', - '-pix_fmt yuv420p', - `-preset ${streamOpts.h26xPreset}`, - '-profile:v baseline', - `-g ${streamOpts.fps}`, - `-x264-params keyint=${streamOpts.fps}:min-keyint=${streamOpts.fps}`, - ]); - break; - case 'H265': - command - .videoCodec("libx265") - .outputOptions([ - '-tune zerolatency', - '-pix_fmt yuv420p', - `-preset ${streamOpts.h26xPreset}`, - '-profile:v main', - `-g ${streamOpts.fps}`, - `-x265-params keyint=${streamOpts.fps}:min-keyint=${streamOpts.fps}`, - ]); - break; - } - - // audio setup - command - .audioChannels(2) - .audioFrequency(48000) - .audioCodec("libopus") - //.audioBitrate('128k') - - if (streamOpts.hardwareAcceleratedDecoding) command.inputOption('-hwaccel', 'auto'); - - if (streamOpts.minimizeLatency) { - command.addOptions([ - '-fflags nobuffer', - '-analyzeduration 0' - ]) - } - - if (isHttpUrl) { - command.inputOption('-headers', - Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join("\r\n") - ); - if (!isHls) { - command.inputOptions([ - '-reconnect 1', - '-reconnect_at_eof 1', - '-reconnect_streamed 1', - '-reconnect_delay_max 4294' - ]); - } - } - - command.run(); - onCancel(() => command.kill("SIGINT")); - - // demuxing - const { video, audio } = await demux(ffmpegOutput).catch((e) => { - command.kill("SIGINT"); - throw e; - }); - if (!video) - throw new Error("No video stream"); - const videoStream = new VideoStream(mediaUdp); - video.stream.pipe(videoStream) - if (audio && includeAudio) { - const audioStream = new AudioStream(mediaUdp); - audio.stream.pipe(audioStream); - videoStream.syncStream = audioStream; - audioStream.syncStream = videoStream; - } - } catch (e) { - //audioStream.end(); - //videoStream.end(); - reject(`cannot play video ${(e as Error).message}`); - } - }) -} - -export function getInputMetadata(input: string | Readable): Promise { - return new Promise((resolve, reject) => { - const instance = ffmpeg(input).on('error', (err, stdout, stderr) => reject(err)); - - instance.ffprobe((err, metadata) => { - if (err) reject(err); - instance.removeAllListeners(); - resolve(metadata); - instance.kill('SIGINT'); - }); - }) -} - -export function inputHasAudio(metadata: ffmpeg.FfprobeData) { - return metadata.streams.some((value) => value.codec_type === 'audio'); -} - -export function inputHasVideo(metadata: ffmpeg.FfprobeData) { - return metadata.streams.some((value) => value.codec_type === 'video'); -}