feat(VoiceReceiver): Proof of concept - Video recording

This commit is contained in:
Elysia
2024-10-27 02:31:44 +07:00
parent c6a905cb80
commit 4c39f68353
4 changed files with 181 additions and 46 deletions

View File

@@ -0,0 +1,106 @@
'use strict';
const { spawn } = require('child_process');
const { createSocket } = require('dgram');
const { EventEmitter } = require('events');
const { Buffer } = require('node:buffer');
const { Writable } = require('stream');
const Util = require('../../../util/Util');
const { StreamOutput } = require('../util/Socket');
/**
* Represents a FFmpeg handler
* @extends {EventEmitter}
*/
class FFmpegHandler extends EventEmitter {
constructor(codec, portUdp, output) {
super();
/**
* The codec of the stream
* @type {VideoCodec}
*/
this.codec = codec;
/**
* The UDP port to listen to
* @type {number}
*/
this.portUdp = portUdp;
const isStream = output instanceof Writable;
if (isStream) {
this.outputStream = StreamOutput(output);
}
/**
* The output of the stream
* @type {string|Readable}
*/
this.output = output;
const sdpData = Util.getSDPCodecName(codec, portUdp);
/**
* The FFmpeg process is ready or not
* @type {boolean}
*/
this.ready = false;
/**
* The FFmpeg process
* @type {ChildProcessWithoutNullStreams}
*/
this.stream = spawn('ffmpeg', [
'-reorder_queue_size',
'50',
'-err_detect',
'ignore_err',
'-flags2',
'+export_mvs',
'-fflags',
'+genpts',
'-fflags',
'+discardcorrupt',
'-use_wallclock_as_timestamps',
'1',
'-protocol_whitelist',
'file,udp,rtp,pipe,fd',
'-i',
'-', // Read from stdin
'-buffer_size',
'1000000',
'-max_delay',
'500000',
'-y',
'-f', // Specify the format
'matroska', // MKV format
isStream ? this.outputStream.url : output,
]);
this.stream.stdin.write(sdpData);
this.stream.stdin.end();
this.stream.stderr.once('data', data => {
this.emit('debug', `stderr: ${data}`);
this.ready = true;
this.emit('ready');
});
this.socket = createSocket('udp4');
}
/**
* Send a payload to FFmpeg via UDP
* @param {Buffer} payload The payload
* @param {*} callback Callback
*/
sendPayloadToFFmpeg(
payload,
callback = e => {
if (e) {
console.error('Error sending packet:', e);
}
},
) {
const message = Buffer.from(payload);
this.socket.send(message, 0, message.length, this.portUdp, '127.0.0.1', callback);
}
}
module.exports = FFmpegHandler;

View File

