refactor: vendor content as regular files (remove git submodules) so CI builds don't need GitHub auth
This commit is contained in:
Vendored
-1
Submodule vendor/discord-video-stream deleted from f2b18cec74
@@ -0,0 +1,18 @@
|
||||
name: Biome Lint & Format Check
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
with:
|
||||
version: 2.3.8
|
||||
- name: Run Biome
|
||||
run: biome ci .
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Publish pkg.pr.new
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Publish
|
||||
run: pnpx pkg-pr-new publish
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
package-lock.json
|
||||
#example/src/config.json
|
||||
examples/*/node_modules
|
||||
examples/*/dist
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
# Performance related tweaks
|
||||
|
||||
## `ultrafast` shouldn't be used for x264/5
|
||||
|
||||
In our testing, the `ultrafast` preset produces a lot of bitrate spikes, causing the stream to stutter. `superfast` and below seems to keep it under control pretty well. Do not use `ultrafast`. Previous versions of the library has `ultrafast` as the default, which has been changed after the testing.
|
||||
|
||||
## Transport encryption methods
|
||||
|
||||
> [!NOTE]
|
||||
> This is no longer accurate as of [#195](https://github.com/Discord-RE/Discord-video-stream/pull/195), which replaces the custom UDP connection with standard WebRTC. This is kept here for historical purposes only.
|
||||
|
||||
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.
|
||||
|
||||
<details>
|
||||
<summary>AES-256-GCM, with AES acceleration</summary>
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>AES-256-GCM, without AES acceleration</summary>
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Chacha20-Poly1305</summary>
|
||||
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
</details>
|
||||
+296
@@ -0,0 +1,296 @@
|
||||
# Discord self-bot video
|
||||
|
||||
[](https://pkg.pr.new/~/Discord-RE/Discord-video-stream)
|
||||
|
||||
Fork: [Discord-video-experiment](https://github.com/mrjvs/Discord-video-experiment)
|
||||
|
||||
> [!CAUTION]
|
||||
> Using any kind of automation programs on your account can result in your account getting permanently banned by Discord. Use at your own risk
|
||||
|
||||
## Features
|
||||
|
||||
- Playing video & audio in a voice channel (`Go Live`, or webcam video)
|
||||
|
||||
## Implementation
|
||||
|
||||
What I implemented and what I did not.
|
||||
|
||||
### Video codecs
|
||||
|
||||
- [ ] VP8 (once supported, removed for maintainability)
|
||||
- [ ] VP9
|
||||
- [X] H.264
|
||||
- [X] H.265
|
||||
- [ ] AV1
|
||||
|
||||
### Packet types
|
||||
|
||||
- [X] RTP (sending of realtime data)
|
||||
- [ ] RTX (retransmission)
|
||||
|
||||
### Connection types
|
||||
|
||||
- [X] Regular Voice Connection
|
||||
- [X] Go Live
|
||||
|
||||
### Encryption
|
||||
|
||||
- [X] Transport Encryption
|
||||
- [X] [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"
|
||||
"a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"
|
||||
"a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid"
|
||||
"a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay"
|
||||
"a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type"
|
||||
"a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing"
|
||||
"a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space"
|
||||
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id"
|
||||
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id"
|
||||
"a=extmap:13 urn:3gpp:video-orientation"
|
||||
"a=extmap:14 urn:ietf:params:rtp-hdrext:toffset"
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
For full functionality, this library requires an FFmpeg build with `libzmq` enabled. Here is our recommendation:
|
||||
|
||||
- Windows & Linux: [BtbN's FFmpeg Builds](https://github.com/BtbN/FFmpeg-Builds)
|
||||
- macOS (Intel): [evermeet.cx](https://evermeet.cx/ffmpeg/)
|
||||
- macOS (Apple Silicon): Install from Homebrew
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This library makes use of native dependencies (`node-av` and `node-datachannel`). If you use package managers that don't run install scripts by default (`pnpm`, `bun`, etc.), you'll need to allow running install scripts for `node-av` and `node-datachannel` for proper operation.
|
||||
|
||||
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';
|
||||
|
||||
const streamer = new Streamer(new Client());
|
||||
await streamer.client.login('TOKEN HERE');
|
||||
```
|
||||
|
||||
Make client join a voice channel
|
||||
|
||||
```typescript
|
||||
await streamer.joinVoice("GUILD ID HERE", "CHANNEL ID HERE");
|
||||
```
|
||||
|
||||
Start sending media
|
||||
|
||||
```typescript
|
||||
import { prepareStream, playStream, Utils, Encoders } from "@dank074/discord-video-stream"
|
||||
try {
|
||||
// NVENC is also available, change Encoders.software to Encoders.nvenc and
|
||||
// adapt the settings
|
||||
let encoder = Encoders.software({
|
||||
x264: {
|
||||
preset: "superfast"
|
||||
},
|
||||
x265: {
|
||||
preset: "superfast"
|
||||
}
|
||||
});
|
||||
const { command, output } = prepareStream("DIRECT VIDEO URL OR READABLE STREAM HERE", {
|
||||
encoder,
|
||||
|
||||
// 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 */),
|
||||
});
|
||||
command.on("error", (err, stdout, stderr) => {
|
||||
// Handle ffmpeg errors here
|
||||
});
|
||||
|
||||
await playStream(output, streamer, {
|
||||
type: "go-live" // use "camera" for camera stream
|
||||
});
|
||||
|
||||
console.log("Finished playing video");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
```
|
||||
|
||||
## Encoder options available
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* A function returning encoder settings for a specific avg and max bitrate
|
||||
* You can define your own, or use the pre-made functions in the library
|
||||
*/
|
||||
encoder: EncoderSettingsGetter;
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
width?: number;
|
||||
/**
|
||||
* Video output height
|
||||
*/
|
||||
height?: number;
|
||||
/**
|
||||
* Video output frames per second
|
||||
*/
|
||||
fps?: number;
|
||||
/**
|
||||
* Video average bitrate in kbps
|
||||
*/
|
||||
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
|
||||
*/
|
||||
hardwareAcceleratedDecoding?: boolean;
|
||||
/**
|
||||
* Output video codec. **Only** supports H264, H265, and VP8 currently
|
||||
*/
|
||||
videoCodec?: SupportedVideoCodec;
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* Custom headers for HTTP requests
|
||||
*/
|
||||
customHeaders?: Record<string, string>;
|
||||
/**
|
||||
* Custom input options to pass directly to ffmpeg
|
||||
* These will be added to the command *before* other options
|
||||
*/
|
||||
customInputOptions?: string[];
|
||||
/**
|
||||
* Custom ffmpeg flags/options to pass directly to ffmpeg
|
||||
* These will be added to the command *after* other options
|
||||
*/
|
||||
customFfmpegFlags?: string[];
|
||||
```
|
||||
|
||||
## `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,
|
||||
```
|
||||
|
||||
## 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"],
|
||||
```
|
||||
|
||||
1. Configure your `config.json` with your accepted authors ids, and your self token
|
||||
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:
|
||||
|
||||
for go-live
|
||||
|
||||
```
|
||||
$play-live <Direct video link>
|
||||
```
|
||||
|
||||
or for cam
|
||||
|
||||
```
|
||||
$play-cam <Direct video link>
|
||||
```
|
||||
|
||||
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 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.
|
||||
|
||||
- Does this library work with bot tokens?
|
||||
|
||||
No, Discord blocks video from bots which is why this library uses a selfbot library as peer dependency. You must use a user token
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["./src/**/*"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# basic 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
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"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": "^5.0.0",
|
||||
"discord.js-selfbot-v13": "^3.5.1"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"token": "SELF TOKEN HERE",
|
||||
"acceptedAuthors": ["USER_ID_HERE"],
|
||||
"streamOpts": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30,
|
||||
"bitrateKbps": 1000,
|
||||
"maxBitrateKbps": 2500,
|
||||
"hardware_acceleration": false,
|
||||
"videoCodec": "H264"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Client, StageChannel } from "discord.js-selfbot-v13";
|
||||
import { Streamer, Utils, prepareStream, playStream } from "@dank074/discord-video-stream";
|
||||
import config from "./config.json" with {type: "json"};
|
||||
|
||||
const streamer = new Streamer(new Client());
|
||||
|
||||
// ready event
|
||||
streamer.client.on("ready", () => {
|
||||
console.log(`--- ${streamer.client.user?.tag} is ready ---`);
|
||||
});
|
||||
|
||||
let controller: AbortController;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
|
||||
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)
|
||||
}, controller.signal);
|
||||
command.on("error", (err) => {
|
||||
console.log("An error happened with ffmpeg");
|
||||
console.log(err);
|
||||
});
|
||||
await playStream(output, streamer, undefined, controller.signal)
|
||||
.catch(() => controller.abort());
|
||||
} 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);
|
||||
}
|
||||
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
|
||||
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)
|
||||
}, controller.signal)
|
||||
command.on("error", (err) => {
|
||||
console.log("An error happened with ffmpeg");
|
||||
console.log(err);
|
||||
});
|
||||
await playStream(output, streamer, undefined, controller.signal)
|
||||
.catch(() => controller.abort());
|
||||
} else if (msg.content.startsWith("$disconnect")) {
|
||||
controller?.abort();
|
||||
streamer.leaveVoice();
|
||||
} else if(msg.content.startsWith("$stop-stream")) {
|
||||
controller?.abort();
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"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 '<reference>'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": true, /* 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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# puppeteer stream example
|
||||
|
||||
This example shows how to use puppeteer stream to stream a browser window
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@dank074/discord-video-stream-example-puppeteer",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@dank074/discord-video-stream": "^5.0.0",
|
||||
"discord.js-selfbot-v13": "^3.5.1",
|
||||
"puppeteer": "^24.1.1",
|
||||
"puppeteer-stream": "^3.0.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.1",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node ./dist/index.js",
|
||||
"yeet": "npm run build && npm run start"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"token": "SELF TOKEN HERE",
|
||||
"acceptedAuthors": ["USER_ID_HERE"],
|
||||
"streamOpts": {
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"fps": 30,
|
||||
"bitrateKbps": 1000,
|
||||
"maxBitrateKbps": 2500,
|
||||
"hardware_acceleration": false,
|
||||
"videoCodec": "H264"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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"};
|
||||
|
||||
type BrowserOptions = {
|
||||
width: number,
|
||||
height: number
|
||||
}
|
||||
|
||||
const streamer = new Streamer(new Client());
|
||||
let browser: Awaited<ReturnType<typeof launch>>;
|
||||
|
||||
// ready event
|
||||
streamer.client.on("ready", () => {
|
||||
console.log(`--- ${streamer.client.user?.tag} is ready ---`);
|
||||
});
|
||||
|
||||
let controller: AbortController;
|
||||
|
||||
// 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-screen")) {
|
||||
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);
|
||||
}
|
||||
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
await streamPuppeteer(url, streamer, {
|
||||
width: config.streamOpts.width,
|
||||
height: config.streamOpts.height
|
||||
}, controller.signal);
|
||||
} else if (msg.content.startsWith("$disconnect")) {
|
||||
controller?.abort();
|
||||
streamer.leaveVoice();
|
||||
}
|
||||
})
|
||||
|
||||
// login
|
||||
streamer.client.login(config.token);
|
||||
|
||||
async function streamPuppeteer(url: string, streamer: Streamer, opts: BrowserOptions, cancelSignal?: AbortSignal) {
|
||||
cancelSignal?.throwIfAborted();
|
||||
cancelSignal?.addEventListener("abort", () => {
|
||||
browser.close();
|
||||
}, { once: true });
|
||||
browser = await launch({
|
||||
defaultViewport: {
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
},
|
||||
executablePath: executablePath()
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url);
|
||||
|
||||
const stream = await getStream(page, { audio: true, video: true, mimeType: "video/webm;codecs=vp8,opus" });
|
||||
|
||||
try {
|
||||
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)
|
||||
}, cancelSignal);
|
||||
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
|
||||
}, cancelSignal);
|
||||
console.log("Finished playing video");
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"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 '<reference>'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": true, /* 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/**/*"
|
||||
]
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "@dank074/discord-video-stream",
|
||||
"version": "6.0.0",
|
||||
"description": "Experiment for making video streaming work for discord selfbots",
|
||||
"exports": "./dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lng2004/node-datachannel": "0.32.0-20260202",
|
||||
"@snazzah/davey": "^0.1.8",
|
||||
"debug-level": "^4.1.1",
|
||||
"fluent-ffmpeg-simplified": "^0.1.0",
|
||||
"node-av": "^5.2.2",
|
||||
"p-debounce": "^5.1.0",
|
||||
"sharp": "^0.34.5",
|
||||
"zeromq": "^6.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.8",
|
||||
"@types/fluent-ffmpeg": "^2.1.28",
|
||||
"@types/node": "^25.0.1",
|
||||
"discord.js-selfbot-v13": "workspace:*",
|
||||
"pkg-pr-new": "^0.0.62",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"discord.js-selfbot-v13": "^3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "biome lint --error-on-warnings .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
"check": "biome check .",
|
||||
"check:fix": "biome check --write ."
|
||||
},
|
||||
"keywords": [
|
||||
"discord",
|
||||
"video",
|
||||
"voice",
|
||||
"stream",
|
||||
"go-live"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/dank074/Discord-video-stream.git"
|
||||
},
|
||||
"contributors": [
|
||||
"Long Nguyen <nguyen.long.908132@gmail.com>",
|
||||
"s074 <torresefrain10@gmail.com>",
|
||||
"mrjvs <jellevs@gmail.com>",
|
||||
"Elysia <71698422+aiko-chan-ai@users.noreply.github.com>",
|
||||
"Fede14 <fede.ferri2001@gmail.com>",
|
||||
"Malthe Morsing Larsen <57196060+malthemorsing@users.noreply.github.com>"
|
||||
],
|
||||
"license": "ISC",
|
||||
"bugs": {
|
||||
"url": "https://github.com/dank074/Discord-video-stream/issues"
|
||||
},
|
||||
"homepage": "https://github.com/dank074/Discord-video-stream#readme"
|
||||
}
|
||||
+2689
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
type GatewayEventGeneric<Type extends string = string, Data = unknown> = {
|
||||
t: Type;
|
||||
d: Data;
|
||||
};
|
||||
|
||||
export namespace GatewayEvent {
|
||||
export type VoiceStateUpdate = GatewayEventGeneric<
|
||||
"VOICE_STATE_UPDATE",
|
||||
{
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
}
|
||||
>;
|
||||
export type VoiceServerUpdate = GatewayEventGeneric<
|
||||
"VOICE_SERVER_UPDATE",
|
||||
{
|
||||
guild_id: string;
|
||||
channel_id?: string;
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}
|
||||
>;
|
||||
export type StreamCreate = GatewayEventGeneric<
|
||||
"STREAM_CREATE",
|
||||
{
|
||||
stream_key: string;
|
||||
rtc_server_id: string;
|
||||
}
|
||||
>;
|
||||
export type StreamServerUpdate = GatewayEventGeneric<
|
||||
"STREAM_SERVER_UPDATE",
|
||||
{
|
||||
stream_key: string;
|
||||
endpoint: string;
|
||||
token: string;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
export type GatewayEvent =
|
||||
| GatewayEvent.VoiceStateUpdate
|
||||
| GatewayEvent.VoiceServerUpdate
|
||||
| GatewayEvent.StreamCreate
|
||||
| GatewayEvent.StreamServerUpdate;
|
||||
|
||||
export type GatewayEventMap = {
|
||||
[E in GatewayEvent as E["t"]]: [E["d"]];
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
export enum GatewayOpCodes {
|
||||
DISPATCH = 0,
|
||||
HEARTBEAT = 1,
|
||||
IDENTIFY = 2,
|
||||
PRESENCE_UPDATE = 3,
|
||||
VOICE_STATE_UPDATE = 4,
|
||||
VOICE_SERVER_PING = 5,
|
||||
RESUME = 6,
|
||||
RECONNECT = 7,
|
||||
REQUEST_GUILD_MEMBERS = 8,
|
||||
INVALID_SESSION = 9,
|
||||
HELLO = 10,
|
||||
HEARTBEAT_ACK = 11,
|
||||
CALL_CONNECT = 13,
|
||||
GUILD_SUBSCRIPTIONS = 14,
|
||||
LOBBY_CONNECT = 15,
|
||||
LOBBY_DISCONNECT = 16,
|
||||
LOBBY_VOICE_STATES_UPDATE = 17,
|
||||
STREAM_CREATE = 18,
|
||||
STREAM_DELETE = 19,
|
||||
STREAM_WATCH = 20,
|
||||
STREAM_PING = 21,
|
||||
STREAM_SET_PAUSED = 22,
|
||||
REQUEST_GUILD_APPLICATION_COMMANDS = 24,
|
||||
EMBEDDED_ACTIVITY_LAUNCH = 25,
|
||||
EMBEDDED_ACTIVITY_CLOSE = 26,
|
||||
EMBEDDED_ACTIVITY_UPDATE = 27,
|
||||
REQUEST_FORUM_UNREADS = 28,
|
||||
REMOTE_COMMAND = 29,
|
||||
GET_DELETED_ENTITY_IDS_NOT_MATCHING_HASH = 30,
|
||||
REQUEST_SOUNDBOARD_SOUNDS = 31,
|
||||
SPEED_TEST_CREATE = 32,
|
||||
SPEED_TEST_DELETE = 33,
|
||||
REQUEST_LAST_MESSAGES = 34,
|
||||
SEARCH_RECENT_MEMBERS = 35,
|
||||
REQUEST_CHANNEL_STATUSES = 36,
|
||||
GUILD_SUBSCRIPTIONS_BULK = 37,
|
||||
GUILD_CHANNELS_RESYNC = 38,
|
||||
REQUEST_CHANNEL_MEMBER_COUNT = 39,
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { VoiceConnection } from "./voice/VoiceConnection.js";
|
||||
import { StreamConnection } from "./voice/StreamConnection.js";
|
||||
import { GatewayOpCodes } from "./GatewayOpCodes.js";
|
||||
import type {
|
||||
Client,
|
||||
DMChannel,
|
||||
GroupDMChannel,
|
||||
VoiceBasedChannel,
|
||||
} from "discord.js-selfbot-v13";
|
||||
import type { GatewayEvent, GatewayEventMap } from "./GatewayEvents.js";
|
||||
import type { WebRtcConnWrapper } from "./voice/WebRtcWrapper.js";
|
||||
import { generateStreamKey, parseStreamKey } from "../utils.js";
|
||||
|
||||
export class Streamer {
|
||||
private _voiceConnection?: VoiceConnection;
|
||||
private _client: Client;
|
||||
private _gatewayEmitter = new EventEmitter<GatewayEventMap>();
|
||||
|
||||
constructor(client: Client) {
|
||||
this._client = client;
|
||||
|
||||
//listen for messages
|
||||
this.client.on("raw", (packet: GatewayEvent) => {
|
||||
// @ts-expect-error I don't know how to make this work with TypeScript, so whatever
|
||||
this._gatewayEmitter.emit(packet.t, packet.d);
|
||||
});
|
||||
}
|
||||
|
||||
public get client(): Client {
|
||||
return this._client;
|
||||
}
|
||||
|
||||
public get opts() {
|
||||
return {};
|
||||
}
|
||||
|
||||
public get voiceConnection(): VoiceConnection | undefined {
|
||||
return this._voiceConnection;
|
||||
}
|
||||
|
||||
public sendOpcode(code: number, data: unknown): void {
|
||||
this.client.ws.broadcast({
|
||||
op: code,
|
||||
d: data,
|
||||
});
|
||||
}
|
||||
|
||||
public joinVoiceChannel(
|
||||
channel: DMChannel | GroupDMChannel | VoiceBasedChannel,
|
||||
): Promise<WebRtcConnWrapper> {
|
||||
let guildId: string | null = null;
|
||||
|
||||
if (
|
||||
channel.type === "GUILD_STAGE_VOICE" ||
|
||||
channel.type === "GUILD_VOICE"
|
||||
) {
|
||||
guildId = channel.guildId;
|
||||
}
|
||||
|
||||
return this.joinVoice(guildId, channel.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins a voice channel and returns a WebRtcConnWrapper object.
|
||||
* @param guild_id the guild id of the voice channel. If null, it will join a DM voice channel.
|
||||
* @param channel_id the channel id of the voice channel
|
||||
* @returns the WebRtcConnWrapper object
|
||||
* @throws Error if the client is not logged in
|
||||
*/
|
||||
public joinVoice(
|
||||
guild_id: string | null,
|
||||
channel_id: string,
|
||||
): Promise<WebRtcConnWrapper> {
|
||||
return new Promise<WebRtcConnWrapper>((resolve, reject) => {
|
||||
if (!this.client.user) {
|
||||
reject("Client not logged in");
|
||||
return;
|
||||
}
|
||||
const user_id = this.client.user.id;
|
||||
const voiceConn = new VoiceConnection(
|
||||
this,
|
||||
guild_id,
|
||||
user_id,
|
||||
channel_id,
|
||||
(conn) => {
|
||||
resolve(conn);
|
||||
},
|
||||
);
|
||||
this._voiceConnection = voiceConn;
|
||||
this._gatewayEmitter.on("VOICE_STATE_UPDATE", (d) => {
|
||||
if (user_id !== d.user_id) return;
|
||||
voiceConn.setSession(d.session_id);
|
||||
});
|
||||
this._gatewayEmitter.on("VOICE_SERVER_UPDATE", (d) => {
|
||||
if (guild_id !== d.guild_id) return;
|
||||
|
||||
// channel_id is not set for guild voice calls
|
||||
if (d.channel_id && channel_id !== d.channel_id) return;
|
||||
|
||||
voiceConn.setTokens(d.endpoint, d.token);
|
||||
});
|
||||
this.signalVideo(false);
|
||||
});
|
||||
}
|
||||
|
||||
public createStream(): Promise<WebRtcConnWrapper> {
|
||||
return new Promise<WebRtcConnWrapper>((resolve, reject) => {
|
||||
if (!this.client.user) {
|
||||
reject("Client not logged in");
|
||||
return;
|
||||
}
|
||||
if (!this.voiceConnection) {
|
||||
reject("cannot start stream without first joining voice channel");
|
||||
return;
|
||||
}
|
||||
|
||||
this.signalStream();
|
||||
const {
|
||||
guildId: clientGuildId,
|
||||
channelId: clientChannelId,
|
||||
session_id,
|
||||
} = this.voiceConnection;
|
||||
const { id: clientUserId } = this.client.user;
|
||||
|
||||
if (!session_id) throw new Error("Session doesn't exist yet");
|
||||
const streamConn = new StreamConnection(
|
||||
this,
|
||||
clientGuildId,
|
||||
clientUserId,
|
||||
clientChannelId,
|
||||
(conn) => {
|
||||
resolve(conn);
|
||||
},
|
||||
);
|
||||
this.voiceConnection.streamConnection = streamConn;
|
||||
this._gatewayEmitter.on("STREAM_CREATE", (d) => {
|
||||
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
||||
|
||||
if (
|
||||
clientGuildId !== guildId ||
|
||||
clientChannelId !== channelId ||
|
||||
clientUserId !== userId
|
||||
)
|
||||
return;
|
||||
|
||||
streamConn.serverId = d.rtc_server_id;
|
||||
streamConn.streamKey = d.stream_key;
|
||||
streamConn.setSession(session_id);
|
||||
});
|
||||
this._gatewayEmitter.on("STREAM_SERVER_UPDATE", (d) => {
|
||||
const { channelId, guildId, userId } = parseStreamKey(d.stream_key);
|
||||
|
||||
if (
|
||||
clientGuildId !== guildId ||
|
||||
clientChannelId !== channelId ||
|
||||
clientUserId !== userId
|
||||
)
|
||||
return;
|
||||
|
||||
streamConn.setTokens(d.endpoint, d.token);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public async setStreamPreview(image: Buffer): Promise<void> {
|
||||
if (!this.client.token) throw new Error("Please login :)");
|
||||
if (!this.voiceConnection?.streamConnection?.guildId) return;
|
||||
const data = `data:image/jpeg;base64,${image.toString("base64")}`;
|
||||
const { guildId } = this.voiceConnection.streamConnection;
|
||||
const server = await this.client.guilds.fetch(guildId);
|
||||
await server.members.me?.voice.postPreview(data);
|
||||
}
|
||||
|
||||
public stopStream(): void {
|
||||
const stream = this.voiceConnection?.streamConnection;
|
||||
|
||||
if (!stream) return;
|
||||
|
||||
stream.stop();
|
||||
|
||||
this.signalStopStream();
|
||||
|
||||
this.voiceConnection.streamConnection = undefined;
|
||||
this._gatewayEmitter.removeAllListeners("STREAM_CREATE");
|
||||
this._gatewayEmitter.removeAllListeners("STREAM_SERVER_UPDATE");
|
||||
}
|
||||
|
||||
public leaveVoice(): void {
|
||||
this.voiceConnection?.stop();
|
||||
|
||||
this.signalLeaveVoice();
|
||||
|
||||
this._voiceConnection = undefined;
|
||||
this._gatewayEmitter.removeAllListeners("VOICE_STATE_UPDATE");
|
||||
this._gatewayEmitter.removeAllListeners("VOICE_SERVER_UPDATE");
|
||||
}
|
||||
|
||||
public signalVideo(video_enabled: boolean): void {
|
||||
if (!this.voiceConnection) return;
|
||||
const { guildId: guild_id, channelId: channel_id } = this.voiceConnection;
|
||||
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
||||
guild_id: guild_id,
|
||||
channel_id,
|
||||
self_mute: false,
|
||||
self_deaf: true,
|
||||
self_video: video_enabled,
|
||||
});
|
||||
}
|
||||
|
||||
public signalStream(): void {
|
||||
if (!this.voiceConnection) return;
|
||||
const {
|
||||
type,
|
||||
guildId: guild_id,
|
||||
channelId: channel_id,
|
||||
botId: user_id,
|
||||
} = this.voiceConnection;
|
||||
|
||||
const streamKey = generateStreamKey(type, guild_id, channel_id, user_id);
|
||||
|
||||
this.sendOpcode(GatewayOpCodes.STREAM_CREATE, {
|
||||
type,
|
||||
guild_id,
|
||||
channel_id,
|
||||
preferred_region: null,
|
||||
});
|
||||
|
||||
this.sendOpcode(GatewayOpCodes.STREAM_SET_PAUSED, {
|
||||
stream_key: streamKey,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
|
||||
public signalStopStream(): void {
|
||||
if (!this.voiceConnection) return;
|
||||
const {
|
||||
type,
|
||||
guildId: guild_id,
|
||||
channelId: channel_id,
|
||||
botId: user_id,
|
||||
} = this.voiceConnection;
|
||||
|
||||
const streamKey = generateStreamKey(type, guild_id, channel_id, user_id);
|
||||
this.sendOpcode(GatewayOpCodes.STREAM_DELETE, {
|
||||
stream_key: streamKey,
|
||||
});
|
||||
}
|
||||
|
||||
public signalLeaveVoice(): void {
|
||||
this.sendOpcode(GatewayOpCodes.VOICE_STATE_UPDATE, {
|
||||
guild_id: null,
|
||||
channel_id: null,
|
||||
self_mute: true,
|
||||
self_deaf: false,
|
||||
self_video: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./voice/index.js";
|
||||
export * from "./GatewayOpCodes.js";
|
||||
export * from "./Streamer.js";
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
export class AnnexBBitstreamReader {
|
||||
private _buffer: Buffer;
|
||||
private _byteOffset = 0;
|
||||
private _bitOffset = 0;
|
||||
|
||||
constructor(buffer: Buffer) {
|
||||
this._buffer = buffer;
|
||||
}
|
||||
public readBits(count: number) {
|
||||
if (count === 0) return 0;
|
||||
let result = 0;
|
||||
while (count > 0) {
|
||||
if (this._byteOffset >= this._buffer.length)
|
||||
throw new Error("Bad byte offset");
|
||||
if (
|
||||
this._bitOffset === 0 &&
|
||||
this._byteOffset >= 2 &&
|
||||
this._buffer[this._byteOffset - 2] === 0 &&
|
||||
this._buffer[this._byteOffset - 1] === 0 &&
|
||||
this._buffer[this._byteOffset] === 3
|
||||
) {
|
||||
// Skip over emulation prevention
|
||||
this._byteOffset++;
|
||||
}
|
||||
if (this._bitOffset === 0 && count >= 8) {
|
||||
// We're byte aligned, read whole bytes and push in
|
||||
result = (result << 8) | this._buffer[this._byteOffset++];
|
||||
count -= 8;
|
||||
} else {
|
||||
// Read just enough to get us to the next byte
|
||||
const numBitsToRead = Math.min(count, 8 - this._bitOffset);
|
||||
const mask = (1 << numBitsToRead) - 1;
|
||||
const newBits =
|
||||
(this._buffer[this._byteOffset] >>
|
||||
(8 - this._bitOffset - numBitsToRead)) &
|
||||
mask;
|
||||
result = (result << numBitsToRead) | newBits;
|
||||
count -= numBitsToRead;
|
||||
this._bitOffset += numBitsToRead;
|
||||
if (this._bitOffset === 8) {
|
||||
this._bitOffset = 0;
|
||||
this._byteOffset++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
public readUnsigned(bits: number) {
|
||||
return this.readBits(bits);
|
||||
}
|
||||
public readSigned(bits: number) {
|
||||
const unsigned = this.readUnsigned(bits);
|
||||
if (unsigned & (1 << (bits - 1))) return unsigned - (1 << bits);
|
||||
return unsigned;
|
||||
}
|
||||
public readUnsignedExpGolomb() {
|
||||
let leading0 = 0;
|
||||
while (this.readBits(1) === 0) leading0++;
|
||||
|
||||
return (1 << leading0) + this.readBits(leading0) - 1;
|
||||
}
|
||||
public readSignedExpGolomb() {
|
||||
// Mapping: x <= 0 => -2x, x > 0 => 2x - 1
|
||||
const unsigned = this.readUnsignedExpGolomb();
|
||||
if (unsigned % 2 === 0) return unsigned / -2;
|
||||
return (unsigned + 1) / 2;
|
||||
}
|
||||
}
|
||||
|
||||
export class AnnexBBitstreamWriter {
|
||||
private _arr: number[] = [];
|
||||
private _pendingByte = 0;
|
||||
private _bitOffset = 0;
|
||||
|
||||
public toBuffer() {
|
||||
return Buffer.from(this._arr);
|
||||
}
|
||||
public flush() {
|
||||
// Write the pending byte into the array and reset, taking care of emulation prevention
|
||||
if (
|
||||
this._pendingByte <= 3 &&
|
||||
this._arr.at(-1) === 0 &&
|
||||
this._arr.at(-2) === 0
|
||||
)
|
||||
this._arr.push(3);
|
||||
this._arr.push(this._pendingByte);
|
||||
this._pendingByte = 0;
|
||||
this._bitOffset = 0;
|
||||
}
|
||||
public writeBits(bits: number, count: number) {
|
||||
while (count > 0) {
|
||||
if (this._bitOffset === 0) {
|
||||
if (count >= 8) {
|
||||
// We're byte aligned and has more than 1 byte left to write, write a whole byte
|
||||
this._pendingByte = (bits >> (count - 8)) & 0xff;
|
||||
count -= 8;
|
||||
this.flush();
|
||||
} else {
|
||||
// We have less than 1 byte, write the rest in
|
||||
const mask = (1 << count) - 1;
|
||||
this._pendingByte |= (bits & mask) << (8 - count);
|
||||
this._bitOffset = count;
|
||||
count = 0;
|
||||
}
|
||||
} else {
|
||||
// Write the minimum number of bits to get us byte aligned again
|
||||
const numBitsToWrite = Math.min(8 - this._bitOffset, count);
|
||||
const bitsToWrite =
|
||||
(bits >> (count - numBitsToWrite)) & ((1 << numBitsToWrite) - 1);
|
||||
this._pendingByte |=
|
||||
bitsToWrite << (8 - this._bitOffset - numBitsToWrite);
|
||||
count -= numBitsToWrite;
|
||||
this._bitOffset += numBitsToWrite;
|
||||
if (this._bitOffset === 8) {
|
||||
this._bitOffset = 0;
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public writeUnsigned(num: number, count: number) {
|
||||
if (num < 0) throw new Error("Expected a non-negative number");
|
||||
this.writeBits(num, count);
|
||||
}
|
||||
public writeSigned(num: number, count: number) {
|
||||
if (count <= 0) return;
|
||||
if (count > 32) throw new Error("writeSigned supports up to 32 bits");
|
||||
|
||||
// Build mask for `count` bits. Handle 32-bit as a special case.
|
||||
const mask =
|
||||
count === 32 ? 0xffffffff >>> 0 : (((1 << count) >>> 0) - 1) >>> 0;
|
||||
|
||||
// Convert to two's-complement unsigned representation and write
|
||||
const unsigned = (num & mask) >>> 0;
|
||||
this.writeBits(unsigned, count);
|
||||
}
|
||||
public writeUnsignedExpGolomb(num: number) {
|
||||
if (num < 0) throw new Error("Expected a non-negative number");
|
||||
num++;
|
||||
const bitCount = 32 - Math.clz32(num >>> 0);
|
||||
this.writeBits(0, bitCount - 1);
|
||||
this.writeBits(num, bitCount);
|
||||
}
|
||||
public writeSignedExpGolomb(num: number) {
|
||||
if (num < 0) this.writeUnsignedExpGolomb(-2 * num);
|
||||
else this.writeUnsignedExpGolomb(2 * num - 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export enum H264NalUnitTypes {
|
||||
Unspecified = 0,
|
||||
CodedSliceNonIDR = 1,
|
||||
CodedSlicePartitionA = 2,
|
||||
CodedSlicePartitionB = 3,
|
||||
CodedSlicePartitionC = 4,
|
||||
CodedSliceIdr = 5,
|
||||
SEI = 6,
|
||||
SPS = 7,
|
||||
PPS = 8,
|
||||
AccessUnitDelimiter = 9,
|
||||
EndOfSequence = 10,
|
||||
EndOfStream = 11,
|
||||
FillerData = 12,
|
||||
SEIExtenstion = 13,
|
||||
PrefixNalUnit = 14,
|
||||
SubsetSPS = 15,
|
||||
}
|
||||
|
||||
export enum H265NalUnitTypes {
|
||||
TRAIL_N = 0,
|
||||
TRAIL_R = 1,
|
||||
TSA_N = 2,
|
||||
TSA_R = 3,
|
||||
STSA_N = 4,
|
||||
STSA_R = 5,
|
||||
RADL_N = 6,
|
||||
RADL_R = 7,
|
||||
RASL_N = 8,
|
||||
RASL_R = 9,
|
||||
RSV_VCL_N10 = 10,
|
||||
RSV_VCL_R11 = 11,
|
||||
RSV_VCL_N12 = 12,
|
||||
RSV_VCL_R13 = 13,
|
||||
RSV_VCL_N14 = 14,
|
||||
RSV_VCL_R15 = 15,
|
||||
BLA_W_LP = 16,
|
||||
BLA_W_RADL = 17,
|
||||
BLA_N_LP = 18,
|
||||
IDR_W_RADL = 19,
|
||||
IDR_N_LP = 20,
|
||||
CRA_NUT = 21,
|
||||
RSV_IRAP_VCL22 = 22,
|
||||
RSV_IRAP_VCL23 = 23,
|
||||
RSV_VCL24 = 24,
|
||||
RSV_VCL25 = 25,
|
||||
RSV_VCL26 = 26,
|
||||
RSV_VCL27 = 27,
|
||||
RSV_VCL28 = 28,
|
||||
RSV_VCL29 = 29,
|
||||
RSV_VCL30 = 30,
|
||||
RSV_VCL31 = 31,
|
||||
VPS_NUT = 32,
|
||||
SPS_NUT = 33,
|
||||
PPS_NUT = 34,
|
||||
AUD_NUT = 35,
|
||||
EOS_NUT = 36,
|
||||
EOB_NUT = 37,
|
||||
FD_NUT = 38,
|
||||
PREFIX_SEI_NUT = 39,
|
||||
SUFFIX_SEI_NUT = 40,
|
||||
RSV_NVCL41 = 41,
|
||||
RSV_NVCL42 = 42,
|
||||
RSV_NVCL43 = 43,
|
||||
RSV_NVCL44 = 44,
|
||||
RSV_NVCL45 = 45,
|
||||
RSV_NVCL46 = 46,
|
||||
RSV_NVCL47 = 47,
|
||||
UNSPEC48 = 48,
|
||||
UNSPEC49 = 49,
|
||||
UNSPEC50 = 50,
|
||||
UNSPEC51 = 51,
|
||||
UNSPEC52 = 52,
|
||||
UNSPEC53 = 53,
|
||||
UNSPEC54 = 54,
|
||||
UNSPEC55 = 55,
|
||||
UNSPEC56 = 56,
|
||||
UNSPEC57 = 57,
|
||||
UNSPEC58 = 58,
|
||||
UNSPEC59 = 59,
|
||||
UNSPEC60 = 60,
|
||||
UNSPEC61 = 61,
|
||||
UNSPEC62 = 62,
|
||||
UNSPEC63 = 63,
|
||||
}
|
||||
|
||||
export interface AnnexBHelpers {
|
||||
getUnitType(frame: Buffer): number;
|
||||
splitHeader(frame: Buffer): [Buffer, Buffer];
|
||||
isAUD(unitType: number): boolean;
|
||||
}
|
||||
|
||||
export const H264Helpers: AnnexBHelpers = {
|
||||
getUnitType(frame) {
|
||||
return frame[0] & 0x1f;
|
||||
},
|
||||
splitHeader(frame) {
|
||||
return [frame.subarray(0, 1), frame.subarray(1)];
|
||||
},
|
||||
isAUD(unitType) {
|
||||
return unitType === H264NalUnitTypes.AccessUnitDelimiter;
|
||||
},
|
||||
};
|
||||
|
||||
export const H265Helpers: AnnexBHelpers = {
|
||||
getUnitType(frame) {
|
||||
return (frame[0] >> 1) & 0x3f;
|
||||
},
|
||||
splitHeader(frame) {
|
||||
return [frame.subarray(0, 2), frame.subarray(2)];
|
||||
},
|
||||
isAUD(unitType) {
|
||||
return unitType === H265NalUnitTypes.AUD_NUT;
|
||||
},
|
||||
};
|
||||
|
||||
export const startCode3 = Buffer.from([0, 0, 1]);
|
||||
|
||||
export function splitNalu(buf: Buffer) {
|
||||
let temp: Buffer | null = buf;
|
||||
const nalus: Buffer[] = [];
|
||||
while (temp?.byteLength) {
|
||||
let pos: number = temp.indexOf(startCode3);
|
||||
let length = 3;
|
||||
if (pos > 0 && temp[pos - 1] === 0) {
|
||||
pos--;
|
||||
length++;
|
||||
}
|
||||
const nalu = pos === -1 ? temp : temp.subarray(0, pos);
|
||||
temp = pos === -1 ? null : temp.subarray(pos + length);
|
||||
if (nalu.byteLength) nalus.push(nalu);
|
||||
}
|
||||
return nalus;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
AnnexBBitstreamReader,
|
||||
AnnexBBitstreamWriter,
|
||||
} from "./AnnexBBitstreamReaderWriter.js";
|
||||
|
||||
export function rewriteSPSVUI(buffer: Buffer) {
|
||||
const reader = new AnnexBBitstreamReader(buffer.subarray(1));
|
||||
const writer = new AnnexBBitstreamWriter();
|
||||
|
||||
const readBit = (n = 1) => reader.readBits(n);
|
||||
const writeBit = (v: number, n = 1) => writer.writeBits(v, n);
|
||||
const readU = (n: number) => reader.readUnsigned(n);
|
||||
const writeU = (v: number, n: number) => writer.writeUnsigned(v, n);
|
||||
const readUE = () => reader.readUnsignedExpGolomb();
|
||||
const writeUE = (v: number) => writer.writeUnsignedExpGolomb(v);
|
||||
const readSE = () => reader.readSignedExpGolomb();
|
||||
const writeSE = (v: number) => writer.writeSignedExpGolomb(v);
|
||||
|
||||
// Rewrite the NAL header
|
||||
writeU(buffer[0], 8);
|
||||
|
||||
const profile_idc = readU(8);
|
||||
writeU(profile_idc, 8);
|
||||
|
||||
const constraint_flags = readU(8);
|
||||
writeU(constraint_flags, 8);
|
||||
|
||||
const level_idc = readU(8);
|
||||
writeU(level_idc, 8);
|
||||
|
||||
const seq_parameter_set_id = readUE();
|
||||
writeUE(seq_parameter_set_id);
|
||||
|
||||
// If profile in high profiles, additional fields
|
||||
const highProfiles = new Set([
|
||||
100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 144,
|
||||
]);
|
||||
if (highProfiles.has(profile_idc)) {
|
||||
const chroma_format_idc = readUE();
|
||||
writeUE(chroma_format_idc);
|
||||
|
||||
if (chroma_format_idc === 3) {
|
||||
const separate_colour_plane_flag = readBit(1);
|
||||
writeBit(separate_colour_plane_flag, 1);
|
||||
}
|
||||
|
||||
const bit_depth_luma_minus8 = readUE();
|
||||
writeUE(bit_depth_luma_minus8);
|
||||
const bit_depth_chroma_minus8 = readUE();
|
||||
writeUE(bit_depth_chroma_minus8);
|
||||
|
||||
const qpprime_y_zero_transform_bypass_flag = readBit(1);
|
||||
writeBit(qpprime_y_zero_transform_bypass_flag, 1);
|
||||
|
||||
const seq_scaling_matrix_present_flag = readBit(1);
|
||||
writeBit(seq_scaling_matrix_present_flag, 1);
|
||||
if (seq_scaling_matrix_present_flag) {
|
||||
const scalingCount = chroma_format_idc !== 3 ? 8 : 12;
|
||||
for (let i = 0; i < scalingCount; i++) {
|
||||
const seq_scaling_list_present_flag = readBit(1);
|
||||
writeBit(seq_scaling_list_present_flag, 1);
|
||||
if (seq_scaling_list_present_flag) {
|
||||
const size = i < 6 ? 16 : 64;
|
||||
// scaling_list(size)
|
||||
let lastScale = 8;
|
||||
let nextScale = 8;
|
||||
for (let j = 0; j < size; j++) {
|
||||
const delta = readSE();
|
||||
writeSE(delta);
|
||||
nextScale = (lastScale + delta + 256) % 256;
|
||||
if (nextScale !== 0) lastScale = nextScale;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const log2_max_frame_num_minus4 = readUE();
|
||||
writeUE(log2_max_frame_num_minus4);
|
||||
|
||||
const pic_order_cnt_type = readUE();
|
||||
writeUE(pic_order_cnt_type);
|
||||
if (pic_order_cnt_type === 0) {
|
||||
const log2_max_pic_order_cnt_lsb_minus4 = readUE();
|
||||
writeUE(log2_max_pic_order_cnt_lsb_minus4);
|
||||
} else if (pic_order_cnt_type === 1) {
|
||||
const delta_pic_order_always_zero_flag = readBit(1);
|
||||
writeBit(delta_pic_order_always_zero_flag, 1);
|
||||
const offset_for_non_ref_pic = readSE();
|
||||
writeSE(offset_for_non_ref_pic);
|
||||
const offset_for_top_to_bottom_field = readSE();
|
||||
writeSE(offset_for_top_to_bottom_field);
|
||||
const num_ref_frames_in_pic_order_cnt_cycle = readUE();
|
||||
writeUE(num_ref_frames_in_pic_order_cnt_cycle);
|
||||
for (let i = 0; i < num_ref_frames_in_pic_order_cnt_cycle; i++) {
|
||||
const offset_for_ref_frame = readSE();
|
||||
writeSE(offset_for_ref_frame);
|
||||
}
|
||||
}
|
||||
|
||||
const max_num_ref_frames = readUE();
|
||||
writeUE(max_num_ref_frames);
|
||||
|
||||
const gaps_in_frame_num_value_allowed_flag = readBit(1);
|
||||
writeBit(gaps_in_frame_num_value_allowed_flag, 1);
|
||||
|
||||
const pic_width_in_mbs_minus1 = readUE();
|
||||
writeUE(pic_width_in_mbs_minus1);
|
||||
|
||||
const pic_height_in_map_units_minus1 = readUE();
|
||||
writeUE(pic_height_in_map_units_minus1);
|
||||
|
||||
const frame_mbs_only_flag = readBit(1);
|
||||
writeBit(frame_mbs_only_flag, 1);
|
||||
if (frame_mbs_only_flag === 0) {
|
||||
const mb_adaptive_frame_field_flag = readBit(1);
|
||||
writeBit(mb_adaptive_frame_field_flag, 1);
|
||||
}
|
||||
|
||||
const direct_8x8_inference_flag = readBit(1);
|
||||
writeBit(direct_8x8_inference_flag, 1);
|
||||
|
||||
const frame_cropping_flag = readBit(1);
|
||||
writeBit(frame_cropping_flag, 1);
|
||||
if (frame_cropping_flag) {
|
||||
const frame_crop_left_offset = readUE();
|
||||
writeUE(frame_crop_left_offset);
|
||||
const frame_crop_right_offset = readUE();
|
||||
writeUE(frame_crop_right_offset);
|
||||
const frame_crop_top_offset = readUE();
|
||||
writeUE(frame_crop_top_offset);
|
||||
const frame_crop_bottom_offset = readUE();
|
||||
writeUE(frame_crop_bottom_offset);
|
||||
}
|
||||
|
||||
// https://webrtc.googlesource.com/src/+/5f2c9278f35e47ff72eb191669d473b7400c9f3e/common_video/h264/sps_vui_rewriter.cc#283
|
||||
function addBitstreamRestriction() {
|
||||
// motion_vectors_over_pic_boundaries_flag: u(1)
|
||||
// Default is 1 when not present.
|
||||
writeBit(1, 1);
|
||||
// max_bytes_per_pic_denom: ue(v)
|
||||
// Default is 2 when not present.
|
||||
writeUE(2);
|
||||
// max_bits_per_mb_denom: ue(v)
|
||||
// Default is 1 when not present.
|
||||
writeUE(1);
|
||||
// log2_max_mv_length_horizontal: ue(v)
|
||||
// log2_max_mv_length_vertical: ue(v)
|
||||
// Both default to 16 when not present.
|
||||
writeUE(16);
|
||||
writeUE(16);
|
||||
// ********* IMPORTANT! **********
|
||||
// max_num_reorder_frames: ue(v)
|
||||
writeUE(0);
|
||||
// max_dec_frame_buffering: ue(v)
|
||||
writeUE(max_num_ref_frames);
|
||||
}
|
||||
|
||||
const vui_parameters_present_flag = readBit(1);
|
||||
writeBit(1, 1);
|
||||
// If no VUI exists, write one
|
||||
if (!vui_parameters_present_flag) {
|
||||
// aspect_ratio_info_present_flag, overscan_info_present_flag. Both u(1).
|
||||
writeBit(0, 2);
|
||||
|
||||
// video_signal_type_present_flag, u(1).
|
||||
// Just write 0 here because I'm not gonna bother myself with color space and whatnot
|
||||
writeBit(0, 1);
|
||||
|
||||
// chroma_loc_info_present_flag, timing_info_present_flag,
|
||||
// nal_hrd_parameters_present_flag, vcl_hrd_parameters_present_flag,
|
||||
// pic_struct_present_flag, All u(1)
|
||||
writeBit(0, 5);
|
||||
|
||||
// bitstream_restriction_flag: u(1)
|
||||
writeBit(1, 1);
|
||||
|
||||
addBitstreamRestriction();
|
||||
} else {
|
||||
// VUI parsing and copying
|
||||
const aspect_ratio_info_present_flag = readBit(1);
|
||||
writeBit(aspect_ratio_info_present_flag, 1);
|
||||
if (aspect_ratio_info_present_flag) {
|
||||
const aspect_ratio_idc = readU(8);
|
||||
writeU(aspect_ratio_idc, 8);
|
||||
if (aspect_ratio_idc === 255) {
|
||||
// Extended_SAR
|
||||
const sar_width = readU(16);
|
||||
writeU(sar_width, 16);
|
||||
const sar_height = readU(16);
|
||||
writeU(sar_height, 16);
|
||||
}
|
||||
}
|
||||
|
||||
const overscan_info_present_flag = readBit(1);
|
||||
writeBit(overscan_info_present_flag, 1);
|
||||
if (overscan_info_present_flag) {
|
||||
const overscan_appropriate_flag = readBit(1);
|
||||
writeBit(overscan_appropriate_flag, 1);
|
||||
}
|
||||
|
||||
// Read the video signal type, but don't copy it
|
||||
const video_signal_type_present_flag = readBit(1);
|
||||
writeBit(0, 1);
|
||||
if (video_signal_type_present_flag) {
|
||||
const _video_format = readBit(3);
|
||||
// writeBit(video_format, 3);
|
||||
const _video_full_range_flag = readBit(1);
|
||||
// writeBit(video_full_range_flag, 1);
|
||||
const colour_description_present_flag = readBit(1);
|
||||
// writeBit(colour_description_present_flag, 1);
|
||||
if (colour_description_present_flag) {
|
||||
const _colour_primaries = readU(8);
|
||||
// writeU(colour_primaries, 8);
|
||||
const _transfer_characteristics = readU(8);
|
||||
// writeU(transfer_characteristics, 8);
|
||||
const _matrix_coeffs = readU(8);
|
||||
// writeU(matrix_coeffs, 8);
|
||||
}
|
||||
}
|
||||
|
||||
const chroma_loc_info_present_flag = readBit(1);
|
||||
writeBit(chroma_loc_info_present_flag, 1);
|
||||
if (chroma_loc_info_present_flag) {
|
||||
const chroma_sample_loc_type_top_field = readUE();
|
||||
writeUE(chroma_sample_loc_type_top_field);
|
||||
const chroma_sample_loc_type_bottom_field = readUE();
|
||||
writeUE(chroma_sample_loc_type_bottom_field);
|
||||
}
|
||||
|
||||
const timing_info_present_flag = readBit(1);
|
||||
writeBit(timing_info_present_flag, 1);
|
||||
if (timing_info_present_flag) {
|
||||
const num_units_in_tick = readU(32);
|
||||
writeU(num_units_in_tick, 32);
|
||||
const time_scale = readU(32);
|
||||
writeU(time_scale, 32);
|
||||
const fixed_frame_rate_flag = readBit(1);
|
||||
writeBit(fixed_frame_rate_flag, 1);
|
||||
}
|
||||
|
||||
const nal_hrd_parameters_present_flag = readBit(1);
|
||||
writeBit(nal_hrd_parameters_present_flag, 1);
|
||||
if (nal_hrd_parameters_present_flag) {
|
||||
// hrd_parameters()
|
||||
const cpb_cnt_minus1 = readUE();
|
||||
writeUE(cpb_cnt_minus1);
|
||||
const bit_rate_scale = readBit(4);
|
||||
writeBit(bit_rate_scale, 4);
|
||||
const cpb_size_scale = readBit(4);
|
||||
writeBit(cpb_size_scale, 4);
|
||||
for (let i = 0; i <= cpb_cnt_minus1; i++) {
|
||||
const bit_rate_value_minus1 = readUE();
|
||||
writeUE(bit_rate_value_minus1);
|
||||
const cpb_size_value_minus1 = readUE();
|
||||
writeUE(cpb_size_value_minus1);
|
||||
const cbr_flag = readBit(1);
|
||||
writeBit(cbr_flag, 1);
|
||||
}
|
||||
const initial_cpb_removal_delay_length_minus1 = readBit(5);
|
||||
writeBit(initial_cpb_removal_delay_length_minus1, 5);
|
||||
const cpb_removal_delay_length_minus1 = readBit(5);
|
||||
writeBit(cpb_removal_delay_length_minus1, 5);
|
||||
const dpb_output_delay_length_minus1 = readBit(5);
|
||||
writeBit(dpb_output_delay_length_minus1, 5);
|
||||
const time_offset_length = readBit(5);
|
||||
writeBit(time_offset_length, 5);
|
||||
}
|
||||
|
||||
const vcl_hrd_parameters_present_flag = readBit(1);
|
||||
writeBit(vcl_hrd_parameters_present_flag, 1);
|
||||
if (vcl_hrd_parameters_present_flag) {
|
||||
// hrd_parameters()
|
||||
const cpb_cnt_minus1 = readUE();
|
||||
writeUE(cpb_cnt_minus1);
|
||||
const bit_rate_scale = readBit(4);
|
||||
writeBit(bit_rate_scale, 4);
|
||||
const cpb_size_scale = readBit(4);
|
||||
writeBit(cpb_size_scale, 4);
|
||||
for (let i = 0; i <= cpb_cnt_minus1; i++) {
|
||||
const bit_rate_value_minus1 = readUE();
|
||||
writeUE(bit_rate_value_minus1);
|
||||
const cpb_size_value_minus1 = readUE();
|
||||
writeUE(cpb_size_value_minus1);
|
||||
const cbr_flag = readBit(1);
|
||||
writeBit(cbr_flag, 1);
|
||||
}
|
||||
const initial_cpb_removal_delay_length_minus1 = readBit(5);
|
||||
writeBit(initial_cpb_removal_delay_length_minus1, 5);
|
||||
const cpb_removal_delay_length_minus1 = readBit(5);
|
||||
writeBit(cpb_removal_delay_length_minus1, 5);
|
||||
const dpb_output_delay_length_minus1 = readBit(5);
|
||||
writeBit(dpb_output_delay_length_minus1, 5);
|
||||
const time_offset_length = readBit(5);
|
||||
writeBit(time_offset_length, 5);
|
||||
}
|
||||
|
||||
if (nal_hrd_parameters_present_flag || vcl_hrd_parameters_present_flag) {
|
||||
const low_delay_hrd_flag = readBit(1);
|
||||
writeBit(low_delay_hrd_flag, 1);
|
||||
}
|
||||
|
||||
const pic_struct_present_flag = readBit(1);
|
||||
writeBit(pic_struct_present_flag, 1);
|
||||
|
||||
const bitstream_restriction_flag = readBit(1);
|
||||
writeBit(1, 1);
|
||||
if (!bitstream_restriction_flag) {
|
||||
addBitstreamRestriction();
|
||||
} else {
|
||||
const motion_vectors_over_pic_boundaries_flag = readBit(1);
|
||||
writeBit(motion_vectors_over_pic_boundaries_flag, 1);
|
||||
const max_bytes_per_pic_denom = readUE();
|
||||
writeUE(max_bytes_per_pic_denom);
|
||||
const max_bits_per_mb_denom = readUE();
|
||||
writeUE(max_bits_per_mb_denom);
|
||||
const log2_max_mv_length_horizontal = readUE();
|
||||
writeUE(log2_max_mv_length_horizontal);
|
||||
const log2_max_mv_length_vertical = readUE();
|
||||
writeUE(log2_max_mv_length_vertical);
|
||||
const _num_reorder_frames = readUE();
|
||||
writeUE(0);
|
||||
const _max_dec_frame_buffering = readUE();
|
||||
writeUE(max_num_ref_frames);
|
||||
}
|
||||
}
|
||||
|
||||
writeBit(1, 1); // rbsp_stop_one_bit
|
||||
writer.flush();
|
||||
// return the rewritten RBSP as a buffer
|
||||
return writer.toBuffer();
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import Davey from "@snazzah/davey";
|
||||
import EventEmitter from "node:events";
|
||||
import { Log } from "debug-level";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { CodecPayloadType } from "./CodecPayloadType.js";
|
||||
import { WebRtcConnWrapper } from "./WebRtcWrapper.js";
|
||||
import { VoiceOpCodes, VoiceOpCodesBinary } from "./VoiceOpCodes.js";
|
||||
import {
|
||||
STREAMS_SIMULCAST,
|
||||
type SupportedEncryptionModes,
|
||||
} from "../../utils.js";
|
||||
import type {
|
||||
Message,
|
||||
GatewayRequest,
|
||||
GatewayResponse,
|
||||
} from "./VoiceMessageTypes.js";
|
||||
import type { Streamer } from "../Streamer.js";
|
||||
|
||||
type VoiceConnectionStatus = {
|
||||
hasSession: boolean;
|
||||
hasToken: boolean;
|
||||
started: boolean;
|
||||
resuming: boolean;
|
||||
};
|
||||
|
||||
type WebRtcParameters = {
|
||||
address: string;
|
||||
port: number;
|
||||
audioSsrc: number;
|
||||
videoSsrc: number;
|
||||
rtxSsrc: number;
|
||||
supportedEncryptionModes: SupportedEncryptionModes[];
|
||||
};
|
||||
|
||||
type ValueOf<T> = T extends (infer U)[]
|
||||
? U
|
||||
: T extends Record<string, infer U>
|
||||
? U
|
||||
: never;
|
||||
|
||||
export type VideoAttributes = {
|
||||
width: number;
|
||||
height: number;
|
||||
fps: number;
|
||||
};
|
||||
|
||||
export abstract class BaseMediaConnection extends EventEmitter {
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
public guildId: string | null = null;
|
||||
public channelId: string;
|
||||
public botId: string;
|
||||
public ws: WebSocket | null = null;
|
||||
public status: VoiceConnectionStatus;
|
||||
public server: string | null = null; //websocket url
|
||||
public token: string | null = null;
|
||||
public session_id: string | null = null;
|
||||
|
||||
private _webRtcWrapper;
|
||||
private _webRtcParams: WebRtcParameters | null = null;
|
||||
private _closed = false;
|
||||
public ready: (conn: WebRtcConnWrapper) => void;
|
||||
|
||||
private _streamer: Streamer;
|
||||
private _sequenceNumber = -1;
|
||||
|
||||
private _daveSession: Davey.DaveSession | undefined;
|
||||
private _connectedUsers = new Set<string>();
|
||||
private _daveProtocolVersion = 0;
|
||||
private _davePendingTransitions = new Map<number, number>();
|
||||
private _daveDowngraded = false;
|
||||
|
||||
private _logger = new Log("conn");
|
||||
private _loggerDave = new Log("conn:dave");
|
||||
constructor(
|
||||
streamer: Streamer,
|
||||
guildId: string | null,
|
||||
botId: string,
|
||||
channelId: string,
|
||||
callback: (conn: WebRtcConnWrapper) => void,
|
||||
) {
|
||||
super();
|
||||
this._streamer = streamer;
|
||||
this.status = {
|
||||
hasSession: false,
|
||||
hasToken: false,
|
||||
started: false,
|
||||
resuming: false,
|
||||
};
|
||||
|
||||
this.guildId = guildId;
|
||||
this.channelId = channelId;
|
||||
this.botId = botId;
|
||||
this.ready = callback;
|
||||
this._webRtcWrapper = new WebRtcConnWrapper(this);
|
||||
}
|
||||
|
||||
public abstract get serverId(): string | null;
|
||||
|
||||
public get type(): "guild" | "call" {
|
||||
return this.guildId ? "guild" : "call";
|
||||
}
|
||||
|
||||
public get webRtcConn() {
|
||||
return this._webRtcWrapper;
|
||||
}
|
||||
|
||||
public get webRtcParams() {
|
||||
return this._webRtcParams;
|
||||
}
|
||||
|
||||
public get streamer() {
|
||||
return this._streamer;
|
||||
}
|
||||
|
||||
public abstract get daveChannelId(): string;
|
||||
|
||||
stop(): void {
|
||||
this._closed = true;
|
||||
this._webRtcWrapper.close();
|
||||
this.ws?.close();
|
||||
}
|
||||
|
||||
setSession(session_id: string): void {
|
||||
this.session_id = session_id;
|
||||
|
||||
this.status.hasSession = true;
|
||||
this.start();
|
||||
}
|
||||
|
||||
setTokens(server: string, token: string): void {
|
||||
this.token = token;
|
||||
this.server = server;
|
||||
|
||||
this.status.hasToken = true;
|
||||
this.start();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
/*
|
||||
** Connection can only start once both
|
||||
** session description and tokens have been gathered
|
||||
*/
|
||||
if (this.status.hasSession && this.status.hasToken) {
|
||||
if (this.status.started) return;
|
||||
this.status.started = true;
|
||||
|
||||
this.ws = new WebSocket(`wss://${this.server}/?v=8`);
|
||||
this.ws.binaryType = "arraybuffer";
|
||||
this.ws.addEventListener("open", () => {
|
||||
if (this.status.resuming) {
|
||||
this.status.resuming = false;
|
||||
this.resume();
|
||||
} else {
|
||||
this.identify();
|
||||
}
|
||||
});
|
||||
this.ws.addEventListener("error", (err) => {
|
||||
console.error(err);
|
||||
});
|
||||
this.ws.addEventListener("close", (e) => {
|
||||
const wasStarted = this.status.started;
|
||||
|
||||
this.interval && clearInterval(this.interval);
|
||||
this.status.started = false;
|
||||
const canResume = e.code === 4_015 || e.code < 4_000;
|
||||
|
||||
if (canResume && wasStarted) {
|
||||
this.status.resuming = true;
|
||||
this.start();
|
||||
} else {
|
||||
this._closed = true;
|
||||
this._webRtcWrapper?.close();
|
||||
}
|
||||
});
|
||||
this.setupEvents();
|
||||
}
|
||||
}
|
||||
|
||||
handleReady(d: Message.Ready): void {
|
||||
// we hardcoded the STREAMS_SIMULCAST, which will always be array of 1
|
||||
const stream = d.streams[0];
|
||||
this._webRtcParams = {
|
||||
address: d.ip,
|
||||
port: d.port,
|
||||
audioSsrc: d.ssrc,
|
||||
videoSsrc: stream.ssrc,
|
||||
rtxSsrc: stream.rtx_ssrc,
|
||||
supportedEncryptionModes: d.modes,
|
||||
};
|
||||
}
|
||||
|
||||
async handleProtocolAck(d: Message.SelectProtocolAck) {
|
||||
if (!("sdp" in d)) throw new Error("Only WebRTC connections are allowed");
|
||||
this._daveProtocolVersion = d.dave_protocol_version;
|
||||
this.initDave();
|
||||
// Discord's SDP is absolute garbage...Generate one ourselves
|
||||
let ip = "",
|
||||
port = "",
|
||||
iceUsername = "",
|
||||
icePassword = "",
|
||||
fingerprint = "",
|
||||
candidate = "";
|
||||
for (const line of d.sdp.split("\n")) {
|
||||
if (line.startsWith("c=")) ip = line;
|
||||
else if (line.startsWith("a=rtcp")) port = line.split(":")[1];
|
||||
else if (line.startsWith("a=ice-ufrag")) iceUsername = line;
|
||||
else if (line.startsWith("a=ice-pwd")) icePassword = line;
|
||||
else if (line.startsWith("a=fingerprint")) fingerprint = line;
|
||||
else if (line.startsWith("a=candidate")) candidate = line;
|
||||
}
|
||||
const audioPayloadType = CodecPayloadType.opus.payload_type;
|
||||
const audioSection = `
|
||||
m=audio ${port} UDP/TLS/RTP/SAVPF ${audioPayloadType}
|
||||
${ip}
|
||||
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
|
||||
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
|
||||
a=setup:passive
|
||||
a=mid:0
|
||||
a=maxptime:60
|
||||
a=inactive
|
||||
${iceUsername}
|
||||
${icePassword}
|
||||
${fingerprint}
|
||||
${candidate}
|
||||
a=rtcp-mux
|
||||
a=rtpmap:${audioPayloadType} opus/48000/2
|
||||
a=fmtp:${audioPayloadType} minptime=10;useinbandfec=1;usedtx=1
|
||||
a=rtcp-fb:${audioPayloadType} transport-cc
|
||||
a=rtcp-fb:${audioPayloadType} nack
|
||||
a=ice-lite
|
||||
`.trim();
|
||||
const videoPayloads = Object.values(CodecPayloadType).filter(
|
||||
(el) => el.type === "video",
|
||||
);
|
||||
const videoPayloadTypes = videoPayloads.flatMap((el) => [
|
||||
el.payload_type,
|
||||
el.rtx_payload_type,
|
||||
]);
|
||||
const videoSection = `
|
||||
m=video ${port} UDP/TLS/RTP/SAVPF ${videoPayloadTypes.join(" ")}
|
||||
${ip}
|
||||
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
|
||||
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
|
||||
a=extmap:14 urn:ietf:params:rtp-hdrext:toffset
|
||||
a=extmap:13 urn:3gpp:video-orientation
|
||||
a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
|
||||
a=setup:passive
|
||||
a=mid:1
|
||||
a=inactive
|
||||
${iceUsername}
|
||||
${icePassword}
|
||||
${fingerprint}
|
||||
${candidate}
|
||||
a=rtcp-mux
|
||||
a=ice-lite
|
||||
`.trim();
|
||||
const videoRtpMap = videoPayloads
|
||||
.flatMap((el) => [
|
||||
`a=rtpmap:${el.payload_type} ${el.name}/90000`,
|
||||
`a=rtpmap:${el.rtx_payload_type} rtx/90000`,
|
||||
`a=fmtp:${el.rtx_payload_type} apt=${el.payload_type}`,
|
||||
`a=rtcp-fb:${el.payload_type} ccm fir`,
|
||||
`a=rtcp-fb:${el.payload_type} nack`,
|
||||
`a=rtcp-fb:${el.payload_type} nack pli`,
|
||||
`a=rtcp-fb:${el.payload_type} goog-remb`,
|
||||
`a=rtcp-fb:${el.payload_type} transport-cc`,
|
||||
])
|
||||
.join("\n");
|
||||
this._webRtcWrapper.webRtcConn?.setRemoteDescription(
|
||||
[audioSection, videoSection, videoRtpMap].join("\n"),
|
||||
"answer",
|
||||
);
|
||||
this.emit("select_protocol_ack");
|
||||
}
|
||||
|
||||
initDave() {
|
||||
if (this._daveProtocolVersion) {
|
||||
if (this._daveSession) {
|
||||
this._daveSession.reinit(
|
||||
this._daveProtocolVersion,
|
||||
this.botId,
|
||||
this.daveChannelId,
|
||||
);
|
||||
this._loggerDave.debug(`Reinitialized DAVE`, {
|
||||
user_id: this.botId,
|
||||
channel_id: this.daveChannelId,
|
||||
});
|
||||
} else {
|
||||
this._daveSession = new Davey.DAVESession(
|
||||
this._daveProtocolVersion,
|
||||
this.botId,
|
||||
this.daveChannelId,
|
||||
);
|
||||
this._loggerDave.debug(`Initialized DAVE`, {
|
||||
user_id: this.botId,
|
||||
channel_id: this.daveChannelId,
|
||||
});
|
||||
}
|
||||
this.sendOpcodeBinary(
|
||||
VoiceOpCodesBinary.MLS_KEY_PACKAGE,
|
||||
this._daveSession.getSerializedKeyPackage(),
|
||||
);
|
||||
} else if (this._daveSession) {
|
||||
this._daveSession.reset();
|
||||
this._daveSession.setPassthroughMode(true, 10);
|
||||
}
|
||||
}
|
||||
|
||||
processInvalidCommit(transitionId: number) {
|
||||
this._loggerDave.debug("Invalid commit received, reinitializing DAVE", {
|
||||
transitionId,
|
||||
});
|
||||
this.sendOpcode(VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME, {
|
||||
transition_id: transitionId,
|
||||
});
|
||||
this.initDave();
|
||||
}
|
||||
|
||||
executePendingTransition(transitionId: number) {
|
||||
const newVersion = this._davePendingTransitions.get(transitionId);
|
||||
if (newVersion === undefined) {
|
||||
this._loggerDave.error("Unrecognized transition ID", { transitionId });
|
||||
return;
|
||||
}
|
||||
const oldVersion = this._daveProtocolVersion;
|
||||
this._daveProtocolVersion = newVersion;
|
||||
|
||||
if (oldVersion !== newVersion && newVersion === 0) {
|
||||
// Downgraded
|
||||
this._daveDowngraded = true;
|
||||
this._loggerDave.debug("Downgraded to non-E2E voice call");
|
||||
} else if (transitionId > 0 && this._daveDowngraded) {
|
||||
this._daveDowngraded = false;
|
||||
this._daveSession?.setPassthroughMode(true, 10);
|
||||
this._loggerDave.debug("Upgraded to E2E voice call");
|
||||
}
|
||||
|
||||
this._davePendingTransitions.delete(transitionId);
|
||||
this._loggerDave.debug(`Pending transition ID ${transitionId} executed`, {
|
||||
transitionId,
|
||||
});
|
||||
}
|
||||
|
||||
setupEvents(): void {
|
||||
this.ws?.addEventListener("message", async (e) => {
|
||||
if (e.data instanceof ArrayBuffer) {
|
||||
this.handleBinaryMessages(Buffer.from(e.data));
|
||||
return;
|
||||
}
|
||||
const { op, d, seq } = JSON.parse(e.data as string) as GatewayResponse;
|
||||
if (seq) this._sequenceNumber = seq;
|
||||
|
||||
if (op === VoiceOpCodes.READY) {
|
||||
// ready
|
||||
this.handleReady(d);
|
||||
this.setProtocols().then(() => this.ready(this._webRtcWrapper));
|
||||
this.setVideoAttributes(false);
|
||||
} else if (op >= 4000) {
|
||||
console.error(`Error ${this.constructor.name} connection`, d);
|
||||
} else if (op === VoiceOpCodes.HELLO) {
|
||||
this.setupHeartbeat(d.heartbeat_interval);
|
||||
} else if (op === VoiceOpCodes.SELECT_PROTOCOL_ACK) {
|
||||
// session description
|
||||
this.handleProtocolAck(d);
|
||||
} else if (op === VoiceOpCodes.SPEAKING) {
|
||||
// ignore speaking updates
|
||||
} else if (op === VoiceOpCodes.HEARTBEAT_ACK) {
|
||||
// ignore heartbeat acknowledgements
|
||||
} else if (op === VoiceOpCodes.RESUMED) {
|
||||
this.status.started = true;
|
||||
} else if (op === VoiceOpCodes.CLIENTS_CONNECT) {
|
||||
d.user_ids.forEach((id) => {
|
||||
this._connectedUsers.add(id);
|
||||
});
|
||||
} else if (op === VoiceOpCodes.CLIENT_DISCONNECT) {
|
||||
this._connectedUsers.delete(d.user_id);
|
||||
} else if (op === VoiceOpCodes.DAVE_PREPARE_TRANSITION) {
|
||||
this._loggerDave.debug("Preparing for DAVE transition", d);
|
||||
this._davePendingTransitions.set(d.transition_id, d.protocol_version);
|
||||
if (d.transition_id === 0) {
|
||||
this.executePendingTransition(d.transition_id);
|
||||
} else {
|
||||
if (d.protocol_version === 0)
|
||||
this._daveSession?.setPassthroughMode(true, 120);
|
||||
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
|
||||
transition_id: d.transition_id,
|
||||
});
|
||||
}
|
||||
} else if (op === VoiceOpCodes.DAVE_EXECUTE_TRANSITION) {
|
||||
this.executePendingTransition(d.transition_id);
|
||||
} else if (op === VoiceOpCodes.DAVE_PREPARE_EPOCH) {
|
||||
this._loggerDave.debug("Preparing for DAVE epoch", d);
|
||||
if (d.epoch === 1) {
|
||||
this._daveProtocolVersion = d.protocol_version;
|
||||
this.initDave();
|
||||
}
|
||||
} else {
|
||||
//console.log("unhandled voice event", {op, d});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleBinaryMessages(msg: Buffer) {
|
||||
this._sequenceNumber = msg.readUint16BE(0);
|
||||
const op = msg.readUint8(2);
|
||||
this._logger.trace(`Handling binary message with op ${op}`, { op });
|
||||
switch (op) {
|
||||
case VoiceOpCodesBinary.MLS_EXTERNAL_SENDER: {
|
||||
this._daveSession?.setExternalSender(msg.subarray(3));
|
||||
this._loggerDave.debug("Set MLS external sender");
|
||||
break;
|
||||
}
|
||||
case VoiceOpCodesBinary.MLS_PROPOSALS: {
|
||||
const optype = msg.readUint8(3);
|
||||
const { commit, welcome } = this._daveSession!.processProposals(
|
||||
optype,
|
||||
msg.subarray(4),
|
||||
[...this._connectedUsers],
|
||||
);
|
||||
if (commit) {
|
||||
this.sendOpcodeBinary(
|
||||
VoiceOpCodesBinary.MLS_COMMIT_WELCOME,
|
||||
welcome ? Buffer.concat([commit, welcome]) : commit,
|
||||
);
|
||||
}
|
||||
this._loggerDave.debug("Processed MLS proposal");
|
||||
break;
|
||||
}
|
||||
case VoiceOpCodesBinary.MLS_ANNOUNCE_COMMIT_TRANSITION: {
|
||||
const transitionId = msg.readUInt16BE(3);
|
||||
try {
|
||||
this._daveSession?.processCommit(msg.subarray(5));
|
||||
if (transitionId) {
|
||||
this._davePendingTransitions.set(
|
||||
transitionId,
|
||||
this._daveProtocolVersion,
|
||||
);
|
||||
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
|
||||
transition_id: transitionId,
|
||||
});
|
||||
}
|
||||
this._loggerDave.debug("MLS commit processed", { transitionId });
|
||||
} catch (e) {
|
||||
this._loggerDave.debug("MLS commit errored", e);
|
||||
this.processInvalidCommit(transitionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case VoiceOpCodesBinary.MLS_WELCOME: {
|
||||
const transitionId = msg.readUInt16BE(3);
|
||||
try {
|
||||
this._daveSession?.processWelcome(msg.subarray(5));
|
||||
if (transitionId) {
|
||||
this._davePendingTransitions.set(
|
||||
transitionId,
|
||||
this._daveProtocolVersion,
|
||||
);
|
||||
this.sendOpcode(VoiceOpCodes.DAVE_TRANSITION_READY, {
|
||||
transition_id: transitionId,
|
||||
});
|
||||
}
|
||||
this._loggerDave.debug("MLS welcome processed", { transitionId });
|
||||
} catch (e) {
|
||||
this._loggerDave.debug("MLS welcome errored", e);
|
||||
this.processInvalidCommit(transitionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public get daveReady() {
|
||||
return this._daveProtocolVersion && this._daveSession?.ready;
|
||||
}
|
||||
|
||||
public get daveSession() {
|
||||
return this._daveSession;
|
||||
}
|
||||
|
||||
setupHeartbeat(interval: number): void {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.interval = setInterval(() => {
|
||||
try {
|
||||
this.sendOpcode(VoiceOpCodes.HEARTBEAT, {
|
||||
t: Date.now(),
|
||||
seq_ack: this._sequenceNumber,
|
||||
});
|
||||
} catch {}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
sendOpcode<T extends GatewayRequest>(code: T["op"], data: T["d"]): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
op: code,
|
||||
d: data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
sendOpcodeBinary(code: VoiceOpCodesBinary, data: Buffer) {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
||||
const buf = Buffer.allocUnsafe(data.length + 1);
|
||||
buf.writeUInt8(code);
|
||||
data.copy(buf, 1);
|
||||
this.ws.send(buf);
|
||||
}
|
||||
|
||||
/*
|
||||
** identifies with media server with credentials
|
||||
*/
|
||||
identify(): void {
|
||||
if (!this.serverId) throw new Error("Server ID is null or empty");
|
||||
if (!this.session_id) throw new Error("Session ID is null or empty");
|
||||
if (!this.token) throw new Error("Token is null or empty");
|
||||
this.sendOpcode(VoiceOpCodes.IDENTIFY, {
|
||||
server_id: this.serverId,
|
||||
user_id: this.botId,
|
||||
session_id: this.session_id,
|
||||
token: this.token,
|
||||
video: true,
|
||||
streams: STREAMS_SIMULCAST,
|
||||
max_dave_protocol_version: Davey.DAVE_PROTOCOL_VERSION ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
if (!this.serverId) throw new Error("Server ID is null or empty");
|
||||
if (!this.session_id) throw new Error("Session ID is null or empty");
|
||||
if (!this.token) throw new Error("Token is null or empty");
|
||||
this.sendOpcode(VoiceOpCodes.RESUME, {
|
||||
server_id: this.serverId,
|
||||
session_id: this.session_id,
|
||||
token: this.token,
|
||||
seq_ack: this._sequenceNumber,
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
** Sets protocols and ip data used for video and audio.
|
||||
** Uses vp8 for video
|
||||
** Uses opus for audio
|
||||
*/
|
||||
public async setProtocols(): Promise<void> {
|
||||
if (!this._webRtcParams) throw new Error("WebRTC parameters not set");
|
||||
// if (
|
||||
// this._webRtcParams.supportedEncryptionModes.includes(SupportedEncryptionModes.AES256) &&
|
||||
// !this._streamer.opts.forceChacha20Encryption
|
||||
// ) {
|
||||
// encryptionMode = SupportedEncryptionModes.AES256
|
||||
// } else {
|
||||
// encryptionMode = SupportedEncryptionModes.XCHACHA20
|
||||
// }
|
||||
|
||||
const reconnect = () => {
|
||||
const webRtcConn = this._webRtcWrapper.initWebRtc();
|
||||
webRtcConn.onStateChange((state) => {
|
||||
if (state === "closed" && !this._closed) reconnect();
|
||||
});
|
||||
webRtcConn.onLocalDescription((sdp) => {
|
||||
const rtc_connection_id = randomUUID();
|
||||
this.sendOpcode(VoiceOpCodes.SELECT_PROTOCOL, {
|
||||
protocol: "webrtc",
|
||||
codecs: Object.values(CodecPayloadType) as ValueOf<
|
||||
typeof CodecPayloadType
|
||||
>[],
|
||||
data: sdp,
|
||||
sdp: sdp,
|
||||
rtc_connection_id,
|
||||
});
|
||||
});
|
||||
webRtcConn.setLocalDescription();
|
||||
};
|
||||
reconnect();
|
||||
return new Promise((resolve) => {
|
||||
this.once("select_protocol_ack", () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* 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 parameters not set");
|
||||
const { audioSsrc, videoSsrc, rtxSsrc } = this._webRtcParams;
|
||||
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,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
** Set speaking status
|
||||
** speaking -> speaking status on or off
|
||||
*/
|
||||
public setSpeaking(speaking: boolean): void {
|
||||
if (!this._webRtcParams) throw new Error("WebRTC connection not ready");
|
||||
this.sendOpcode(VoiceOpCodes.SPEAKING, {
|
||||
delay: 0,
|
||||
speaking: speaking ? 1 : 0,
|
||||
ssrc: this._webRtcParams.audioSsrc,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export const CodecPayloadType = {
|
||||
opus: {
|
||||
name: "opus",
|
||||
type: "audio",
|
||||
clockRate: 48000,
|
||||
priority: 1000,
|
||||
payload_type: 120,
|
||||
},
|
||||
H264: {
|
||||
name: "H264",
|
||||
type: "video",
|
||||
clockRate: 90000,
|
||||
priority: 1000,
|
||||
payload_type: 101,
|
||||
rtx_payload_type: 102,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
H265: {
|
||||
name: "H265",
|
||||
type: "video",
|
||||
clockRate: 90000,
|
||||
priority: 1000,
|
||||
payload_type: 103,
|
||||
rtx_payload_type: 104,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
VP8: {
|
||||
name: "VP8",
|
||||
type: "video",
|
||||
clockRate: 90000,
|
||||
priority: 1000,
|
||||
payload_type: 105,
|
||||
rtx_payload_type: 106,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
VP9: {
|
||||
name: "VP9",
|
||||
type: "video",
|
||||
clockRate: 90000,
|
||||
priority: 1000,
|
||||
payload_type: 107,
|
||||
rtx_payload_type: 108,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
AV1: {
|
||||
name: "AV1",
|
||||
type: "video",
|
||||
clockRate: 90000,
|
||||
priority: 1000,
|
||||
payload_type: 109,
|
||||
rtx_payload_type: 110,
|
||||
encode: true,
|
||||
decode: true,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,171 @@
|
||||
import udpCon from 'node:dgram';
|
||||
import { isIP } from 'node:net';
|
||||
import { AudioPacketizer } from '../packet/AudioPacketizer.js';
|
||||
import {
|
||||
VideoPacketizerH264,
|
||||
VideoPacketizerH265
|
||||
} from '../packet/VideoPacketizerAnnexB.js';
|
||||
import { VideoPacketizerVP8 } from '../packet/VideoPacketizerVP8.js';
|
||||
import { normalizeVideoCodec } from '../../utils.js';
|
||||
import type { BaseMediaPacketizer } from '../packet/BaseMediaPacketizer.js';
|
||||
import type { BaseMediaConnection } from './BaseMediaConnection.js';
|
||||
|
||||
// credit to discord.js
|
||||
function parseLocalPacket(message: Buffer) {
|
||||
const packet = Buffer.from(message);
|
||||
|
||||
const ip = packet.subarray(8, packet.indexOf(0, 8)).toString('utf8');
|
||||
|
||||
if (!isIP(ip)) {
|
||||
throw new Error('Malformed IP address');
|
||||
}
|
||||
|
||||
const port = packet.readUInt16BE(packet.length - 2);
|
||||
|
||||
return { ip, port };
|
||||
}
|
||||
|
||||
export class MediaUdp {
|
||||
private _mediaConnection: BaseMediaConnection;
|
||||
private _socket: udpCon.Socket | null = null;
|
||||
private _ready = false;
|
||||
private _audioPacketizer?: BaseMediaPacketizer;
|
||||
private _videoPacketizer?: BaseMediaPacketizer;
|
||||
private _ip?: string;
|
||||
private _port?: number;
|
||||
|
||||
constructor(voiceConnection: BaseMediaConnection) {
|
||||
this._mediaConnection = voiceConnection;
|
||||
}
|
||||
|
||||
public get audioPacketizer(): BaseMediaPacketizer | undefined {
|
||||
return this._audioPacketizer;
|
||||
}
|
||||
|
||||
public get videoPacketizer(): BaseMediaPacketizer | undefined {
|
||||
// This will never be undefined anyway, so it's safe
|
||||
return this._videoPacketizer;
|
||||
}
|
||||
|
||||
public get mediaConnection(): BaseMediaConnection {
|
||||
return this._mediaConnection;
|
||||
}
|
||||
|
||||
public get ip()
|
||||
{
|
||||
return this._ip;
|
||||
}
|
||||
|
||||
public get port()
|
||||
{
|
||||
return this._port;
|
||||
}
|
||||
|
||||
public async sendAudioFrame(frame: Buffer, frametime: number): Promise<void> {
|
||||
if(!this.ready) return;
|
||||
await this.audioPacketizer?.sendFrame(frame, frametime);
|
||||
}
|
||||
|
||||
public async sendVideoFrame(frame: Buffer, frametime: number): Promise<void> {
|
||||
if(!this.ready) return;
|
||||
await this.videoPacketizer?.sendFrame(frame, frametime);
|
||||
}
|
||||
|
||||
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, audioSsrc);
|
||||
switch (normalizeVideoCodec(videoCodec))
|
||||
{
|
||||
case "H264":
|
||||
this._videoPacketizer = new VideoPacketizerH264(this, videoSsrc);
|
||||
break;
|
||||
case "H265":
|
||||
this._videoPacketizer = new VideoPacketizerH265(this, videoSsrc);
|
||||
break;
|
||||
case "VP8":
|
||||
this._videoPacketizer = new VideoPacketizerVP8(this, videoSsrc);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Packetizer not implemented for ${videoCodec}`)
|
||||
}
|
||||
}
|
||||
|
||||
public sendPacket(packet: Buffer): Promise<void> {
|
||||
if (!this.mediaConnection.webRtcParams)
|
||||
throw new Error("WebRTC connection not ready");
|
||||
const { address, port } = this.mediaConnection.webRtcParams;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
this._socket?.send(packet, 0, packet.length, port, address, (error, bytes) => {
|
||||
if (error) {
|
||||
console.log("ERROR", error);
|
||||
reject(error);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
} catch(e) {reject(e)}
|
||||
});
|
||||
}
|
||||
|
||||
handleIncoming(buf: unknown): void {
|
||||
//console.log("RECEIVED PACKET", buf);
|
||||
}
|
||||
|
||||
public get ready(): boolean {
|
||||
return this._ready;
|
||||
}
|
||||
|
||||
public set ready(val: boolean) {
|
||||
this._ready = val;
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
try {
|
||||
this.ready = false;
|
||||
this._socket?.disconnect();
|
||||
}catch(e) {}
|
||||
}
|
||||
|
||||
public createUdp(): Promise<void> {
|
||||
if (!this.mediaConnection.webRtcParams)
|
||||
throw new Error("WebRTC connection not ready");
|
||||
const { audioSsrc, address, port } = this.mediaConnection.webRtcParams;
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this._socket = udpCon.createSocket('udp4');
|
||||
|
||||
this._socket.on('error', (error: Error) => {
|
||||
console.error("Error connecting to media udp server", error);
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this._socket.once('message', (message) => {
|
||||
if (message.readUInt16BE(0) !== 2) {
|
||||
reject('wrong handshake packet for udp')
|
||||
}
|
||||
try {
|
||||
const packet = parseLocalPacket(message);
|
||||
this._ip = packet.ip;
|
||||
this._port = packet.port;
|
||||
this._ready = true;
|
||||
} catch(e) { reject(e) }
|
||||
|
||||
resolve();
|
||||
this._socket?.on('message', this.handleIncoming);
|
||||
});
|
||||
|
||||
const blank = Buffer.alloc(74);
|
||||
|
||||
blank.writeUInt16BE(1, 0);
|
||||
blank.writeUInt16BE(70, 2);
|
||||
blank.writeUInt32BE(audioSsrc, 4);
|
||||
|
||||
this._socket.send(blank, 0, blank.length, port, address, (error, bytes) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { VoiceOpCodes } from "../voice/VoiceOpCodes.js";
|
||||
import { BaseMediaConnection } from "./BaseMediaConnection.js";
|
||||
|
||||
export class StreamConnection extends BaseMediaConnection {
|
||||
private _streamKey: string | null = null;
|
||||
private _serverId: string | null = null;
|
||||
|
||||
public override setSpeaking(speaking: boolean): void {
|
||||
if (!this.webRtcParams) throw new Error("WebRTC connection not ready");
|
||||
this.sendOpcode(VoiceOpCodes.SPEAKING, {
|
||||
delay: 0,
|
||||
speaking: speaking ? 2 : 0,
|
||||
ssrc: this.webRtcParams.audioSsrc,
|
||||
});
|
||||
}
|
||||
|
||||
public override get daveChannelId() {
|
||||
if (this._serverId === null)
|
||||
throw new Error("Server ID not set (this shouldn't happen)");
|
||||
const channelId = BigInt(this._serverId) - 1n;
|
||||
return channelId.toString();
|
||||
}
|
||||
|
||||
public override get serverId(): string | null {
|
||||
return this._serverId;
|
||||
}
|
||||
|
||||
public set serverId(id: string) {
|
||||
this._serverId = id;
|
||||
}
|
||||
|
||||
public get streamKey(): string | null {
|
||||
return this._streamKey;
|
||||
}
|
||||
public set streamKey(value: string) {
|
||||
this._streamKey = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { BaseMediaConnection } from "./BaseMediaConnection.js";
|
||||
import type { StreamConnection } from "./StreamConnection.js";
|
||||
|
||||
export class VoiceConnection extends BaseMediaConnection {
|
||||
public streamConnection?: StreamConnection;
|
||||
|
||||
public override get daveChannelId() {
|
||||
return this.channelId;
|
||||
}
|
||||
|
||||
public override get serverId(): string {
|
||||
return this.guildId ?? this.channelId; // for guild vc it is the guild id, for dm voice it is the channel id
|
||||
}
|
||||
|
||||
public override stop(): void {
|
||||
super.stop();
|
||||
this.streamConnection?.stop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import type { VoiceOpCodes } from "./VoiceOpCodes.js";
|
||||
import type { SupportedEncryptionModes } from "../../utils.js";
|
||||
|
||||
type StreamInfo = {
|
||||
active: boolean;
|
||||
quality: number;
|
||||
rid: string;
|
||||
ssrc: number;
|
||||
rtx_ssrc: number;
|
||||
/**
|
||||
* always "video" from what I observed
|
||||
*/
|
||||
type: string;
|
||||
};
|
||||
|
||||
type SimulcastInfo = {
|
||||
type: string;
|
||||
rid: string;
|
||||
quality: number;
|
||||
};
|
||||
|
||||
type CodecPayloadType =
|
||||
| {
|
||||
name: string;
|
||||
type: "audio";
|
||||
priority: number;
|
||||
payload_type: number;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
type: "video";
|
||||
priority: number;
|
||||
payload_type: number;
|
||||
rtx_payload_type: number;
|
||||
encode: boolean;
|
||||
decode: boolean;
|
||||
};
|
||||
|
||||
export namespace Message {
|
||||
// Request messages
|
||||
export type Identify = {
|
||||
server_id: string;
|
||||
user_id: string;
|
||||
session_id: string;
|
||||
token: string;
|
||||
video: boolean;
|
||||
streams: SimulcastInfo[];
|
||||
max_dave_protocol_version?: number;
|
||||
};
|
||||
|
||||
export type Resume = {
|
||||
server_id: string;
|
||||
session_id: string;
|
||||
token: string;
|
||||
seq_ack: number;
|
||||
};
|
||||
|
||||
export type Heartbeat = {
|
||||
t: number;
|
||||
seq_ack?: number;
|
||||
};
|
||||
|
||||
export type SelectProtocol =
|
||||
| {
|
||||
protocol: "udp";
|
||||
codecs: CodecPayloadType[];
|
||||
data: {
|
||||
address: string;
|
||||
port: number;
|
||||
mode: SupportedEncryptionModes;
|
||||
};
|
||||
}
|
||||
| {
|
||||
protocol: "webrtc";
|
||||
codecs: CodecPayloadType[];
|
||||
data: string;
|
||||
sdp: string;
|
||||
rtc_connection_id: string;
|
||||
};
|
||||
|
||||
export type Video = {
|
||||
audio_ssrc: number;
|
||||
video_ssrc: number;
|
||||
rtx_ssrc: number;
|
||||
streams: {
|
||||
type: "video";
|
||||
rid: string;
|
||||
ssrc: number;
|
||||
active: boolean;
|
||||
quality: number;
|
||||
rtx_ssrc: number;
|
||||
max_bitrate: number;
|
||||
max_framerate: number;
|
||||
max_resolution: {
|
||||
type: "fixed";
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
// Response messages
|
||||
export type Hello = {
|
||||
heartbeat_interval: number;
|
||||
};
|
||||
|
||||
export type Ready = {
|
||||
ssrc: number;
|
||||
ip: string;
|
||||
port: number;
|
||||
modes: SupportedEncryptionModes[];
|
||||
experiments: string[];
|
||||
streams: StreamInfo[];
|
||||
};
|
||||
|
||||
export type Speaking = {
|
||||
speaking: 0 | 1 | 2;
|
||||
delay: number;
|
||||
ssrc: number;
|
||||
};
|
||||
|
||||
export type SelectProtocolAck = {
|
||||
audio_codec: string;
|
||||
video_codec: string;
|
||||
dave_protocol_version: number;
|
||||
} & (
|
||||
| {
|
||||
secret_key: number[];
|
||||
mode: string;
|
||||
}
|
||||
| {
|
||||
media_session_id: number;
|
||||
sdp: string;
|
||||
}
|
||||
);
|
||||
|
||||
export type HeartbeatAck = {
|
||||
t: number;
|
||||
};
|
||||
|
||||
export type ClientsConnect = {
|
||||
user_ids: string[];
|
||||
};
|
||||
|
||||
export type ClientDisconnect = {
|
||||
user_id: string;
|
||||
};
|
||||
|
||||
export type DavePrepareTransition = {
|
||||
transition_id: number;
|
||||
protocol_version: number;
|
||||
};
|
||||
|
||||
export type DaveExecuteTransition = {
|
||||
transition_id: number;
|
||||
};
|
||||
|
||||
export type DaveTransitionReady = {
|
||||
transition_id: number;
|
||||
};
|
||||
|
||||
export type DavePrepareEpoch = {
|
||||
epoch: number;
|
||||
protocol_version: number;
|
||||
};
|
||||
|
||||
export type MlsInvalidCommitWelcome = {
|
||||
transition_id: number;
|
||||
};
|
||||
}
|
||||
|
||||
export namespace GatewayResponse {
|
||||
type Generic<
|
||||
Op extends VoiceOpCodes,
|
||||
T extends Record<string, unknown> | null,
|
||||
> = {
|
||||
op: Op;
|
||||
d: T;
|
||||
seq?: number;
|
||||
};
|
||||
export type Hello = Generic<VoiceOpCodes.HELLO, Message.Hello>;
|
||||
export type Ready = Generic<VoiceOpCodes.READY, Message.Ready>;
|
||||
export type Resumed = Generic<VoiceOpCodes.RESUMED, null>;
|
||||
export type Speaking = Generic<VoiceOpCodes.SPEAKING, Message.Speaking>;
|
||||
export type SelectProtocolAck = Generic<
|
||||
VoiceOpCodes.SELECT_PROTOCOL_ACK,
|
||||
Message.SelectProtocolAck
|
||||
>;
|
||||
export type HeartbeatAck = Generic<
|
||||
VoiceOpCodes.HEARTBEAT_ACK,
|
||||
Message.HeartbeatAck
|
||||
>;
|
||||
export type ClientsConnect = Generic<
|
||||
VoiceOpCodes.CLIENTS_CONNECT,
|
||||
Message.ClientsConnect
|
||||
>;
|
||||
export type ClientDisconnect = Generic<
|
||||
VoiceOpCodes.CLIENT_DISCONNECT,
|
||||
Message.ClientDisconnect
|
||||
>;
|
||||
export type DavePrepareTransition = Generic<
|
||||
VoiceOpCodes.DAVE_PREPARE_TRANSITION,
|
||||
Message.DavePrepareTransition
|
||||
>;
|
||||
export type DaveExecuteTransition = Generic<
|
||||
VoiceOpCodes.DAVE_EXECUTE_TRANSITION,
|
||||
Message.DaveExecuteTransition
|
||||
>;
|
||||
export type DavePrepareEpoch = Generic<
|
||||
VoiceOpCodes.DAVE_PREPARE_EPOCH,
|
||||
Message.DavePrepareEpoch
|
||||
>;
|
||||
}
|
||||
|
||||
export type GatewayResponse =
|
||||
| GatewayResponse.Hello
|
||||
| GatewayResponse.Ready
|
||||
| GatewayResponse.Resumed
|
||||
| GatewayResponse.Speaking
|
||||
| GatewayResponse.SelectProtocolAck
|
||||
| GatewayResponse.HeartbeatAck
|
||||
| GatewayResponse.ClientsConnect
|
||||
| GatewayResponse.ClientDisconnect
|
||||
| GatewayResponse.DavePrepareTransition
|
||||
| GatewayResponse.DaveExecuteTransition
|
||||
| GatewayResponse.DavePrepareEpoch;
|
||||
|
||||
export namespace GatewayRequest {
|
||||
type Generic<
|
||||
Op extends VoiceOpCodes,
|
||||
T extends Record<string, unknown> | null,
|
||||
> = {
|
||||
op: Op;
|
||||
d: T;
|
||||
};
|
||||
export type Identify = Generic<VoiceOpCodes.IDENTIFY, Message.Identify>;
|
||||
export type Resume = Generic<VoiceOpCodes.RESUME, Message.Resume>;
|
||||
export type Heartbeat = Generic<VoiceOpCodes.HEARTBEAT, Message.Heartbeat>;
|
||||
export type SelectProtocol = Generic<
|
||||
VoiceOpCodes.SELECT_PROTOCOL,
|
||||
Message.SelectProtocol
|
||||
>;
|
||||
export type Video = Generic<VoiceOpCodes.VIDEO, Message.Video>;
|
||||
export type Speaking = Generic<VoiceOpCodes.SPEAKING, Message.Speaking>;
|
||||
export type DaveTransitionReady = Generic<
|
||||
VoiceOpCodes.DAVE_TRANSITION_READY,
|
||||
Message.DaveTransitionReady
|
||||
>;
|
||||
export type MlsInvalidCommitWelcome = Generic<
|
||||
VoiceOpCodes.MLS_INVALID_COMMIT_WELCOME,
|
||||
Message.MlsInvalidCommitWelcome
|
||||
>;
|
||||
}
|
||||
|
||||
export type GatewayRequest =
|
||||
| GatewayRequest.Identify
|
||||
| GatewayRequest.Resume
|
||||
| GatewayRequest.Heartbeat
|
||||
| GatewayRequest.SelectProtocol
|
||||
| GatewayRequest.Video
|
||||
| GatewayRequest.Speaking
|
||||
| GatewayRequest.DaveTransitionReady
|
||||
| GatewayRequest.MlsInvalidCommitWelcome;
|
||||
@@ -0,0 +1,36 @@
|
||||
export enum VoiceOpCodes {
|
||||
IDENTIFY = 0,
|
||||
SELECT_PROTOCOL = 1,
|
||||
READY = 2,
|
||||
HEARTBEAT = 3,
|
||||
SELECT_PROTOCOL_ACK = 4,
|
||||
SPEAKING = 5,
|
||||
HEARTBEAT_ACK = 6,
|
||||
RESUME = 7,
|
||||
HELLO = 8,
|
||||
RESUMED = 9,
|
||||
CLIENTS_CONNECT = 11,
|
||||
VIDEO = 12,
|
||||
CLIENT_DISCONNECT = 13,
|
||||
SESSION_UPDATE = 14,
|
||||
MEDIA_SINK_WANTS = 15,
|
||||
VOICE_BACKEND_VERSION = 16,
|
||||
CHANNEL_OPTIONS_UPDATE = 17,
|
||||
FLAGS = 18,
|
||||
SPEED_TEST = 19,
|
||||
PLATFORM = 20,
|
||||
DAVE_PREPARE_TRANSITION = 21,
|
||||
DAVE_EXECUTE_TRANSITION = 22,
|
||||
DAVE_TRANSITION_READY = 23,
|
||||
DAVE_PREPARE_EPOCH = 24,
|
||||
MLS_INVALID_COMMIT_WELCOME = 31,
|
||||
}
|
||||
|
||||
export enum VoiceOpCodesBinary {
|
||||
MLS_EXTERNAL_SENDER = 25,
|
||||
MLS_KEY_PACKAGE = 26,
|
||||
MLS_PROPOSALS = 27,
|
||||
MLS_COMMIT_WELCOME = 28,
|
||||
MLS_ANNOUNCE_COMMIT_TRANSITION = 29,
|
||||
MLS_WELCOME = 30,
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
PeerConnection,
|
||||
Audio,
|
||||
Video,
|
||||
PacingHandler,
|
||||
RtpPacketizer,
|
||||
H264RtpPacketizer,
|
||||
H265RtpPacketizer,
|
||||
AV1RtpPacketizer,
|
||||
RtpPacketizationConfig,
|
||||
RtcpNackResponder,
|
||||
RtcpSrReporter,
|
||||
type Track,
|
||||
} from "@lng2004/node-datachannel";
|
||||
import { Codec, MediaType } from "@snazzah/davey";
|
||||
import { CodecPayloadType } from "./CodecPayloadType.js";
|
||||
import { normalizeVideoCodec, type SupportedVideoCodec } from "../../utils.js";
|
||||
import {
|
||||
splitNalu,
|
||||
H264Helpers,
|
||||
H264NalUnitTypes,
|
||||
startCode3,
|
||||
} from "../processing/AnnexBHelper.js";
|
||||
import { rewriteSPSVUI } from "../processing/SPSVUIRewriter.js";
|
||||
import type { BaseMediaConnection } from "./BaseMediaConnection.js";
|
||||
|
||||
export class WebRtcConnWrapper {
|
||||
private _mediaConn: BaseMediaConnection;
|
||||
|
||||
private _webRtcConn?: PeerConnection;
|
||||
private _audioDef: Audio;
|
||||
private _videoDef: Video;
|
||||
private _audioTrack?: Track;
|
||||
private _videoTrack?: Track;
|
||||
private _audioPacketizer?: RtpPacketizer;
|
||||
private _videoPacketizer?: RtpPacketizer;
|
||||
private _videoCodec?: SupportedVideoCodec;
|
||||
|
||||
constructor(mediaConn: BaseMediaConnection) {
|
||||
this._mediaConn = mediaConn;
|
||||
this._audioDef = new Audio("0", "SendRecv");
|
||||
this._videoDef = new Video("1", "SendRecv");
|
||||
this._audioDef.addOpusCodec(CodecPayloadType.opus.payload_type);
|
||||
for (const {
|
||||
name,
|
||||
payload_type,
|
||||
rtx_payload_type,
|
||||
clockRate,
|
||||
} of Object.values(CodecPayloadType).filter((el) => el.type === "video")) {
|
||||
switch (name) {
|
||||
case "H264":
|
||||
this._videoDef.addH264Codec(payload_type);
|
||||
break;
|
||||
case "H265":
|
||||
this._videoDef.addH265Codec(payload_type);
|
||||
break;
|
||||
case "VP8":
|
||||
this._videoDef.addVP8Codec(payload_type);
|
||||
break;
|
||||
case "VP9":
|
||||
this._videoDef.addVP9Codec(payload_type);
|
||||
break;
|
||||
case "AV1":
|
||||
this._videoDef.addAV1Codec(payload_type);
|
||||
break;
|
||||
}
|
||||
this._videoDef.addRTXCodec(rtx_payload_type, payload_type, clockRate);
|
||||
}
|
||||
}
|
||||
|
||||
public initWebRtc() {
|
||||
this._webRtcConn = new PeerConnection("", {
|
||||
iceServers: ["stun:stun.l.google.com:19302"],
|
||||
});
|
||||
this._audioTrack = this._webRtcConn.addTrack(this._audioDef);
|
||||
this._videoTrack = this._webRtcConn.addTrack(this._videoDef);
|
||||
this._setMediaHandler();
|
||||
return this._webRtcConn;
|
||||
}
|
||||
|
||||
private _setMediaHandler() {
|
||||
if (this._audioPacketizer)
|
||||
this._audioTrack?.setMediaHandler(this._audioPacketizer);
|
||||
if (this._videoPacketizer)
|
||||
this._videoTrack?.setMediaHandler(this._videoPacketizer);
|
||||
}
|
||||
|
||||
public close() {
|
||||
this._webRtcConn?.close();
|
||||
}
|
||||
|
||||
public get webRtcConn() {
|
||||
return this._webRtcConn;
|
||||
}
|
||||
|
||||
public get ready() {
|
||||
return this._webRtcConn?.state() === "connected";
|
||||
}
|
||||
|
||||
public get mediaConnection() {
|
||||
return this._mediaConn;
|
||||
}
|
||||
|
||||
public sendAudioFrame(frame: Buffer, frametime: number) {
|
||||
if (!this.ready) return;
|
||||
if (!this._audioPacketizer) return;
|
||||
const { rtpConfig } = this._audioPacketizer;
|
||||
const { clockRate } = rtpConfig;
|
||||
if (this.mediaConnection.daveReady)
|
||||
frame = this.mediaConnection.daveSession!.encryptOpus(frame);
|
||||
this._audioTrack?.sendMessageBinary(frame);
|
||||
rtpConfig.timestamp += Math.round((frametime * clockRate) / 1000);
|
||||
}
|
||||
|
||||
public sendVideoFrame(frame: Buffer, frametime: number) {
|
||||
if (!this.ready) return;
|
||||
if (!this._videoPacketizer) return;
|
||||
const { rtpConfig } = this._videoPacketizer;
|
||||
const { clockRate } = rtpConfig;
|
||||
if (this._videoCodec === "H264") {
|
||||
let spsRewritten = false;
|
||||
const nalus = splitNalu(frame).map((el) => {
|
||||
if (H264Helpers.getUnitType(el) === H264NalUnitTypes.SPS) {
|
||||
spsRewritten = true;
|
||||
return rewriteSPSVUI(el);
|
||||
}
|
||||
return el;
|
||||
});
|
||||
if (spsRewritten)
|
||||
frame = Buffer.concat(nalus.flatMap((el) => [startCode3, el]));
|
||||
}
|
||||
if (this.mediaConnection.daveReady) {
|
||||
let daveCodec = Codec.UNKNOWN;
|
||||
switch (this._videoCodec) {
|
||||
case "H264":
|
||||
daveCodec = Codec.H264;
|
||||
break;
|
||||
case "H265":
|
||||
daveCodec = Codec.H265;
|
||||
break;
|
||||
case "VP8":
|
||||
daveCodec = Codec.VP8;
|
||||
break;
|
||||
case "VP9":
|
||||
daveCodec = Codec.VP9;
|
||||
break;
|
||||
case "AV1":
|
||||
daveCodec = Codec.AV1;
|
||||
break;
|
||||
}
|
||||
frame = this.mediaConnection.daveSession!.encrypt(
|
||||
MediaType.VIDEO,
|
||||
daveCodec,
|
||||
frame,
|
||||
);
|
||||
}
|
||||
this._videoTrack?.sendMessageBinary(frame);
|
||||
rtpConfig.timestamp += Math.round((frametime * clockRate) / 1000);
|
||||
}
|
||||
|
||||
public setPacketizer(videoCodec: string): void {
|
||||
if (!this.mediaConnection.webRtcParams)
|
||||
throw new Error("WebRTC connection not ready");
|
||||
const { audioSsrc, videoSsrc } = this.mediaConnection.webRtcParams;
|
||||
const rtpConfigAudio = new RtpPacketizationConfig(
|
||||
audioSsrc,
|
||||
"",
|
||||
CodecPayloadType.opus.payload_type,
|
||||
CodecPayloadType.opus.clockRate,
|
||||
);
|
||||
rtpConfigAudio.playoutDelayId = 5;
|
||||
rtpConfigAudio.playoutDelayMin = 0;
|
||||
rtpConfigAudio.playoutDelayMax = 1;
|
||||
this._audioPacketizer = new RtpPacketizer(rtpConfigAudio);
|
||||
this._audioPacketizer.addToChain(new RtcpSrReporter(rtpConfigAudio));
|
||||
this._audioPacketizer.addToChain(new RtcpNackResponder());
|
||||
|
||||
this._videoCodec = normalizeVideoCodec(videoCodec);
|
||||
const rtpConfigVideo = new RtpPacketizationConfig(
|
||||
videoSsrc,
|
||||
"",
|
||||
CodecPayloadType[this._videoCodec].payload_type,
|
||||
CodecPayloadType[this._videoCodec].clockRate,
|
||||
);
|
||||
rtpConfigVideo.playoutDelayId = 5;
|
||||
rtpConfigVideo.playoutDelayMin = 0;
|
||||
rtpConfigVideo.playoutDelayMax = 10;
|
||||
switch (this._videoCodec) {
|
||||
case "H264":
|
||||
this._videoPacketizer = new H264RtpPacketizer(
|
||||
"StartSequence",
|
||||
rtpConfigVideo,
|
||||
);
|
||||
break;
|
||||
case "H265":
|
||||
this._videoPacketizer = new H265RtpPacketizer(
|
||||
"StartSequence",
|
||||
rtpConfigVideo,
|
||||
);
|
||||
break;
|
||||
case "AV1":
|
||||
this._videoPacketizer = new AV1RtpPacketizer("Obu", rtpConfigVideo);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Packetizer not implemented for ${this._videoCodec}`);
|
||||
}
|
||||
this._videoPacketizer.addToChain(new RtcpSrReporter(rtpConfigVideo));
|
||||
this._videoPacketizer.addToChain(new RtcpNackResponder());
|
||||
this._videoPacketizer.addToChain(new PacingHandler(25 * 1000 * 1000, 1));
|
||||
|
||||
this._setMediaHandler();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./VoiceConnection.js";
|
||||
export * from "./VoiceOpCodes.js";
|
||||
// export * from './MediaUdp.js';
|
||||
export * from "./StreamConnection.js";
|
||||
export * from "./BaseMediaConnection.js";
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
export * from "./client/index.js";
|
||||
export * from "./media/index.js";
|
||||
export * as Utils from "./utils.js";
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BaseMediaStream } from "./BaseMediaStream.js";
|
||||
import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
|
||||
|
||||
export class AudioStream extends BaseMediaStream {
|
||||
private _conn: WebRtcConnWrapper;
|
||||
|
||||
constructor(conn: WebRtcConnWrapper, noSleep = false) {
|
||||
super("audio", noSleep);
|
||||
this._conn = conn;
|
||||
}
|
||||
|
||||
protected override async _sendFrame(
|
||||
frame: Buffer,
|
||||
frametime: number,
|
||||
): Promise<void> {
|
||||
this._conn.sendAudioFrame(frame, frametime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Log } from "debug-level";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
import { Writable } from "node:stream";
|
||||
import type { Packet } from "node-av";
|
||||
|
||||
export class BaseMediaStream extends Writable {
|
||||
private _pts?: number;
|
||||
private _syncTolerance = 20;
|
||||
private _loggerSend: Log;
|
||||
private _loggerSync: Log;
|
||||
private _loggerSleep: Log;
|
||||
|
||||
private _noSleep: boolean;
|
||||
private _startTime?: number;
|
||||
private _startPts?: number;
|
||||
private _sync = true;
|
||||
private _syncStream?: BaseMediaStream;
|
||||
|
||||
constructor(type: string, noSleep = false) {
|
||||
super({ objectMode: true, highWaterMark: 0 });
|
||||
this._loggerSend = new Log(`stream:${type}:send`);
|
||||
this._loggerSync = new Log(`stream:${type}:sync`);
|
||||
this._loggerSleep = new Log(`stream:${type}:sleep`);
|
||||
this._noSleep = noSleep;
|
||||
}
|
||||
|
||||
get sync(): boolean {
|
||||
return this._sync;
|
||||
}
|
||||
set sync(val: boolean) {
|
||||
this._sync = val;
|
||||
if (val) this._loggerSync.debug("Sync enabled");
|
||||
else this._loggerSync.debug("Sync disabled");
|
||||
}
|
||||
get syncStream() {
|
||||
return this._syncStream;
|
||||
}
|
||||
set syncStream(stream: BaseMediaStream | undefined) {
|
||||
if (stream !== undefined && this === stream.syncStream)
|
||||
throw new Error("Cannot sync 2 streams with eachother");
|
||||
this._syncStream = stream;
|
||||
}
|
||||
get noSleep(): boolean {
|
||||
return this._noSleep;
|
||||
}
|
||||
set noSleep(val: boolean) {
|
||||
this._noSleep = val;
|
||||
if (!val) this.resetTimingCompensation();
|
||||
}
|
||||
get pts(): number | undefined {
|
||||
return this._pts;
|
||||
}
|
||||
get syncTolerance() {
|
||||
return this._syncTolerance;
|
||||
}
|
||||
set syncTolerance(n: number) {
|
||||
if (n < 0) return;
|
||||
this._syncTolerance = n;
|
||||
}
|
||||
protected async _sendFrame(
|
||||
_frame: Buffer,
|
||||
_frametime: number,
|
||||
): Promise<void> {
|
||||
throw new Error("Not implemented");
|
||||
}
|
||||
private ptsDelta() {
|
||||
if (this.pts !== undefined && this.syncStream?.pts !== undefined)
|
||||
return this.pts - this.syncStream.pts;
|
||||
return undefined;
|
||||
}
|
||||
private isAhead() {
|
||||
const delta = this.ptsDelta();
|
||||
return (
|
||||
this.syncStream?.writableEnded === false &&
|
||||
delta !== undefined &&
|
||||
delta > this.syncTolerance
|
||||
);
|
||||
}
|
||||
private isBehind() {
|
||||
const delta = this.ptsDelta();
|
||||
return (
|
||||
this.syncStream?.writableEnded === false &&
|
||||
delta !== undefined &&
|
||||
delta < -this.syncTolerance
|
||||
);
|
||||
}
|
||||
private resetTimingCompensation() {
|
||||
this._startTime = this._startPts = undefined;
|
||||
}
|
||||
async _write(
|
||||
frame: Packet,
|
||||
_: BufferEncoding,
|
||||
callback: (error?: Error | null) => void,
|
||||
) {
|
||||
const { data, pts, duration, timeBase } = frame;
|
||||
if (!data) {
|
||||
frame.free();
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
const frametime = (Number(duration) / timeBase.den) * timeBase.num * 1000;
|
||||
|
||||
const start_sendFrame = performance.now();
|
||||
await this._sendFrame(Buffer.from(data), frametime);
|
||||
const end_sendFrame = performance.now();
|
||||
|
||||
this._pts = (Number(pts) / timeBase.den) * timeBase.num * 1000;
|
||||
this.emit("pts", this._pts);
|
||||
|
||||
const sendTime = end_sendFrame - start_sendFrame;
|
||||
const ratio = sendTime / frametime;
|
||||
this._loggerSend.debug(
|
||||
{
|
||||
stats: {
|
||||
pts: this._pts,
|
||||
frame_size: data.length,
|
||||
duration: sendTime,
|
||||
frametime,
|
||||
},
|
||||
},
|
||||
`Frame sent in ${sendTime.toFixed(2)}ms (${(ratio * 100).toFixed(2)}% frametime)`,
|
||||
);
|
||||
if (ratio > 1) {
|
||||
this._loggerSend.warn(
|
||||
{
|
||||
frame_size: data.length,
|
||||
duration: sendTime,
|
||||
frametime,
|
||||
},
|
||||
`Frame takes too long to send (${(ratio * 100).toFixed(2)}% frametime)`,
|
||||
);
|
||||
}
|
||||
|
||||
this._startTime ??= start_sendFrame;
|
||||
this._startPts ??= this._pts;
|
||||
const sleep = Math.max(
|
||||
0,
|
||||
this._pts -
|
||||
this._startPts +
|
||||
frametime -
|
||||
(end_sendFrame - this._startTime),
|
||||
);
|
||||
if (this._noSleep || sleep === 0) {
|
||||
callback(null);
|
||||
} else if (this.sync && this.isBehind()) {
|
||||
this._loggerSync.debug(
|
||||
{
|
||||
stats: {
|
||||
pts: this.pts,
|
||||
pts_other: this.syncStream?.pts,
|
||||
},
|
||||
},
|
||||
"Stream is behind. Not sleeping for this frame",
|
||||
);
|
||||
this.resetTimingCompensation();
|
||||
callback(null);
|
||||
} else if (this.sync && this.isAhead()) {
|
||||
do {
|
||||
this._loggerSync.debug(
|
||||
{
|
||||
stats: {
|
||||
pts: this.pts,
|
||||
pts_other: this.syncStream?.pts,
|
||||
frametime,
|
||||
},
|
||||
},
|
||||
`Stream is ahead. Waiting for ${frametime}ms`,
|
||||
);
|
||||
await setTimeout(frametime);
|
||||
} while (this.sync && this.isAhead());
|
||||
this.resetTimingCompensation();
|
||||
callback(null);
|
||||
} else {
|
||||
this._loggerSleep.debug(
|
||||
{
|
||||
stats: {
|
||||
pts: this._pts,
|
||||
startPts: this._startPts,
|
||||
time: end_sendFrame,
|
||||
startTime: this._startTime,
|
||||
frametime,
|
||||
},
|
||||
},
|
||||
`Sleeping for ${sleep}ms`,
|
||||
);
|
||||
setTimeout(sleep).then(() => callback(null));
|
||||
}
|
||||
frame.free();
|
||||
}
|
||||
_destroy(
|
||||
error: Error | null,
|
||||
callback: (error?: Error | null) => void,
|
||||
): void {
|
||||
super._destroy(error, callback);
|
||||
this.syncStream = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
// https://ffmpeg.org/doxygen/7.0/codec__id_8h_source.html
|
||||
|
||||
export enum AVCodecID {
|
||||
AV_CODEC_ID_NONE,
|
||||
|
||||
/* video codecs */
|
||||
AV_CODEC_ID_MPEG1VIDEO,
|
||||
AV_CODEC_ID_MPEG2VIDEO, ///< preferred ID for MPEG-1/2 video decoding
|
||||
AV_CODEC_ID_H261,
|
||||
AV_CODEC_ID_H263,
|
||||
AV_CODEC_ID_RV10,
|
||||
AV_CODEC_ID_RV20,
|
||||
AV_CODEC_ID_MJPEG,
|
||||
AV_CODEC_ID_MJPEGB,
|
||||
AV_CODEC_ID_LJPEG,
|
||||
AV_CODEC_ID_SP5X,
|
||||
AV_CODEC_ID_JPEGLS,
|
||||
AV_CODEC_ID_MPEG4,
|
||||
AV_CODEC_ID_RAWVIDEO,
|
||||
AV_CODEC_ID_MSMPEG4V1,
|
||||
AV_CODEC_ID_MSMPEG4V2,
|
||||
AV_CODEC_ID_MSMPEG4V3,
|
||||
AV_CODEC_ID_WMV1,
|
||||
AV_CODEC_ID_WMV2,
|
||||
AV_CODEC_ID_H263P,
|
||||
AV_CODEC_ID_H263I,
|
||||
AV_CODEC_ID_FLV1,
|
||||
AV_CODEC_ID_SVQ1,
|
||||
AV_CODEC_ID_SVQ3,
|
||||
AV_CODEC_ID_DVVIDEO,
|
||||
AV_CODEC_ID_HUFFYUV,
|
||||
AV_CODEC_ID_CYUV,
|
||||
AV_CODEC_ID_H264,
|
||||
AV_CODEC_ID_INDEO3,
|
||||
AV_CODEC_ID_VP3,
|
||||
AV_CODEC_ID_THEORA,
|
||||
AV_CODEC_ID_ASV1,
|
||||
AV_CODEC_ID_ASV2,
|
||||
AV_CODEC_ID_FFV1,
|
||||
AV_CODEC_ID_4XM,
|
||||
AV_CODEC_ID_VCR1,
|
||||
AV_CODEC_ID_CLJR,
|
||||
AV_CODEC_ID_MDEC,
|
||||
AV_CODEC_ID_ROQ,
|
||||
AV_CODEC_ID_INTERPLAY_VIDEO,
|
||||
AV_CODEC_ID_XAN_WC3,
|
||||
AV_CODEC_ID_XAN_WC4,
|
||||
AV_CODEC_ID_RPZA,
|
||||
AV_CODEC_ID_CINEPAK,
|
||||
AV_CODEC_ID_WS_VQA,
|
||||
AV_CODEC_ID_MSRLE,
|
||||
AV_CODEC_ID_MSVIDEO1,
|
||||
AV_CODEC_ID_IDCIN,
|
||||
AV_CODEC_ID_8BPS,
|
||||
AV_CODEC_ID_SMC,
|
||||
AV_CODEC_ID_FLIC,
|
||||
AV_CODEC_ID_TRUEMOTION1,
|
||||
AV_CODEC_ID_VMDVIDEO,
|
||||
AV_CODEC_ID_MSZH,
|
||||
AV_CODEC_ID_ZLIB,
|
||||
AV_CODEC_ID_QTRLE,
|
||||
AV_CODEC_ID_TSCC,
|
||||
AV_CODEC_ID_ULTI,
|
||||
AV_CODEC_ID_QDRAW,
|
||||
AV_CODEC_ID_VIXL,
|
||||
AV_CODEC_ID_QPEG,
|
||||
AV_CODEC_ID_PNG,
|
||||
AV_CODEC_ID_PPM,
|
||||
AV_CODEC_ID_PBM,
|
||||
AV_CODEC_ID_PGM,
|
||||
AV_CODEC_ID_PGMYUV,
|
||||
AV_CODEC_ID_PAM,
|
||||
AV_CODEC_ID_FFVHUFF,
|
||||
AV_CODEC_ID_RV30,
|
||||
AV_CODEC_ID_RV40,
|
||||
AV_CODEC_ID_VC1,
|
||||
AV_CODEC_ID_WMV3,
|
||||
AV_CODEC_ID_LOCO,
|
||||
AV_CODEC_ID_WNV1,
|
||||
AV_CODEC_ID_AASC,
|
||||
AV_CODEC_ID_INDEO2,
|
||||
AV_CODEC_ID_FRAPS,
|
||||
AV_CODEC_ID_TRUEMOTION2,
|
||||
AV_CODEC_ID_BMP,
|
||||
AV_CODEC_ID_CSCD,
|
||||
AV_CODEC_ID_MMVIDEO,
|
||||
AV_CODEC_ID_ZMBV,
|
||||
AV_CODEC_ID_AVS,
|
||||
AV_CODEC_ID_SMACKVIDEO,
|
||||
AV_CODEC_ID_NUV,
|
||||
AV_CODEC_ID_KMVC,
|
||||
AV_CODEC_ID_FLASHSV,
|
||||
AV_CODEC_ID_CAVS,
|
||||
AV_CODEC_ID_JPEG2000,
|
||||
AV_CODEC_ID_VMNC,
|
||||
AV_CODEC_ID_VP5,
|
||||
AV_CODEC_ID_VP6,
|
||||
AV_CODEC_ID_VP6F,
|
||||
AV_CODEC_ID_TARGA,
|
||||
AV_CODEC_ID_DSICINVIDEO,
|
||||
AV_CODEC_ID_TIERTEXSEQVIDEO,
|
||||
AV_CODEC_ID_TIFF,
|
||||
AV_CODEC_ID_GIF,
|
||||
AV_CODEC_ID_DXA,
|
||||
AV_CODEC_ID_DNXHD,
|
||||
AV_CODEC_ID_THP,
|
||||
AV_CODEC_ID_SGI,
|
||||
AV_CODEC_ID_C93,
|
||||
AV_CODEC_ID_BETHSOFTVID,
|
||||
AV_CODEC_ID_PTX,
|
||||
AV_CODEC_ID_TXD,
|
||||
AV_CODEC_ID_VP6A,
|
||||
AV_CODEC_ID_AMV,
|
||||
AV_CODEC_ID_VB,
|
||||
AV_CODEC_ID_PCX,
|
||||
AV_CODEC_ID_SUNRAST,
|
||||
AV_CODEC_ID_INDEO4,
|
||||
AV_CODEC_ID_INDEO5,
|
||||
AV_CODEC_ID_MIMIC,
|
||||
AV_CODEC_ID_RL2,
|
||||
AV_CODEC_ID_ESCAPE124,
|
||||
AV_CODEC_ID_DIRAC,
|
||||
AV_CODEC_ID_BFI,
|
||||
AV_CODEC_ID_CMV,
|
||||
AV_CODEC_ID_MOTIONPIXELS,
|
||||
AV_CODEC_ID_TGV,
|
||||
AV_CODEC_ID_TGQ,
|
||||
AV_CODEC_ID_TQI,
|
||||
AV_CODEC_ID_AURA,
|
||||
AV_CODEC_ID_AURA2,
|
||||
AV_CODEC_ID_V210X,
|
||||
AV_CODEC_ID_TMV,
|
||||
AV_CODEC_ID_V210,
|
||||
AV_CODEC_ID_DPX,
|
||||
AV_CODEC_ID_MAD,
|
||||
AV_CODEC_ID_FRWU,
|
||||
AV_CODEC_ID_FLASHSV2,
|
||||
AV_CODEC_ID_CDGRAPHICS,
|
||||
AV_CODEC_ID_R210,
|
||||
AV_CODEC_ID_ANM,
|
||||
AV_CODEC_ID_BINKVIDEO,
|
||||
AV_CODEC_ID_IFF_ILBM,
|
||||
AV_CODEC_ID_IFF_BYTERUN1 = AV_CODEC_ID_IFF_ILBM,
|
||||
AV_CODEC_ID_KGV1,
|
||||
AV_CODEC_ID_YOP,
|
||||
AV_CODEC_ID_VP8,
|
||||
AV_CODEC_ID_PICTOR,
|
||||
AV_CODEC_ID_ANSI,
|
||||
AV_CODEC_ID_A64_MULTI,
|
||||
AV_CODEC_ID_A64_MULTI5,
|
||||
AV_CODEC_ID_R10K,
|
||||
AV_CODEC_ID_MXPEG,
|
||||
AV_CODEC_ID_LAGARITH,
|
||||
AV_CODEC_ID_PRORES,
|
||||
AV_CODEC_ID_JV,
|
||||
AV_CODEC_ID_DFA,
|
||||
AV_CODEC_ID_WMV3IMAGE,
|
||||
AV_CODEC_ID_VC1IMAGE,
|
||||
AV_CODEC_ID_UTVIDEO,
|
||||
AV_CODEC_ID_BMV_VIDEO,
|
||||
AV_CODEC_ID_VBLE,
|
||||
AV_CODEC_ID_DXTORY,
|
||||
AV_CODEC_ID_V410,
|
||||
AV_CODEC_ID_XWD,
|
||||
AV_CODEC_ID_CDXL,
|
||||
AV_CODEC_ID_XBM,
|
||||
AV_CODEC_ID_ZEROCODEC,
|
||||
AV_CODEC_ID_MSS1,
|
||||
AV_CODEC_ID_MSA1,
|
||||
AV_CODEC_ID_TSCC2,
|
||||
AV_CODEC_ID_MTS2,
|
||||
AV_CODEC_ID_CLLC,
|
||||
AV_CODEC_ID_MSS2,
|
||||
AV_CODEC_ID_VP9,
|
||||
AV_CODEC_ID_AIC,
|
||||
AV_CODEC_ID_ESCAPE130,
|
||||
AV_CODEC_ID_G2M,
|
||||
AV_CODEC_ID_WEBP,
|
||||
AV_CODEC_ID_HNM4_VIDEO,
|
||||
AV_CODEC_ID_HEVC,
|
||||
AV_CODEC_ID_H265 = AV_CODEC_ID_HEVC,
|
||||
AV_CODEC_ID_FIC,
|
||||
AV_CODEC_ID_ALIAS_PIX,
|
||||
AV_CODEC_ID_BRENDER_PIX,
|
||||
AV_CODEC_ID_PAF_VIDEO,
|
||||
AV_CODEC_ID_EXR,
|
||||
AV_CODEC_ID_VP7,
|
||||
AV_CODEC_ID_SANM,
|
||||
AV_CODEC_ID_SGIRLE,
|
||||
AV_CODEC_ID_MVC1,
|
||||
AV_CODEC_ID_MVC2,
|
||||
AV_CODEC_ID_HQX,
|
||||
AV_CODEC_ID_TDSC,
|
||||
AV_CODEC_ID_HQ_HQA,
|
||||
AV_CODEC_ID_HAP,
|
||||
AV_CODEC_ID_DDS,
|
||||
AV_CODEC_ID_DXV,
|
||||
AV_CODEC_ID_SCREENPRESSO,
|
||||
AV_CODEC_ID_RSCC,
|
||||
AV_CODEC_ID_AVS2,
|
||||
AV_CODEC_ID_PGX,
|
||||
AV_CODEC_ID_AVS3,
|
||||
AV_CODEC_ID_MSP2,
|
||||
AV_CODEC_ID_VVC,
|
||||
AV_CODEC_ID_H266 = AV_CODEC_ID_VVC,
|
||||
AV_CODEC_ID_Y41P,
|
||||
AV_CODEC_ID_AVRP,
|
||||
AV_CODEC_ID_012V,
|
||||
AV_CODEC_ID_AVUI,
|
||||
AV_CODEC_ID_TARGA_Y216,
|
||||
AV_CODEC_ID_V308,
|
||||
AV_CODEC_ID_V408,
|
||||
AV_CODEC_ID_YUV4,
|
||||
AV_CODEC_ID_AVRN,
|
||||
AV_CODEC_ID_CPIA,
|
||||
AV_CODEC_ID_XFACE,
|
||||
AV_CODEC_ID_SNOW,
|
||||
AV_CODEC_ID_SMVJPEG,
|
||||
AV_CODEC_ID_APNG,
|
||||
AV_CODEC_ID_DAALA,
|
||||
AV_CODEC_ID_CFHD,
|
||||
AV_CODEC_ID_TRUEMOTION2RT,
|
||||
AV_CODEC_ID_M101,
|
||||
AV_CODEC_ID_MAGICYUV,
|
||||
AV_CODEC_ID_SHEERVIDEO,
|
||||
AV_CODEC_ID_YLC,
|
||||
AV_CODEC_ID_PSD,
|
||||
AV_CODEC_ID_PIXLET,
|
||||
AV_CODEC_ID_SPEEDHQ,
|
||||
AV_CODEC_ID_FMVC,
|
||||
AV_CODEC_ID_SCPR,
|
||||
AV_CODEC_ID_CLEARVIDEO,
|
||||
AV_CODEC_ID_XPM,
|
||||
AV_CODEC_ID_AV1,
|
||||
AV_CODEC_ID_BITPACKED,
|
||||
AV_CODEC_ID_MSCC,
|
||||
AV_CODEC_ID_SRGC,
|
||||
AV_CODEC_ID_SVG,
|
||||
AV_CODEC_ID_GDV,
|
||||
AV_CODEC_ID_FITS,
|
||||
AV_CODEC_ID_IMM4,
|
||||
AV_CODEC_ID_PROSUMER,
|
||||
AV_CODEC_ID_MWSC,
|
||||
AV_CODEC_ID_WCMV,
|
||||
AV_CODEC_ID_RASC,
|
||||
AV_CODEC_ID_HYMT,
|
||||
AV_CODEC_ID_ARBC,
|
||||
AV_CODEC_ID_AGM,
|
||||
AV_CODEC_ID_LSCR,
|
||||
AV_CODEC_ID_VP4,
|
||||
AV_CODEC_ID_IMM5,
|
||||
AV_CODEC_ID_MVDV,
|
||||
AV_CODEC_ID_MVHA,
|
||||
AV_CODEC_ID_CDTOONS,
|
||||
AV_CODEC_ID_MV30,
|
||||
AV_CODEC_ID_NOTCHLC,
|
||||
AV_CODEC_ID_PFM,
|
||||
AV_CODEC_ID_MOBICLIP,
|
||||
AV_CODEC_ID_PHOTOCD,
|
||||
AV_CODEC_ID_IPU,
|
||||
AV_CODEC_ID_ARGO,
|
||||
AV_CODEC_ID_CRI,
|
||||
AV_CODEC_ID_SIMBIOSIS_IMX,
|
||||
AV_CODEC_ID_SGA_VIDEO,
|
||||
AV_CODEC_ID_GEM,
|
||||
AV_CODEC_ID_VBN,
|
||||
AV_CODEC_ID_JPEGXL,
|
||||
AV_CODEC_ID_QOI,
|
||||
AV_CODEC_ID_PHM,
|
||||
AV_CODEC_ID_RADIANCE_HDR,
|
||||
AV_CODEC_ID_WBMP,
|
||||
AV_CODEC_ID_MEDIA100,
|
||||
AV_CODEC_ID_VQC,
|
||||
AV_CODEC_ID_PDV,
|
||||
AV_CODEC_ID_EVC,
|
||||
AV_CODEC_ID_RTV1,
|
||||
AV_CODEC_ID_VMIX,
|
||||
AV_CODEC_ID_LEAD,
|
||||
|
||||
/* various PCM "codecs" */
|
||||
AV_CODEC_ID_FIRST_AUDIO = 0x10000, ///< A dummy id pointing at the start of audio codecs
|
||||
AV_CODEC_ID_PCM_S16LE = 0x10000,
|
||||
AV_CODEC_ID_PCM_S16BE,
|
||||
AV_CODEC_ID_PCM_U16LE,
|
||||
AV_CODEC_ID_PCM_U16BE,
|
||||
AV_CODEC_ID_PCM_S8,
|
||||
AV_CODEC_ID_PCM_U8,
|
||||
AV_CODEC_ID_PCM_MULAW,
|
||||
AV_CODEC_ID_PCM_ALAW,
|
||||
AV_CODEC_ID_PCM_S32LE,
|
||||
AV_CODEC_ID_PCM_S32BE,
|
||||
AV_CODEC_ID_PCM_U32LE,
|
||||
AV_CODEC_ID_PCM_U32BE,
|
||||
AV_CODEC_ID_PCM_S24LE,
|
||||
AV_CODEC_ID_PCM_S24BE,
|
||||
AV_CODEC_ID_PCM_U24LE,
|
||||
AV_CODEC_ID_PCM_U24BE,
|
||||
AV_CODEC_ID_PCM_S24DAUD,
|
||||
AV_CODEC_ID_PCM_ZORK,
|
||||
AV_CODEC_ID_PCM_S16LE_PLANAR,
|
||||
AV_CODEC_ID_PCM_DVD,
|
||||
AV_CODEC_ID_PCM_F32BE,
|
||||
AV_CODEC_ID_PCM_F32LE,
|
||||
AV_CODEC_ID_PCM_F64BE,
|
||||
AV_CODEC_ID_PCM_F64LE,
|
||||
AV_CODEC_ID_PCM_BLURAY,
|
||||
AV_CODEC_ID_PCM_LXF,
|
||||
AV_CODEC_ID_S302M,
|
||||
AV_CODEC_ID_PCM_S8_PLANAR,
|
||||
AV_CODEC_ID_PCM_S24LE_PLANAR,
|
||||
AV_CODEC_ID_PCM_S32LE_PLANAR,
|
||||
AV_CODEC_ID_PCM_S16BE_PLANAR,
|
||||
AV_CODEC_ID_PCM_S64LE,
|
||||
AV_CODEC_ID_PCM_S64BE,
|
||||
AV_CODEC_ID_PCM_F16LE,
|
||||
AV_CODEC_ID_PCM_F24LE,
|
||||
AV_CODEC_ID_PCM_VIDC,
|
||||
AV_CODEC_ID_PCM_SGA,
|
||||
|
||||
/* various ADPCM codecs */
|
||||
AV_CODEC_ID_ADPCM_IMA_QT = 0x11000,
|
||||
AV_CODEC_ID_ADPCM_IMA_WAV,
|
||||
AV_CODEC_ID_ADPCM_IMA_DK3,
|
||||
AV_CODEC_ID_ADPCM_IMA_DK4,
|
||||
AV_CODEC_ID_ADPCM_IMA_WS,
|
||||
AV_CODEC_ID_ADPCM_IMA_SMJPEG,
|
||||
AV_CODEC_ID_ADPCM_MS,
|
||||
AV_CODEC_ID_ADPCM_4XM,
|
||||
AV_CODEC_ID_ADPCM_XA,
|
||||
AV_CODEC_ID_ADPCM_ADX,
|
||||
AV_CODEC_ID_ADPCM_EA,
|
||||
AV_CODEC_ID_ADPCM_G726,
|
||||
AV_CODEC_ID_ADPCM_CT,
|
||||
AV_CODEC_ID_ADPCM_SWF,
|
||||
AV_CODEC_ID_ADPCM_YAMAHA,
|
||||
AV_CODEC_ID_ADPCM_SBPRO_4,
|
||||
AV_CODEC_ID_ADPCM_SBPRO_3,
|
||||
AV_CODEC_ID_ADPCM_SBPRO_2,
|
||||
AV_CODEC_ID_ADPCM_THP,
|
||||
AV_CODEC_ID_ADPCM_IMA_AMV,
|
||||
AV_CODEC_ID_ADPCM_EA_R1,
|
||||
AV_CODEC_ID_ADPCM_EA_R3,
|
||||
AV_CODEC_ID_ADPCM_EA_R2,
|
||||
AV_CODEC_ID_ADPCM_IMA_EA_SEAD,
|
||||
AV_CODEC_ID_ADPCM_IMA_EA_EACS,
|
||||
AV_CODEC_ID_ADPCM_EA_XAS,
|
||||
AV_CODEC_ID_ADPCM_EA_MAXIS_XA,
|
||||
AV_CODEC_ID_ADPCM_IMA_ISS,
|
||||
AV_CODEC_ID_ADPCM_G722,
|
||||
AV_CODEC_ID_ADPCM_IMA_APC,
|
||||
AV_CODEC_ID_ADPCM_VIMA,
|
||||
AV_CODEC_ID_ADPCM_AFC,
|
||||
AV_CODEC_ID_ADPCM_IMA_OKI,
|
||||
AV_CODEC_ID_ADPCM_DTK,
|
||||
AV_CODEC_ID_ADPCM_IMA_RAD,
|
||||
AV_CODEC_ID_ADPCM_G726LE,
|
||||
AV_CODEC_ID_ADPCM_THP_LE,
|
||||
AV_CODEC_ID_ADPCM_PSX,
|
||||
AV_CODEC_ID_ADPCM_AICA,
|
||||
AV_CODEC_ID_ADPCM_IMA_DAT4,
|
||||
AV_CODEC_ID_ADPCM_MTAF,
|
||||
AV_CODEC_ID_ADPCM_AGM,
|
||||
AV_CODEC_ID_ADPCM_ARGO,
|
||||
AV_CODEC_ID_ADPCM_IMA_SSI,
|
||||
AV_CODEC_ID_ADPCM_ZORK,
|
||||
AV_CODEC_ID_ADPCM_IMA_APM,
|
||||
AV_CODEC_ID_ADPCM_IMA_ALP,
|
||||
AV_CODEC_ID_ADPCM_IMA_MTF,
|
||||
AV_CODEC_ID_ADPCM_IMA_CUNNING,
|
||||
AV_CODEC_ID_ADPCM_IMA_MOFLEX,
|
||||
AV_CODEC_ID_ADPCM_IMA_ACORN,
|
||||
AV_CODEC_ID_ADPCM_XMD,
|
||||
|
||||
/* AMR */
|
||||
AV_CODEC_ID_AMR_NB = 0x12000,
|
||||
AV_CODEC_ID_AMR_WB,
|
||||
|
||||
/* RealAudio codecs*/
|
||||
AV_CODEC_ID_RA_144 = 0x13000,
|
||||
AV_CODEC_ID_RA_288,
|
||||
|
||||
/* various DPCM codecs */
|
||||
AV_CODEC_ID_ROQ_DPCM = 0x14000,
|
||||
AV_CODEC_ID_INTERPLAY_DPCM,
|
||||
AV_CODEC_ID_XAN_DPCM,
|
||||
AV_CODEC_ID_SOL_DPCM,
|
||||
AV_CODEC_ID_SDX2_DPCM,
|
||||
AV_CODEC_ID_GREMLIN_DPCM,
|
||||
AV_CODEC_ID_DERF_DPCM,
|
||||
AV_CODEC_ID_WADY_DPCM,
|
||||
AV_CODEC_ID_CBD2_DPCM,
|
||||
|
||||
/* audio codecs */
|
||||
AV_CODEC_ID_MP2 = 0x15000,
|
||||
AV_CODEC_ID_MP3, ///< preferred ID for decoding MPEG audio layer 1, 2 or 3
|
||||
AV_CODEC_ID_AAC,
|
||||
AV_CODEC_ID_AC3,
|
||||
AV_CODEC_ID_DTS,
|
||||
AV_CODEC_ID_VORBIS,
|
||||
AV_CODEC_ID_DVAUDIO,
|
||||
AV_CODEC_ID_WMAV1,
|
||||
AV_CODEC_ID_WMAV2,
|
||||
AV_CODEC_ID_MACE3,
|
||||
AV_CODEC_ID_MACE6,
|
||||
AV_CODEC_ID_VMDAUDIO,
|
||||
AV_CODEC_ID_FLAC,
|
||||
AV_CODEC_ID_MP3ADU,
|
||||
AV_CODEC_ID_MP3ON4,
|
||||
AV_CODEC_ID_SHORTEN,
|
||||
AV_CODEC_ID_ALAC,
|
||||
AV_CODEC_ID_WESTWOOD_SND1,
|
||||
AV_CODEC_ID_GSM, ///< as in Berlin toast format
|
||||
AV_CODEC_ID_QDM2,
|
||||
AV_CODEC_ID_COOK,
|
||||
AV_CODEC_ID_TRUESPEECH,
|
||||
AV_CODEC_ID_TTA,
|
||||
AV_CODEC_ID_SMACKAUDIO,
|
||||
AV_CODEC_ID_QCELP,
|
||||
AV_CODEC_ID_WAVPACK,
|
||||
AV_CODEC_ID_DSICINAUDIO,
|
||||
AV_CODEC_ID_IMC,
|
||||
AV_CODEC_ID_MUSEPACK7,
|
||||
AV_CODEC_ID_MLP,
|
||||
AV_CODEC_ID_GSM_MS /* as found in WAV */,
|
||||
AV_CODEC_ID_ATRAC3,
|
||||
AV_CODEC_ID_APE,
|
||||
AV_CODEC_ID_NELLYMOSER,
|
||||
AV_CODEC_ID_MUSEPACK8,
|
||||
AV_CODEC_ID_SPEEX,
|
||||
AV_CODEC_ID_WMAVOICE,
|
||||
AV_CODEC_ID_WMAPRO,
|
||||
AV_CODEC_ID_WMALOSSLESS,
|
||||
AV_CODEC_ID_ATRAC3P,
|
||||
AV_CODEC_ID_EAC3,
|
||||
AV_CODEC_ID_SIPR,
|
||||
AV_CODEC_ID_MP1,
|
||||
AV_CODEC_ID_TWINVQ,
|
||||
AV_CODEC_ID_TRUEHD,
|
||||
AV_CODEC_ID_MP4ALS,
|
||||
AV_CODEC_ID_ATRAC1,
|
||||
AV_CODEC_ID_BINKAUDIO_RDFT,
|
||||
AV_CODEC_ID_BINKAUDIO_DCT,
|
||||
AV_CODEC_ID_AAC_LATM,
|
||||
AV_CODEC_ID_QDMC,
|
||||
AV_CODEC_ID_CELT,
|
||||
AV_CODEC_ID_G723_1,
|
||||
AV_CODEC_ID_G729,
|
||||
AV_CODEC_ID_8SVX_EXP,
|
||||
AV_CODEC_ID_8SVX_FIB,
|
||||
AV_CODEC_ID_BMV_AUDIO,
|
||||
AV_CODEC_ID_RALF,
|
||||
AV_CODEC_ID_IAC,
|
||||
AV_CODEC_ID_ILBC,
|
||||
AV_CODEC_ID_OPUS,
|
||||
AV_CODEC_ID_COMFORT_NOISE,
|
||||
AV_CODEC_ID_TAK,
|
||||
AV_CODEC_ID_METASOUND,
|
||||
AV_CODEC_ID_PAF_AUDIO,
|
||||
AV_CODEC_ID_ON2AVC,
|
||||
AV_CODEC_ID_DSS_SP,
|
||||
AV_CODEC_ID_CODEC2,
|
||||
AV_CODEC_ID_FFWAVESYNTH,
|
||||
AV_CODEC_ID_SONIC,
|
||||
AV_CODEC_ID_SONIC_LS,
|
||||
AV_CODEC_ID_EVRC,
|
||||
AV_CODEC_ID_SMV,
|
||||
AV_CODEC_ID_DSD_LSBF,
|
||||
AV_CODEC_ID_DSD_MSBF,
|
||||
AV_CODEC_ID_DSD_LSBF_PLANAR,
|
||||
AV_CODEC_ID_DSD_MSBF_PLANAR,
|
||||
AV_CODEC_ID_4GV,
|
||||
AV_CODEC_ID_INTERPLAY_ACM,
|
||||
AV_CODEC_ID_XMA1,
|
||||
AV_CODEC_ID_XMA2,
|
||||
AV_CODEC_ID_DST,
|
||||
AV_CODEC_ID_ATRAC3AL,
|
||||
AV_CODEC_ID_ATRAC3PAL,
|
||||
AV_CODEC_ID_DOLBY_E,
|
||||
AV_CODEC_ID_APTX,
|
||||
AV_CODEC_ID_APTX_HD,
|
||||
AV_CODEC_ID_SBC,
|
||||
AV_CODEC_ID_ATRAC9,
|
||||
AV_CODEC_ID_HCOM,
|
||||
AV_CODEC_ID_ACELP_KELVIN,
|
||||
AV_CODEC_ID_MPEGH_3D_AUDIO,
|
||||
AV_CODEC_ID_SIREN,
|
||||
AV_CODEC_ID_HCA,
|
||||
AV_CODEC_ID_FASTAUDIO,
|
||||
AV_CODEC_ID_MSNSIREN,
|
||||
AV_CODEC_ID_DFPWM,
|
||||
AV_CODEC_ID_BONK,
|
||||
AV_CODEC_ID_MISC4,
|
||||
AV_CODEC_ID_APAC,
|
||||
AV_CODEC_ID_FTR,
|
||||
AV_CODEC_ID_WAVARC,
|
||||
AV_CODEC_ID_RKA,
|
||||
AV_CODEC_ID_AC4,
|
||||
AV_CODEC_ID_OSQ,
|
||||
AV_CODEC_ID_QOA,
|
||||
|
||||
/* subtitle codecs */
|
||||
AV_CODEC_ID_FIRST_SUBTITLE = 0x17000, ///< A dummy ID pointing at the start of subtitle codecs.
|
||||
AV_CODEC_ID_DVD_SUBTITLE = 0x17000,
|
||||
AV_CODEC_ID_DVB_SUBTITLE,
|
||||
AV_CODEC_ID_TEXT, ///< raw UTF-8 text
|
||||
AV_CODEC_ID_XSUB,
|
||||
AV_CODEC_ID_SSA,
|
||||
AV_CODEC_ID_MOV_TEXT,
|
||||
AV_CODEC_ID_HDMV_PGS_SUBTITLE,
|
||||
AV_CODEC_ID_DVB_TELETEXT,
|
||||
AV_CODEC_ID_SRT,
|
||||
AV_CODEC_ID_MICRODVD,
|
||||
AV_CODEC_ID_EIA_608,
|
||||
AV_CODEC_ID_JACOSUB,
|
||||
AV_CODEC_ID_SAMI,
|
||||
AV_CODEC_ID_REALTEXT,
|
||||
AV_CODEC_ID_STL,
|
||||
AV_CODEC_ID_SUBVIEWER1,
|
||||
AV_CODEC_ID_SUBVIEWER,
|
||||
AV_CODEC_ID_SUBRIP,
|
||||
AV_CODEC_ID_WEBVTT,
|
||||
AV_CODEC_ID_MPL2,
|
||||
AV_CODEC_ID_VPLAYER,
|
||||
AV_CODEC_ID_PJS,
|
||||
AV_CODEC_ID_ASS,
|
||||
AV_CODEC_ID_HDMV_TEXT_SUBTITLE,
|
||||
AV_CODEC_ID_TTML,
|
||||
AV_CODEC_ID_ARIB_CAPTION,
|
||||
|
||||
/* other specific kind of codecs (generally used for attachments) */
|
||||
AV_CODEC_ID_FIRST_UNKNOWN = 0x18000, ///< A dummy ID pointing at the start of various fake codecs.
|
||||
AV_CODEC_ID_TTF = 0x18000,
|
||||
|
||||
AV_CODEC_ID_SCTE_35, ///< Contain timestamp estimated through PCR of program stream.
|
||||
AV_CODEC_ID_EPG,
|
||||
AV_CODEC_ID_BINTEXT,
|
||||
AV_CODEC_ID_XBIN,
|
||||
AV_CODEC_ID_IDF,
|
||||
AV_CODEC_ID_OTF,
|
||||
AV_CODEC_ID_SMPTE_KLV,
|
||||
AV_CODEC_ID_DVD_NAV,
|
||||
AV_CODEC_ID_TIMED_ID3,
|
||||
AV_CODEC_ID_BIN_DATA,
|
||||
AV_CODEC_ID_SMPTE_2038,
|
||||
|
||||
AV_CODEC_ID_PROBE = 0x19000, ///< codec_id is not known (like AV_CODEC_ID_NONE) but lavf should attempt to identify it
|
||||
|
||||
AV_CODEC_ID_MPEG2TS = 0x20000 /**< _FAKE_ codec to indicate a raw MPEG-2 TS
|
||||
* stream (only used by libavformat) */,
|
||||
AV_CODEC_ID_MPEG4SYSTEMS = 0x20001 /**< _FAKE_ codec to indicate a MPEG-4 Systems
|
||||
* stream (only used by libavformat) */,
|
||||
AV_CODEC_ID_FFMETADATA = 0x21000, ///< Dummy codec for streams containing only metadata information.
|
||||
AV_CODEC_ID_WRAPPED_AVFRAME = 0x21001, ///< Passthrough codec, AVFrames wrapped in AVPacket
|
||||
/**
|
||||
* Dummy null video codec, useful mainly for development and debugging.
|
||||
* Null encoder/decoder discard all input and never return any output.
|
||||
*/
|
||||
AV_CODEC_ID_VNULL,
|
||||
/**
|
||||
* Dummy null audio codec, useful mainly for development and debugging.
|
||||
* Null encoder/decoder discard all input and never return any output.
|
||||
*/
|
||||
AV_CODEC_ID_ANULL,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Decoder,
|
||||
FilterAPI,
|
||||
type Frame,
|
||||
type Packet,
|
||||
type Stream,
|
||||
} from "node-av";
|
||||
|
||||
export async function createDecoder(stream: Stream) {
|
||||
const decoder = await Decoder.create(stream);
|
||||
let freed = false;
|
||||
let serializer: Promise<unknown> | null = null;
|
||||
const serialize = <T>(f: () => Promise<T>) => {
|
||||
let p: Promise<T>;
|
||||
if (serializer) {
|
||||
p = serializer.catch(() => {}).then(() => f());
|
||||
} else {
|
||||
p = f();
|
||||
}
|
||||
serializer = p = p.finally(() => {
|
||||
if (serializer === p) serializer = null;
|
||||
});
|
||||
return p;
|
||||
};
|
||||
const filter = FilterAPI.create("format=pix_fmts=rgba");
|
||||
return {
|
||||
decode: async (packets: Packet) => {
|
||||
if (freed) return [];
|
||||
|
||||
return serialize(async () => {
|
||||
const frames = await decoder.decodeAll(packets);
|
||||
let filtered: Frame[] = [];
|
||||
for (const frame of frames) {
|
||||
filtered = [...filtered, ...(await filter.processAll(frame))];
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
},
|
||||
free: () => {
|
||||
freed = true;
|
||||
return serialize(async () => {
|
||||
decoder.close();
|
||||
filter.close();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import pDebounce from "p-debounce";
|
||||
import {
|
||||
BitStreamFilterAPI,
|
||||
Demuxer,
|
||||
avGetCodecName,
|
||||
type Stream,
|
||||
} from "node-av";
|
||||
import { Log } from "debug-level";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { AVCodecID } from "./LibavCodecId.js";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { CodecParameters, Packet } from "node-av";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
type MediaStreamInfoCommon = {
|
||||
index: number;
|
||||
codec: AVCodecID;
|
||||
codecpar: CodecParameters;
|
||||
avStream: Stream;
|
||||
};
|
||||
|
||||
export type VideoStreamInfo = MediaStreamInfoCommon & {
|
||||
width: number;
|
||||
height: number;
|
||||
framerate_num: number;
|
||||
framerate_den: number;
|
||||
};
|
||||
export type AudioStreamInfo = MediaStreamInfoCommon & {
|
||||
sample_rate: number;
|
||||
};
|
||||
|
||||
const allowedVideoCodec = new Set([
|
||||
AVCodecID.AV_CODEC_ID_H264,
|
||||
AVCodecID.AV_CODEC_ID_H265,
|
||||
AVCodecID.AV_CODEC_ID_VP8,
|
||||
AVCodecID.AV_CODEC_ID_VP9,
|
||||
AVCodecID.AV_CODEC_ID_AV1,
|
||||
]);
|
||||
|
||||
const allowedAudioCodec = new Set([AVCodecID.AV_CODEC_ID_OPUS]);
|
||||
|
||||
function parseOpusPacketDuration(frame: Uint8Array) {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6716#section-3.1
|
||||
const frameSizes = [
|
||||
// SILK only, narrow band
|
||||
10, 20, 40, 60,
|
||||
|
||||
// SILK only, medium band
|
||||
10, 20, 40, 60,
|
||||
|
||||
// SILK only, wide band
|
||||
10, 20, 40, 60,
|
||||
|
||||
// Hybrid, super wide band
|
||||
10, 20,
|
||||
|
||||
// Hybrid, full band
|
||||
10, 20,
|
||||
|
||||
// CELT only, narrow band
|
||||
2.5, 5, 10, 20,
|
||||
|
||||
// CELT only, wide band
|
||||
2.5, 5, 10, 20,
|
||||
|
||||
// CELT only, super wide band
|
||||
2.5, 5, 10, 20,
|
||||
|
||||
// CELT only, full band
|
||||
2.5, 5, 10, 20,
|
||||
];
|
||||
|
||||
const frameSize = (48000 / 1000) * frameSizes[frame[0] >> 3];
|
||||
|
||||
let frameCount = 0;
|
||||
const c = frame[0] & 0b11;
|
||||
switch (c) {
|
||||
case 0:
|
||||
frameCount = 1;
|
||||
break;
|
||||
|
||||
case 1:
|
||||
case 2:
|
||||
frameCount = 2;
|
||||
break;
|
||||
|
||||
case 3:
|
||||
frameCount = frame[1] & 0b111111;
|
||||
break;
|
||||
}
|
||||
|
||||
return frameSize * frameCount;
|
||||
}
|
||||
|
||||
type DemuxerOptions = {
|
||||
format: "matroska" | "nut";
|
||||
};
|
||||
|
||||
export async function demux(input: Readable, { format }: DemuxerOptions) {
|
||||
const loggerFormat = new Log("demux:format");
|
||||
const loggerFrameCommon = new Log("demux:frame:common");
|
||||
const loggerFrameVideo = new Log("demux:frame:video");
|
||||
const loggerFrameAudio = new Log("demux:frame:audio");
|
||||
|
||||
const filename = randomUUID();
|
||||
const demuxer = await Demuxer.open(input, {
|
||||
options: {
|
||||
fflags: "nobuffer",
|
||||
},
|
||||
format,
|
||||
bufferSize: 8192,
|
||||
});
|
||||
|
||||
const cleanup = () => {
|
||||
input.destroy();
|
||||
demuxer.close();
|
||||
vPipe.off("drain", readFrame);
|
||||
aPipe.off("drain", readFrame);
|
||||
vPipe.end();
|
||||
aPipe.end();
|
||||
vbsf.forEach((e) => {
|
||||
e.close();
|
||||
});
|
||||
};
|
||||
|
||||
const vStream = demuxer.video();
|
||||
const aStream = demuxer.audio();
|
||||
|
||||
let vInfo: VideoStreamInfo | undefined;
|
||||
let aInfo: AudioStreamInfo | undefined;
|
||||
const vPipe = new PassThrough({
|
||||
objectMode: true,
|
||||
writableHighWaterMark: 128,
|
||||
});
|
||||
const aPipe = new PassThrough({
|
||||
objectMode: true,
|
||||
writableHighWaterMark: 128,
|
||||
});
|
||||
|
||||
const vbsf: BitStreamFilterAPI[] = [];
|
||||
if (vStream) {
|
||||
const codecId = vStream.codecpar.codecId;
|
||||
if (!allowedVideoCodec.has(codecId)) {
|
||||
const codecName = avGetCodecName(codecId);
|
||||
cleanup();
|
||||
throw new Error(`Video codec ${codecName} is not allowed`);
|
||||
}
|
||||
try {
|
||||
switch (codecId) {
|
||||
case AVCodecID.AV_CODEC_ID_H264:
|
||||
vbsf.push(BitStreamFilterAPI.create("h264_mp4toannexb", vStream));
|
||||
vbsf.push(
|
||||
BitStreamFilterAPI.create("h264_metadata", vStream, {
|
||||
options: {
|
||||
aud: "remove",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vbsf.push(BitStreamFilterAPI.create("dump_extra", vStream));
|
||||
break;
|
||||
case AVCodecID.AV_CODEC_ID_HEVC:
|
||||
vbsf.push(BitStreamFilterAPI.create("hevc_mp4toannexb", vStream));
|
||||
vbsf.push(
|
||||
BitStreamFilterAPI.create("hevc_metadata", vStream, {
|
||||
options: {
|
||||
aud: "remove",
|
||||
},
|
||||
}),
|
||||
);
|
||||
vbsf.push(BitStreamFilterAPI.create("dump_extra", vStream));
|
||||
break;
|
||||
default:
|
||||
vbsf.push(BitStreamFilterAPI.create("null", vStream));
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw new Error(`Failed to construct bitstream filterchain`, {
|
||||
cause: (e as Error).cause,
|
||||
});
|
||||
}
|
||||
|
||||
const codecpar = vbsf.at(-1)?.outputCodecParameters ?? vStream.codecpar;
|
||||
vInfo = {
|
||||
index: vStream.index,
|
||||
codec: codecId,
|
||||
codecpar,
|
||||
width: codecpar.width ?? 0,
|
||||
height: codecpar.height ?? 0,
|
||||
framerate_num: codecpar.frameRate.num,
|
||||
framerate_den: codecpar.frameRate.den,
|
||||
avStream: vStream,
|
||||
};
|
||||
loggerFormat.info(
|
||||
{
|
||||
info: vInfo,
|
||||
},
|
||||
`Found video stream in input ${filename}`,
|
||||
);
|
||||
}
|
||||
if (aStream) {
|
||||
const codecId = aStream.codecpar.codecId;
|
||||
if (!allowedAudioCodec.has(codecId)) {
|
||||
const codecName = avGetCodecName(codecId);
|
||||
cleanup();
|
||||
throw new Error(`Audio codec ${codecName} is not allowed`);
|
||||
}
|
||||
aInfo = {
|
||||
index: aStream.index,
|
||||
codec: codecId,
|
||||
codecpar: aStream.codecpar,
|
||||
sample_rate: aStream.codecpar.sampleRate || 0,
|
||||
avStream: aStream,
|
||||
};
|
||||
loggerFormat.info(
|
||||
{
|
||||
info: aInfo,
|
||||
},
|
||||
`Found audio stream in input ${filename}`,
|
||||
);
|
||||
}
|
||||
|
||||
const packetIterator = demuxer.packets();
|
||||
const applyBitStreamFilters = async (
|
||||
input: Packet | null,
|
||||
filters: BitStreamFilterAPI[],
|
||||
) => {
|
||||
let packets = [input];
|
||||
for (const filter of filters) {
|
||||
let newPackets: (Packet | null)[] = [];
|
||||
for (const packet of packets) {
|
||||
newPackets = [...newPackets, ...(await filter.filterAll(packet))];
|
||||
packet?.free();
|
||||
}
|
||||
if (!input) newPackets.push(null);
|
||||
packets = newPackets;
|
||||
}
|
||||
return packets;
|
||||
};
|
||||
const readFrame = pDebounce.promise(async () => {
|
||||
let resume = true;
|
||||
while (resume) {
|
||||
try {
|
||||
const { value: inPacket, done } = await packetIterator.next();
|
||||
if (done) {
|
||||
loggerFrameCommon.info("Reached end of stream. Stopping");
|
||||
const packets = await applyBitStreamFilters(null, vbsf);
|
||||
for (const packet of packets) {
|
||||
if (packet) vPipe.write(packet);
|
||||
}
|
||||
cleanup();
|
||||
return;
|
||||
} else if (inPacket) {
|
||||
const streamIndex = inPacket.streamIndex;
|
||||
if (vInfo && vInfo.index === streamIndex) {
|
||||
loggerFrameVideo.trace("Received a video packet");
|
||||
const packets = await applyBitStreamFilters(inPacket.clone(), vbsf);
|
||||
for (const packet of packets) {
|
||||
if (packet) resume &&= vPipe.write(packet);
|
||||
}
|
||||
} else if (aInfo && aInfo.index === streamIndex) {
|
||||
const packet = inPacket.clone()!;
|
||||
packet.duration ||= BigInt(parseOpusPacketDuration(packet.data!));
|
||||
resume &&= aPipe.write(packet);
|
||||
}
|
||||
inPacket.free();
|
||||
}
|
||||
} catch (e) {
|
||||
loggerFrameCommon.info(
|
||||
{ error: e },
|
||||
"Received an error during frame extraction. Stopping",
|
||||
);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
vPipe.on("drain", () => {
|
||||
loggerFrameVideo.trace("Video pipe drained");
|
||||
readFrame();
|
||||
});
|
||||
aPipe.on("drain", () => {
|
||||
loggerFrameAudio.trace("Audio pipe drained");
|
||||
readFrame();
|
||||
});
|
||||
readFrame();
|
||||
return {
|
||||
video: vInfo ? { ...vInfo, stream: vPipe as Readable } : undefined,
|
||||
audio: aInfo ? { ...aInfo, stream: aPipe as Readable } : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BaseMediaStream } from "./BaseMediaStream.js";
|
||||
import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
|
||||
|
||||
export class VideoStream extends BaseMediaStream {
|
||||
private _conn: WebRtcConnWrapper;
|
||||
constructor(conn: WebRtcConnWrapper, noSleep = false) {
|
||||
super("video", noSleep);
|
||||
this._conn = conn;
|
||||
}
|
||||
|
||||
protected override async _sendFrame(
|
||||
frame: Buffer,
|
||||
frametime: number,
|
||||
): Promise<void> {
|
||||
this._conn.sendVideoFrame(frame, frametime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { SupportedVideoCodec } from "../../utils.js";
|
||||
|
||||
export type EncoderSettings = {
|
||||
name: string;
|
||||
options: string[];
|
||||
globalOptions?: string[];
|
||||
outFilters?: string[];
|
||||
};
|
||||
|
||||
export type EncoderSettingsGetter = (
|
||||
bitrate: number,
|
||||
bitrateMax: number,
|
||||
) => Partial<Record<SupportedVideoCodec, EncoderSettings>>;
|
||||
|
||||
import { software } from "./software.js";
|
||||
import { nvenc } from "./nvenc.js";
|
||||
import { vaapi } from "./vaapi.js";
|
||||
import { merge } from "./merge.js";
|
||||
|
||||
const Encoders = {
|
||||
software,
|
||||
nvenc,
|
||||
vaapi,
|
||||
merge,
|
||||
};
|
||||
|
||||
export { Encoders };
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { EncoderSettingsGetter } from "./index.js";
|
||||
import type { SupportedVideoCodec } from "../../utils.js";
|
||||
|
||||
export const merge = (
|
||||
encoder: Partial<Record<SupportedVideoCodec, EncoderSettingsGetter>>,
|
||||
) => {
|
||||
return ((bitrate, bitrateMax) => ({
|
||||
H264: encoder.H264?.(bitrate, bitrateMax),
|
||||
H265: encoder.H265?.(bitrate, bitrateMax),
|
||||
VP8: encoder.VP8?.(bitrate, bitrateMax),
|
||||
VP9: encoder.VP9?.(bitrate, bitrateMax),
|
||||
AV1: encoder.AV1?.(bitrate, bitrateMax),
|
||||
})) as EncoderSettingsGetter;
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { EncoderSettingsGetter } from "./index.js";
|
||||
|
||||
type NvencPreset = "p1" | "p2" | "p3" | "p4" | "p5" | "p6" | "p7";
|
||||
|
||||
type NvencSettings = {
|
||||
preset: NvencPreset;
|
||||
spatialAq: boolean;
|
||||
temporalAq: boolean;
|
||||
gpu: number;
|
||||
};
|
||||
|
||||
export function nvenc({
|
||||
preset = "p4",
|
||||
spatialAq = false,
|
||||
temporalAq = false,
|
||||
gpu,
|
||||
}: Partial<NvencSettings> = {}) {
|
||||
const options = [
|
||||
`-preset ${preset}`,
|
||||
`-spatial-aq ${spatialAq}`,
|
||||
`-temporal-aq ${temporalAq}`,
|
||||
...(gpu !== undefined ? [`-gpu ${gpu}`] : []),
|
||||
];
|
||||
return (() => ({
|
||||
H264: {
|
||||
name: "h264_nvenc",
|
||||
options,
|
||||
},
|
||||
H265: {
|
||||
name: "hevc_nvenc",
|
||||
options,
|
||||
},
|
||||
AV1: {
|
||||
name: "av1_nvenc",
|
||||
options,
|
||||
},
|
||||
})) as EncoderSettingsGetter;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { EncoderSettingsGetter } from "./index.js";
|
||||
|
||||
type DeepPartial<T> = T extends unknown[]
|
||||
? T
|
||||
: { [P in keyof T]?: DeepPartial<T[P]> };
|
||||
type x26xPreset =
|
||||
| "ultrafast"
|
||||
| "superfast"
|
||||
| "veryfast"
|
||||
| "faster"
|
||||
| "fast"
|
||||
| "medium"
|
||||
| "slow"
|
||||
| "slower"
|
||||
| "veryslow"
|
||||
| "placebo";
|
||||
|
||||
export type SoftwareEncoderSettings = {
|
||||
x264: {
|
||||
preset: x26xPreset;
|
||||
tune:
|
||||
| "film"
|
||||
| "animation"
|
||||
| "grain"
|
||||
| "stillimage"
|
||||
| "fastdecode"
|
||||
| "zerolatency"
|
||||
| "psnr"
|
||||
| "ssim";
|
||||
};
|
||||
x265: {
|
||||
preset: x26xPreset;
|
||||
tune:
|
||||
| "psnr"
|
||||
| "ssim"
|
||||
| "grain"
|
||||
| "fastdecode"
|
||||
| "zerolatency"
|
||||
| "animation";
|
||||
};
|
||||
};
|
||||
|
||||
export const software = ({
|
||||
x264,
|
||||
x265,
|
||||
}: DeepPartial<SoftwareEncoderSettings> = {}) => {
|
||||
const { preset: x264Preset = "superfast", tune: x264Tune = "film" } =
|
||||
x264 ?? {};
|
||||
const { preset: x265Preset = "superfast", tune: x265Tune } = x265 ?? {};
|
||||
return (() => ({
|
||||
H264: {
|
||||
name: "libx264",
|
||||
options: ["-forced-idr 1", `-tune ${x264Tune}`, `-preset ${x264Preset}`],
|
||||
},
|
||||
H265: {
|
||||
name: "libx265",
|
||||
options: [
|
||||
"-forced-idr 1",
|
||||
...(x265Tune ? [`-tune ${x265Tune}`] : []),
|
||||
`-preset ${x265Preset}`,
|
||||
],
|
||||
},
|
||||
VP8: {
|
||||
name: "libvpx",
|
||||
options: ["-deadline 20000"],
|
||||
},
|
||||
VP9: {
|
||||
name: "libvpx-vp9",
|
||||
options: ["-deadline 20000"],
|
||||
},
|
||||
AV1: {
|
||||
name: "libsvtav1",
|
||||
options: [],
|
||||
},
|
||||
})) as EncoderSettingsGetter;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { EncoderSettingsGetter } from "./index.js";
|
||||
|
||||
type VaapiSettings = {
|
||||
device?: string;
|
||||
};
|
||||
|
||||
export function vaapi({
|
||||
device = "/dev/dri/renderD128",
|
||||
}: Partial<VaapiSettings> = {}) {
|
||||
const props = {
|
||||
options: [],
|
||||
globalOptions: ["-vaapi_device", device],
|
||||
outFilters: ["format=nv12|vaapi", "hwupload"],
|
||||
};
|
||||
return (() => ({
|
||||
H264: {
|
||||
name: "h264_vaapi",
|
||||
...props,
|
||||
},
|
||||
H265: {
|
||||
name: "hevc_vaapi",
|
||||
...props,
|
||||
},
|
||||
AV1: {
|
||||
name: "av1_vaapi",
|
||||
...props,
|
||||
},
|
||||
})) as EncoderSettingsGetter;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./LibavDemuxer.js";
|
||||
export * from "./newApi.js";
|
||||
export * as NewApi from "./newApi.js";
|
||||
export * from "./encoders/index.js";
|
||||
+653
@@ -0,0 +1,653 @@
|
||||
import pDebounce from "p-debounce";
|
||||
import sharp from "sharp";
|
||||
import Log from "debug-level";
|
||||
import { FFmpegCommand } from "fluent-ffmpeg-simplified";
|
||||
import { type Packet, AV_PKT_FLAG_KEY } from "node-av";
|
||||
import { PassThrough, type Readable } from "node:stream";
|
||||
import { demux } from "./LibavDemuxer.js";
|
||||
import { VideoStream } from "./VideoStream.js";
|
||||
import { AudioStream } from "./AudioStream.js";
|
||||
import { isBun, isDeno, isFiniteNonZero } from "../utils.js";
|
||||
import { AVCodecID } from "./LibavCodecId.js";
|
||||
import { createDecoder } from "./LibavDecoder.js";
|
||||
import { Encoders } from "./encoders/index.js";
|
||||
|
||||
import type { Request } from "zeromq";
|
||||
import type { SupportedVideoCodec } from "../utils.js";
|
||||
import type { Streamer } from "../client/index.js";
|
||||
import type { EncoderSettingsGetter } from "./encoders/index.js";
|
||||
import type { VideoStreamInfo } from "./LibavDemuxer.js";
|
||||
import type { WebRtcConnWrapper } from "../client/voice/WebRtcWrapper.js";
|
||||
|
||||
export type PrepareStreamOptions = {
|
||||
/**
|
||||
* Disable video transcoding
|
||||
* If enabled, all video related settings have no effects, and the input
|
||||
* video stream is used as-is.
|
||||
*
|
||||
* You need to ensure that the video stream has the right properties
|
||||
* (keyframe every 1s, B-frames disabled). Failure to do so will result in
|
||||
* a glitchy stream, or degraded performance
|
||||
*/
|
||||
noTranscoding: boolean;
|
||||
|
||||
/**
|
||||
* Video width
|
||||
*/
|
||||
width: number;
|
||||
|
||||
/**
|
||||
* Video height
|
||||
*/
|
||||
height: number;
|
||||
|
||||
/**
|
||||
* Video frame rate
|
||||
*/
|
||||
frameRate?: number;
|
||||
|
||||
/**
|
||||
* Video codec
|
||||
*/
|
||||
videoCodec: SupportedVideoCodec;
|
||||
|
||||
/**
|
||||
* Video average bitrate in kbps
|
||||
*/
|
||||
bitrateVideo: number;
|
||||
|
||||
/**
|
||||
* Video max bitrate in kbps
|
||||
*/
|
||||
bitrateVideoMax: number;
|
||||
|
||||
/**
|
||||
* Audio bitrate in kbps
|
||||
*/
|
||||
bitrateAudio: number;
|
||||
|
||||
/**
|
||||
* Enable audio output
|
||||
*/
|
||||
includeAudio: boolean;
|
||||
|
||||
/**
|
||||
* Functions to get encoder settings
|
||||
* This function will receive the average and max bitrate as the input, and
|
||||
* returns an object containing encoder settings for the supported codecs
|
||||
*/
|
||||
encoder: EncoderSettingsGetter;
|
||||
|
||||
/**
|
||||
* Enable hardware accelerated decoding
|
||||
*/
|
||||
hardwareAcceleratedDecoding: boolean;
|
||||
|
||||
/**
|
||||
* Add some options to minimize latency
|
||||
*/
|
||||
minimizeLatency: boolean;
|
||||
|
||||
/**
|
||||
* Custom headers for HTTP requests
|
||||
*/
|
||||
customHeaders: Record<string, string>;
|
||||
|
||||
/**
|
||||
* Custom input options to pass directly to ffmpeg
|
||||
* These will be added to the command before other options
|
||||
*/
|
||||
customInputOptions: string[];
|
||||
|
||||
/**
|
||||
* Custom ffmpeg flags/options to pass directly to ffmpeg
|
||||
* These will be added to the command after other options
|
||||
*/
|
||||
customFfmpegFlags: string[];
|
||||
|
||||
/**
|
||||
* FFmpeg log level
|
||||
*/
|
||||
logLevel:
|
||||
| "quiet"
|
||||
| "panic"
|
||||
| "fatal"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "verbose"
|
||||
| "debug"
|
||||
| "trace";
|
||||
};
|
||||
|
||||
export type Controller = {
|
||||
volume: number;
|
||||
setVolume(newVolume: number): Promise<boolean>;
|
||||
};
|
||||
|
||||
export function prepareStream(
|
||||
input: string | Readable,
|
||||
options: Partial<PrepareStreamOptions> = {},
|
||||
cancelSignal?: AbortSignal,
|
||||
) {
|
||||
cancelSignal?.throwIfAborted();
|
||||
|
||||
const logger = new Log("prepareStream");
|
||||
const loggerFFmpeg = new Log("prepareStream:ffmpeg");
|
||||
const defaultOptions = {
|
||||
noTranscoding: false,
|
||||
// negative values = resize by aspect ratio, see https://trac.ffmpeg.org/wiki/Scaling
|
||||
width: -2,
|
||||
height: -2,
|
||||
frameRate: undefined,
|
||||
videoCodec: "H264",
|
||||
bitrateVideo: 5000,
|
||||
bitrateVideoMax: 7000,
|
||||
bitrateAudio: 128,
|
||||
includeAudio: true,
|
||||
encoder: Encoders.software(),
|
||||
hardwareAcceleratedDecoding: false,
|
||||
minimizeLatency: false,
|
||||
customHeaders: {
|
||||
"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",
|
||||
},
|
||||
customInputOptions: [],
|
||||
customFfmpegFlags: [],
|
||||
logLevel: "verbose",
|
||||
} satisfies PrepareStreamOptions;
|
||||
|
||||
function mergeOptions(opts: Partial<PrepareStreamOptions>) {
|
||||
return {
|
||||
noTranscoding: opts.noTranscoding ?? defaultOptions.noTranscoding,
|
||||
|
||||
width: isFiniteNonZero(opts.width)
|
||||
? Math.round(opts.width)
|
||||
: defaultOptions.width,
|
||||
|
||||
height: isFiniteNonZero(opts.height)
|
||||
? Math.round(opts.height)
|
||||
: defaultOptions.height,
|
||||
|
||||
frameRate:
|
||||
isFiniteNonZero(opts.frameRate) && opts.frameRate > 0
|
||||
? opts.frameRate
|
||||
: defaultOptions.frameRate,
|
||||
|
||||
videoCodec: opts.videoCodec ?? defaultOptions.videoCodec,
|
||||
|
||||
bitrateVideo:
|
||||
isFiniteNonZero(opts.bitrateVideo) && opts.bitrateVideo > 0
|
||||
? Math.round(opts.bitrateVideo)
|
||||
: defaultOptions.bitrateVideo,
|
||||
|
||||
bitrateVideoMax:
|
||||
isFiniteNonZero(opts.bitrateVideoMax) && opts.bitrateVideoMax > 0
|
||||
? Math.round(opts.bitrateVideoMax)
|
||||
: defaultOptions.bitrateVideoMax,
|
||||
|
||||
bitrateAudio:
|
||||
isFiniteNonZero(opts.bitrateAudio) && opts.bitrateAudio > 0
|
||||
? Math.round(opts.bitrateAudio)
|
||||
: defaultOptions.bitrateAudio,
|
||||
|
||||
encoder: opts.encoder ?? defaultOptions.encoder,
|
||||
|
||||
includeAudio: opts.includeAudio ?? defaultOptions.includeAudio,
|
||||
|
||||
hardwareAcceleratedDecoding:
|
||||
opts.hardwareAcceleratedDecoding ??
|
||||
defaultOptions.hardwareAcceleratedDecoding,
|
||||
|
||||
minimizeLatency: opts.minimizeLatency ?? defaultOptions.minimizeLatency,
|
||||
|
||||
customHeaders: {
|
||||
...defaultOptions.customHeaders,
|
||||
...opts.customHeaders,
|
||||
},
|
||||
|
||||
customInputOptions:
|
||||
opts.customInputOptions ?? defaultOptions.customInputOptions,
|
||||
|
||||
customFfmpegFlags:
|
||||
opts.customFfmpegFlags ?? defaultOptions.customFfmpegFlags,
|
||||
|
||||
logLevel: opts.logLevel ?? defaultOptions.logLevel,
|
||||
} satisfies PrepareStreamOptions;
|
||||
}
|
||||
|
||||
const mergedOptions = mergeOptions(options);
|
||||
|
||||
let isHttpUrl = false;
|
||||
let isHls = false;
|
||||
let isSrt = false;
|
||||
|
||||
if (typeof input === "string") {
|
||||
isHttpUrl = input.startsWith("http") || input.startsWith("https");
|
||||
isHls = input.includes("m3u");
|
||||
isSrt = input.startsWith("srt://");
|
||||
}
|
||||
|
||||
const output = new PassThrough();
|
||||
|
||||
// command creation
|
||||
const command = new FFmpegCommand();
|
||||
command.on("stderr", (line) => {
|
||||
loggerFFmpeg.debug(line);
|
||||
});
|
||||
command.input(input);
|
||||
command.inputOptions("-y", "-loglevel", mergedOptions.logLevel, "-nostats");
|
||||
|
||||
// input options
|
||||
if (
|
||||
mergedOptions.customInputOptions &&
|
||||
mergedOptions.customInputOptions.length > 0
|
||||
) {
|
||||
command.inputOptions(mergedOptions.customInputOptions);
|
||||
}
|
||||
|
||||
const { hardwareAcceleratedDecoding, minimizeLatency, customHeaders } =
|
||||
mergedOptions;
|
||||
if (hardwareAcceleratedDecoding) command.inputOptions("-hwaccel", "auto");
|
||||
|
||||
if (minimizeLatency) {
|
||||
command.inputOptions(
|
||||
"-fflags nobuffer",
|
||||
"-flags lowdelay",
|
||||
"-flush_packets 1",
|
||||
"-max_delay 100000",
|
||||
);
|
||||
}
|
||||
|
||||
if (isHttpUrl) {
|
||||
const headersString = Object.entries(customHeaders)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join("\r\n");
|
||||
command.inputOptions(`-headers "${headersString}"`);
|
||||
if (!isHls) {
|
||||
command.inputOptions([
|
||||
"-reconnect 1",
|
||||
"-reconnect_at_eof 1",
|
||||
"-reconnect_streamed 1",
|
||||
"-reconnect_delay_max 4294",
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSrt) {
|
||||
command.inputOptions("-scan_all_pmts 0");
|
||||
}
|
||||
|
||||
// general output options
|
||||
command.output(output).format("nut");
|
||||
|
||||
// video setup
|
||||
const {
|
||||
noTranscoding,
|
||||
width,
|
||||
height,
|
||||
frameRate,
|
||||
bitrateVideo,
|
||||
bitrateVideoMax,
|
||||
videoCodec,
|
||||
encoder,
|
||||
} = mergedOptions;
|
||||
command.outputOptions("-map 0:v");
|
||||
|
||||
if (noTranscoding) {
|
||||
command.videoCodec("copy");
|
||||
} else {
|
||||
command.videoFilters(`scale=${width}:${height}`);
|
||||
|
||||
if (frameRate) command.fps(frameRate);
|
||||
|
||||
command.outputOptions([
|
||||
"-b:v",
|
||||
`${bitrateVideo}k`,
|
||||
"-maxrate:v",
|
||||
`${bitrateVideoMax}k`,
|
||||
"-bufsize:v",
|
||||
`${Math.round(bitrateVideo / 2)}k`,
|
||||
"-bf",
|
||||
"0",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-force_key_frames",
|
||||
"expr:gte(t,n_forced*1)",
|
||||
]);
|
||||
|
||||
const encoderSettings = encoder(bitrateVideo, bitrateVideoMax)[videoCodec];
|
||||
if (!encoderSettings)
|
||||
throw new Error(`Encoder settings not specified for ${videoCodec}`);
|
||||
command
|
||||
.videoCodec(encoderSettings.name)
|
||||
.videoFilters(encoderSettings.outFilters ?? [])
|
||||
.outputOptions(encoderSettings.options)
|
||||
.outputOptions(encoderSettings.globalOptions ?? []);
|
||||
}
|
||||
|
||||
// audio setup
|
||||
const { includeAudio, bitrateAudio } = mergedOptions;
|
||||
if (includeAudio)
|
||||
command
|
||||
.outputOptions("-map 0:a:0?")
|
||||
.audioChannels(2)
|
||||
/*
|
||||
* I don't have much surround sound material to test this with,
|
||||
* if you do and you have better settings for this, feel free to
|
||||
* contribute!
|
||||
*/
|
||||
.outputOptions("-lfe_mix_level 1")
|
||||
.audioFrequency(48000)
|
||||
.audioCodec("libopus")
|
||||
.audioBitrate(`${bitrateAudio}k`)
|
||||
.audioFilters("volume@internal_lib=1.0");
|
||||
|
||||
// Add custom ffmpeg flags
|
||||
if (
|
||||
mergedOptions.customFfmpegFlags &&
|
||||
mergedOptions.customFfmpegFlags.length > 0
|
||||
) {
|
||||
command.outputOptions(mergedOptions.customFfmpegFlags);
|
||||
}
|
||||
|
||||
// realtime control mechanism
|
||||
let currentVolume = 1;
|
||||
let zmqClientPromise: Promise<Request> | undefined;
|
||||
if (includeAudio && !isBun() && !isDeno()) {
|
||||
function randomInclusive(start: number, end: number) {
|
||||
return Math.floor(Math.random() * (end - start + 1)) + start;
|
||||
}
|
||||
// Last octet is from 2 to 254 to avoid WSL2 shenanigans
|
||||
const loopbackIp = [
|
||||
127,
|
||||
randomInclusive(0, 255),
|
||||
randomInclusive(0, 255),
|
||||
randomInclusive(2, 254),
|
||||
].join(".");
|
||||
const zmqEndpoint = `tcp://${loopbackIp}:42069`;
|
||||
command.audioFilters(`azmq=b=${zmqEndpoint.replaceAll(":", "\\\\:")}`);
|
||||
zmqClientPromise = import("zeromq").then((zmq) => {
|
||||
const client = new zmq.Request({
|
||||
sendTimeout: 5000,
|
||||
receiveTimeout: 5000,
|
||||
});
|
||||
client.connect(zmqEndpoint);
|
||||
promise.catch(() => {}).finally(() => client.disconnect(zmqEndpoint));
|
||||
return client;
|
||||
});
|
||||
}
|
||||
|
||||
command.once("start", (cmdline) => {
|
||||
logger.debug(`Starting ffmpeg: ${cmdline}`);
|
||||
});
|
||||
const promise = command.run(cancelSignal);
|
||||
|
||||
return {
|
||||
command,
|
||||
output,
|
||||
promise: promise as Promise<unknown>,
|
||||
controller: {
|
||||
get volume() {
|
||||
return currentVolume;
|
||||
},
|
||||
async setVolume(newVolume: number) {
|
||||
if (newVolume < 0) return false;
|
||||
try {
|
||||
if (!zmqClientPromise) return false;
|
||||
const client = await zmqClientPromise;
|
||||
await client.send(`volume@internal_lib volume ${newVolume}`);
|
||||
const [res] = await client.receive();
|
||||
if (res.toString("utf-8").split(" ")[0] !== "0") return false;
|
||||
currentVolume = newVolume;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
} satisfies Controller,
|
||||
};
|
||||
}
|
||||
|
||||
export type PlayStreamOptions = {
|
||||
/**
|
||||
* Set stream type as "Go Live" or camera stream
|
||||
*/
|
||||
type: "go-live" | "camera";
|
||||
|
||||
/**
|
||||
* Set format of the stream
|
||||
*/
|
||||
format: "matroska" | "nut";
|
||||
|
||||
/**
|
||||
* Override video width sent to Discord.
|
||||
*
|
||||
* DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
|
||||
*/
|
||||
width: number | ((v: VideoStreamInfo) => number);
|
||||
|
||||
/**
|
||||
* Override video height sent to Discord.
|
||||
*
|
||||
* DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
|
||||
*/
|
||||
height: number | ((v: VideoStreamInfo) => number);
|
||||
|
||||
/**
|
||||
* Override video frame rate sent to Discord.
|
||||
*
|
||||
* DO NOT SPECIFY UNLESS YOU KNOW WHAT YOU'RE DOING!
|
||||
*/
|
||||
frameRate: number | ((v: VideoStreamInfo) => 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 stream preview from input stream (experimental)
|
||||
*/
|
||||
streamPreview: boolean;
|
||||
};
|
||||
|
||||
export async function playStream(
|
||||
input: Readable,
|
||||
streamer: Streamer,
|
||||
options: Partial<PlayStreamOptions> = {},
|
||||
cancelSignal?: AbortSignal,
|
||||
) {
|
||||
const logger = new Log("playStream");
|
||||
cancelSignal?.throwIfAborted();
|
||||
if (!streamer.voiceConnection)
|
||||
throw new Error("Bot is not connected to a voice channel");
|
||||
|
||||
const defaultOptions = {
|
||||
type: "go-live",
|
||||
format: "nut",
|
||||
width: (video) => video.width,
|
||||
height: (video) => video.height,
|
||||
frameRate: (video) => video.framerate_num / video.framerate_den,
|
||||
readrateInitialBurst: undefined,
|
||||
streamPreview: false,
|
||||
} satisfies PlayStreamOptions;
|
||||
|
||||
function mergeOptions(opts: Partial<PlayStreamOptions>) {
|
||||
return {
|
||||
type: opts.type ?? defaultOptions.type,
|
||||
|
||||
format: opts.format ?? defaultOptions.format,
|
||||
|
||||
width:
|
||||
typeof opts.width === "function" ||
|
||||
(isFiniteNonZero(opts.width) && opts.width > 0)
|
||||
? opts.width
|
||||
: defaultOptions.width,
|
||||
|
||||
height:
|
||||
typeof opts.height === "function" ||
|
||||
(isFiniteNonZero(opts.height) && opts.height > 0)
|
||||
? opts.height
|
||||
: defaultOptions.height,
|
||||
|
||||
frameRate:
|
||||
typeof opts.frameRate === "function" ||
|
||||
(isFiniteNonZero(opts.frameRate) && opts.frameRate > 0)
|
||||
? opts.frameRate
|
||||
: defaultOptions.frameRate,
|
||||
|
||||
readrateInitialBurst:
|
||||
isFiniteNonZero(opts.readrateInitialBurst) &&
|
||||
opts.readrateInitialBurst > 0
|
||||
? opts.readrateInitialBurst
|
||||
: defaultOptions.readrateInitialBurst,
|
||||
|
||||
streamPreview: opts.streamPreview ?? defaultOptions.streamPreview,
|
||||
} satisfies PlayStreamOptions;
|
||||
}
|
||||
|
||||
const mergedOptions = mergeOptions(options);
|
||||
logger.debug({ options: mergedOptions }, "Merged options");
|
||||
|
||||
logger.debug("Initializing demuxer");
|
||||
const { video, audio } = await demux(input, {
|
||||
format: mergedOptions.format,
|
||||
});
|
||||
cancelSignal?.throwIfAborted();
|
||||
|
||||
if (!video) throw new Error("No video stream in media");
|
||||
|
||||
const cleanupFuncs: (() => unknown)[] = [];
|
||||
const videoCodecMap: Record<number, SupportedVideoCodec> = {
|
||||
[AVCodecID.AV_CODEC_ID_H264]: "H264",
|
||||
[AVCodecID.AV_CODEC_ID_H265]: "H265",
|
||||
[AVCodecID.AV_CODEC_ID_VP8]: "VP8",
|
||||
[AVCodecID.AV_CODEC_ID_VP9]: "VP9",
|
||||
[AVCodecID.AV_CODEC_ID_AV1]: "AV1",
|
||||
};
|
||||
|
||||
let conn: WebRtcConnWrapper;
|
||||
let stopStream: () => unknown;
|
||||
if (mergedOptions.type === "go-live") {
|
||||
conn = await streamer.createStream();
|
||||
stopStream = () => streamer.stopStream();
|
||||
} else {
|
||||
conn = streamer.voiceConnection.webRtcConn;
|
||||
streamer.signalVideo(true);
|
||||
stopStream = () => streamer.signalVideo(false);
|
||||
}
|
||||
conn.setPacketizer(videoCodecMap[video.codec]);
|
||||
conn.mediaConnection.setSpeaking(true);
|
||||
const { width, height, frameRate } = mergedOptions;
|
||||
conn.mediaConnection.setVideoAttributes(true, {
|
||||
width: Math.round(typeof width === "function" ? width(video) : width),
|
||||
height: Math.round(typeof height === "function" ? height(video) : height),
|
||||
fps: Math.round(
|
||||
typeof frameRate === "function" ? frameRate(video) : frameRate,
|
||||
),
|
||||
});
|
||||
|
||||
const vStream = new VideoStream(conn);
|
||||
video.stream.pipe(vStream);
|
||||
if (audio) {
|
||||
const aStream = new AudioStream(conn);
|
||||
audio.stream.pipe(aStream);
|
||||
vStream.syncStream = aStream;
|
||||
|
||||
const burstTime = mergedOptions.readrateInitialBurst;
|
||||
if (typeof burstTime === "number") {
|
||||
vStream.sync = false;
|
||||
vStream.noSleep = aStream.noSleep = true;
|
||||
const stopBurst = (pts: number) => {
|
||||
if (pts < burstTime * 1000) return;
|
||||
vStream.sync = true;
|
||||
vStream.noSleep = aStream.noSleep = false;
|
||||
vStream.off("pts", stopBurst);
|
||||
};
|
||||
vStream.on("pts", stopBurst);
|
||||
}
|
||||
}
|
||||
if (mergedOptions.streamPreview && mergedOptions.type === "go-live") {
|
||||
(async () => {
|
||||
const logger = new Log("playStream:preview");
|
||||
logger.debug("Initializing decoder for stream preview");
|
||||
const decoder = await createDecoder(video.avStream);
|
||||
if (!decoder) {
|
||||
logger.warn(
|
||||
"Failed to initialize decoder. Stream preview will be disabled",
|
||||
);
|
||||
return;
|
||||
}
|
||||
cleanupFuncs.push(() => {
|
||||
logger.debug("Freeing decoder");
|
||||
decoder.free();
|
||||
});
|
||||
const updatePreview = pDebounce.promise(async (packet: Packet) => {
|
||||
if (!(packet.flags !== undefined && packet.flags & AV_PKT_FLAG_KEY))
|
||||
return;
|
||||
const decodeStart = performance.now();
|
||||
const frames = await decoder.decode(packet).catch((e) => {
|
||||
logger.error(e, "Failed to decode the frame");
|
||||
return [];
|
||||
});
|
||||
if (!frames.length) return;
|
||||
|
||||
const decodeEnd = performance.now();
|
||||
logger.debug(`Decoding a frame took ${decodeEnd - decodeStart}ms`);
|
||||
const frame = frames[0];
|
||||
|
||||
return sharp(frame.toBuffer(), {
|
||||
raw: {
|
||||
width: frame.width ?? 0,
|
||||
height: frame.height ?? 0,
|
||||
channels: 4,
|
||||
},
|
||||
})
|
||||
.resize(1024, 576, { fit: "inside" })
|
||||
.jpeg()
|
||||
.toBuffer()
|
||||
.then((image) => streamer.setStreamPreview(image))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
frames.forEach((frame) => {
|
||||
frame.free();
|
||||
});
|
||||
});
|
||||
});
|
||||
video.stream.on("data", updatePreview);
|
||||
cleanupFuncs.push(() => video.stream.off("data", updatePreview));
|
||||
})();
|
||||
}
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
cleanupFuncs.push(() => {
|
||||
stopStream();
|
||||
conn.mediaConnection.setSpeaking(false);
|
||||
conn.mediaConnection.setVideoAttributes(false);
|
||||
});
|
||||
let cleanedUp = false;
|
||||
const cleanup = () => {
|
||||
if (cleanedUp) return;
|
||||
cleanedUp = true;
|
||||
for (const f of cleanupFuncs) f();
|
||||
};
|
||||
cancelSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
cleanup();
|
||||
reject(cancelSignal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
vStream.once("finish", () => {
|
||||
if (cancelSignal?.aborted) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
AnyChannel,
|
||||
DMChannel,
|
||||
GroupDMChannel,
|
||||
VoiceBasedChannel,
|
||||
} from "discord.js-selfbot-v13";
|
||||
|
||||
export function normalizeVideoCodec(
|
||||
codec: string,
|
||||
): "H264" | "H265" | "VP8" | "VP9" | "AV1" {
|
||||
if (/H\.?264|AVC/i.test(codec)) return "H264";
|
||||
if (/H\.?265|HEVC/i.test(codec)) return "H265";
|
||||
if (/VP(8|9)/i.test(codec)) return codec.toUpperCase() as "VP8" | "VP9";
|
||||
if (/AV1/i.test(codec)) return "AV1";
|
||||
throw new Error(`Unknown codec: ${codec}`);
|
||||
}
|
||||
|
||||
// The available video streams are sent by client on connection to voice gateway using OpCode Identify (0)
|
||||
// The server then replies with the ssrc and rtxssrc for each available stream using OpCode Ready (2)
|
||||
// RID is used specifically to distinguish between different simulcast streams of the same video source,
|
||||
// but we don't really care about sending multiple quality streams, so we hardcode a single one
|
||||
export const STREAMS_SIMULCAST = [{ type: "screen", rid: "100", quality: 100 }];
|
||||
|
||||
export enum SupportedEncryptionModes {
|
||||
AES256 = "aead_aes256_gcm_rtpsize",
|
||||
XCHACHA20 = "aead_xchacha20_poly1305_rtpsize",
|
||||
}
|
||||
|
||||
export type SupportedVideoCodec = "H264" | "H265" | "VP8" | "VP9" | "AV1";
|
||||
|
||||
export const max_int16bit = 2 ** 16;
|
||||
export const max_int32bit = 2 ** 32;
|
||||
|
||||
export function isFiniteNonZero(n: unknown): n is number {
|
||||
return !!n && Number.isFinite(n);
|
||||
}
|
||||
|
||||
export function parseStreamKey(streamKey: string): {
|
||||
type: "guild" | "call";
|
||||
channelId: string;
|
||||
guildId: string | null;
|
||||
userId: string;
|
||||
} {
|
||||
const streamKeyArray = streamKey.split(":");
|
||||
|
||||
const type = streamKeyArray.shift();
|
||||
|
||||
if (type !== "guild" && type !== "call") {
|
||||
throw new Error(`Invalid stream key type: ${type}`);
|
||||
}
|
||||
|
||||
if (
|
||||
(type === "guild" && streamKeyArray.length < 3) ||
|
||||
(type === "call" && streamKey.length < 2)
|
||||
)
|
||||
throw new Error(`Invalid stream key: ${streamKey}`); // invalid stream key
|
||||
|
||||
let guildId: string | null = null;
|
||||
if (type === "guild") {
|
||||
guildId = streamKeyArray.shift() ?? null;
|
||||
}
|
||||
const channelId = streamKeyArray.shift();
|
||||
const userId = streamKeyArray.shift();
|
||||
|
||||
if (!channelId || !userId) {
|
||||
throw new Error(`Invalid stream key: ${streamKey}`);
|
||||
}
|
||||
return { type, channelId, guildId, userId };
|
||||
}
|
||||
|
||||
export function generateStreamKey(
|
||||
type: "guild" | "call",
|
||||
guildId: string | null,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
): string {
|
||||
const streamKey = `${type}${type === "guild" ? `:${guildId}` : ""}:${channelId}:${userId}`;
|
||||
|
||||
return streamKey;
|
||||
}
|
||||
|
||||
export function isVoiceChannel(
|
||||
channel: AnyChannel,
|
||||
): channel is DMChannel | GroupDMChannel | VoiceBasedChannel {
|
||||
return (
|
||||
channel.type === "DM" ||
|
||||
channel.type === "GROUP_DM" ||
|
||||
channel.type === "GUILD_STAGE_VOICE" ||
|
||||
channel.type === "GUILD_VOICE"
|
||||
);
|
||||
}
|
||||
|
||||
export function isDeno() {
|
||||
// @ts-expect-error
|
||||
return typeof Deno !== "undefined";
|
||||
}
|
||||
|
||||
export function isBun() {
|
||||
// @ts-expect-error
|
||||
return typeof Bun !== "undefined";
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"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": "es2022", /* 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": "Node16", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "Node16", /* 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 '<reference>'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": true, /* 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/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user