@@ -4,6 +4,7 @@ const EventEmitter = require('events');
const { Buffer } = require('node:buffer');
const crypto = require('node:crypto');
const { setTimeout } = require('node:timers');
const FFmpegHandler = require('./FFmpegHandler');
const Speaking = require('../../../util/Speaking');
const secretbox = require('../util/Secretbox');
const { SILENCE_FRAME } = require('../util/Silence');
@@ -25,6 +26,7 @@ class PacketHandler extends EventEmitter {
super();
this.receiver = receiver;
this.streams = new Map();
this.videoStreams = new Map();
this.speakingTimeouts = new Map();
}
@@ -54,6 +56,15 @@ class PacketHandler extends EventEmitter {
return stream;
}
makeVideoStream(user, portUdp, codec = 'H264', output) {
if (this.videoStreams.has(user)) return this.videoStreams.get(user);
const stream = new FFmpegHandler(codec, portUdp, output);
stream.on('ready', () => {
this.videoStreams.set(user, stream);
});
return stream;
}
parseBuffer(buffer, shouldReturnTuple = false) {
const { secret_key, mode } = this.receiver.connection.authentication;
// Open packet
@@ -180,7 +191,7 @@ class PacketHandler extends EventEmitter {
const userStat = this.connection.ssrcMap.get(ssrc - 1); // Video_ssrc
if (!userStat) return;
const streamInfo = this.videoStreams.get(userStat.userId);
// If the user is in video, we need to check if the packet is just silence
if (userStat.hasVideo) {
const packet = this.parseBuffer(buffer, true);
@@ -193,6 +204,10 @@ class PacketHandler extends EventEmitter {
return;
}
this.receiver.emit('videoData', ssrc, userStat, header, videoPacket);
if (streamInfo) {
streamInfo.sendPayloadToFFmpeg(Buffer.concat(packet));
}
}
}

View File

@@ -54,6 +54,29 @@ class VoiceReceiver extends EventEmitter {
return stream;
}
/**
* Options passed to `VoiceReceiver#createVideoStream`.
* @typedef {Object} ReceiveVideoStreamOptions
* @property {number} [portUdp] The UDP port to use for the video stream (local stream).
* @property {string} [codec='H264'] The codec to use for encoding the video. Default is 'H264'.
* @property {any} [output] Additional output options, as required.
*/
/**
* Creates a new video receiving stream. If a stream already exists for a user, then that stream will be returned
* rather than generating a new one.
* <info>Proof of concept - Requires a very good internet connection</info>
* @param {UserResolvable} user The user to start listening to.
* @param {ReceiveVideoStreamOptions} options Options.
* @returns {FFmpegHandler} The video stream for the specified user.
*/
createVideoStream(user, { portUdp, codec = 'H264', output } = {}) {
user = this.connection.client.users.resolve(user);
if (!user) throw new Error('VOICE_USER_MISSING');
const stream = this.packets.makeVideoStream(user.id, portUdp, codec, output);
return stream;
}
/**
* Emitted whenever there is a video data (Raw)
* @event VoiceReceiver#videoData
@@ -61,49 +84,6 @@ class VoiceReceiver extends EventEmitter {
* @param {{ userId: Snowflake, hasVideo: boolean }} ssrcData SSRC Data
* @param {Buffer} header The unencrypted RTP header contains 12 bytes, Buffer<0xbe, 0xde> and the extension size
* @param {Buffer} packetDecrypt Decrypted contains the extension, if any, the video packet
* @example
* // Send packet to VLC
* const dgram = require('dgram');
* // Replace these with your actual values
* const PORT = 5004; // The port VLC is listening on
* const HOST = '127.0.0.1'; // Your localhost or the IP address of the machine running VLC
* // Create a UDP socket
* const socket = dgram.createSocket('udp4');
* function sendRTPPacket(payload) {
* const message = Buffer.from(payload);
* socket.send(message, 0, message.length, PORT, HOST, err => {
* if (err) {
* console.error('Error sending packet:', err);
* } else {
* console.log(message);
* }
* });
* }
* const connection = await client.voice.joinChannel(channel, {
* selfMute: true,
* selfDeaf: true,
* selfVideo: false,
* });
* connection.receiver.on('videoData', (ssrc, ssrcData, header, packetDecrypt) => {
* if (ssrcData.hasVideo) {
* header[0] &= 0xef; // Remove the marker bit
* // Strip decrypted RTP Header Extension if present
* if (header.slice(12, 14).compare(Buffer.from([0xbe, 0xde])) === 0) {
* const headerExtensionLength = header.slice(14).readUInt16BE();
* packetDecrypt = packetDecrypt.subarray(4 * headerExtensionLength);
* }
* sendRTPPacket(Buffer.concat([header.slice(0, 12), packetDecrypt]));
* }
* });
* // VLC SDP file (You can have it with FFmpeg)
* // ! Very buggy
* // o=- 0 0 IN IP4 <HOST>
* // s=No Name
* // c=IN IP4 <HOST>
* // t=0 0
* // a=tool:libavformat 61.1.100
* // m=video <PORT> RTP/AVP <RTP Dynamic Payload Type>
* // a=rtpmap:<RTP Dynamic Payload Type> <VP8|VP9|H264|H265>/90000
*/
}