diff --git a/.gitignore b/.gitignore
index 536c2c51..7a2ceca7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,4 +18,5 @@ package-lock.json
config.js
docker-compose.yml
docker-push.sh
-rec
\ No newline at end of file
+rec
+rtmp
\ No newline at end of file
diff --git a/README.md b/README.md
index e09e601a..4faed206 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,8 @@
- Push-to-talk functionality, similar to a walkie-talkie.
- Advanced collaborative whiteboard for teachers.
- Real-time sharing of YouTube embed videos, video files (MP4, WebM, OGG), and audio files (MP3).
+- Integrated RTMP server, fully compatible with **[OBS](https://obsproject.com)**.
+- Supports RTMP streaming from files, URLs, webcams, screens, and windows.
- Full-screen mode with one-click video element zooming and pin/unpin.
- Customizable UI themes.
- Right-click options on video elements for additional controls.
diff --git a/app/src/Room.js b/app/src/Room.js
index 8df6c7b2..07d64586 100644
--- a/app/src/Room.js
+++ b/app/src/Room.js
@@ -1,6 +1,12 @@
'use strict';
const config = require('./config');
+const crypto = require('crypto-js');
+const RtmpFile = require('./RtmpFile');
+const RtmpUrl = require('./RtmpUrl');
+const fs = require('fs');
+const path = require('path');
+const { v4: uuidv4 } = require('uuid');
const Logger = require('./Logger');
const log = new Logger('Room');
@@ -50,6 +56,11 @@ module.exports = class Room {
this.router = null;
this.routerSettings = config.mediasoup.router;
this.createTheRouter();
+
+ // RTMP configuration
+ this.rtmpFileStreamer = null;
+ this.rtmpUrlStreamer = null;
+ this.rtmp = config.server.rtmp || false;
}
// ####################################################
@@ -66,6 +77,12 @@ module.exports = class Room {
isLobbyEnabled: this._isLobbyEnabled,
hostOnlyRecording: this._hostOnlyRecording,
},
+ rtmp: {
+ enabled: this.rtmp && this.rtmp.enabled,
+ fromFile: this.rtmp && this.rtmp.fromFile,
+ fromUrl: this.rtmp && this.rtmp.fromUrl,
+ fromStream: this.rtmp && this.rtmp.fromStream,
+ },
moderator: this._moderator,
survey: this.survey,
redirect: this.redirect,
@@ -74,6 +91,164 @@ module.exports = class Room {
};
}
+ // ##############################################
+ // RTMP from FILE
+ // ##############################################
+
+ isRtmpFileStreamerActive() {
+ return this.rtmpFileStreamer;
+ }
+
+ async getRTMP(dir = 'rtmp') {
+ const folderPath = path.join(__dirname, '..', dir);
+
+ // Create dir if not exists
+ if (!fs.existsSync(folderPath)) {
+ fs.mkdirSync(folderPath, { recursive: true });
+ }
+
+ try {
+ const files = fs.readdirSync(folderPath);
+ log.info('RTMP files', files);
+ return files;
+ } catch (error) {
+ log.error(`[getRTMP] Error reading directory: ${error.message}`);
+ return [];
+ }
+ }
+
+ async startRTMP(socket_id, room, host = 'localhost', port = 1935, file = '../rtmp/BigBuckBunny.mp4') {
+ if (!this.rtmp || !this.rtmp.enabled) {
+ log.debug('RTMP server is not enabled or missing the config');
+ return false;
+ }
+
+ if (this.rtmpFileStreamer) {
+ log.debug('RtmpFile is already in progress');
+ return false;
+ }
+
+ const inputFilePath = path.join(__dirname, file);
+
+ if (!fs.existsSync(inputFilePath)) {
+ log.error(`File not found: ${inputFilePath}`);
+ return false;
+ }
+
+ this.rtmpFileStreamer = new RtmpFile(socket_id, room);
+
+ const inputStream = fs.createReadStream(inputFilePath);
+
+ const rtmpUrl = this.getRTMPUrl(host, port);
+
+ const rtmpRun = await this.rtmpFileStreamer.start(inputStream, rtmpUrl);
+
+ if (!rtmpRun) {
+ this.rtmpFileStreamer = false;
+ return this.rtmpFileStreamer;
+ }
+ return rtmpUrl;
+ }
+
+ stopRTMP() {
+ if (!this.rtmp || !this.rtmp.enabled) {
+ log.debug('RTMP server is not enabled or missing the config');
+ return false;
+ }
+ if (this.rtmpFileStreamer) {
+ this.rtmpFileStreamer.stop();
+ this.rtmpFileStreamer = null;
+ log.debug('RTMP File Streamer Stopped successfully!');
+ return true;
+ } else {
+ log.debug('No RtmpFile process to stop');
+ return false;
+ }
+ }
+
+ // ####################################################
+ // RTMP from URL
+ // ####################################################
+
+ isRtmpUrlStreamerActive() {
+ return this.rtmpUrlStreamer;
+ }
+
+ async startRTMPfromURL(
+ socket_id,
+ room,
+ host = 'localhost',
+ port = 1935,
+ inputVideoURL = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
+ ) {
+ if (!this.rtmp || !this.rtmp.enabled) {
+ log.debug('RTMP server is not enabled or missing the config');
+ return false;
+ }
+
+ if (this.rtmpUrlStreamer) {
+ log.debug('RtmpFile is already in progress');
+ return false;
+ }
+
+ this.rtmpUrlStreamer = new RtmpUrl(socket_id, room);
+
+ const rtmpUrl = this.getRTMPUrl(host, port);
+
+ const rtmpRun = await this.rtmpUrlStreamer.start(inputVideoURL, rtmpUrl);
+
+ if (!rtmpRun) {
+ this.rtmpUrlStreamer = false;
+ return this.rtmpUrlStreamer;
+ }
+ return rtmpUrl;
+ }
+
+ stopRTMPfromURL() {
+ if (!this.rtmp || !this.rtmp.enabled) {
+ log.debug('RTMP server is not enabled or missing the config');
+ return false;
+ }
+ if (this.rtmpUrlStreamer) {
+ this.rtmpUrlStreamer.stop();
+ this.rtmpUrlStreamer = null;
+ log.debug('RTMP Url Streamer Stopped successfully!');
+ return true;
+ } else {
+ log.debug('No RtmpUrl process to stop');
+ return false;
+ }
+ }
+
+ // ####################################################
+ // RTMP COMMON
+ // ####################################################
+
+ getRTMPUrl(host, port) {
+ const rtmpServer = this.rtmp.server != '' ? this.rtmp.server : false;
+ const rtmpAppName = this.rtmp.appName != '' ? this.rtmp.appName : 'live';
+ const rtmpStreamKey = this.rtmp.streamKey != '' ? this.rtmp.streamKey : uuidv4();
+ const rtmpServerSecret = this.rtmp.secret != '' ? this.rtmp.secret : false;
+ const expirationHours = this.rtmp.expirationHours || 4;
+ const rtmpServerURL = rtmpServer ? rtmpServer : `rtmp://${host}:${port}`;
+ const rtmpServerPath = '/' + rtmpAppName + '/' + rtmpStreamKey;
+
+ const rtmpUrl = rtmpServerSecret
+ ? this.generateRTMPUrl(rtmpServerURL, rtmpServerPath, rtmpServerSecret, expirationHours)
+ : rtmpServerURL + rtmpServerPath;
+
+ log.info('RTMP Url generated', rtmpUrl);
+ return rtmpUrl;
+ }
+
+ generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 8) {
+ const currentTime = Math.floor(Date.now() / 1000);
+ const expirationTime = currentTime + expirationHours * 3600;
+ const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString();
+ const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`;
+ return rtmpUrl;
+ }
+
// ####################################################
// ROUTER
// ####################################################
diff --git a/app/src/RtmpFile.js b/app/src/RtmpFile.js
new file mode 100644
index 00000000..c802cbfc
--- /dev/null
+++ b/app/src/RtmpFile.js
@@ -0,0 +1,97 @@
+'use strict';
+
+const ffmpeg = require('fluent-ffmpeg');
+const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
+ffmpeg.setFfmpegPath(ffmpegInstaller.path);
+
+const Logger = require('./Logger');
+const log = new Logger('RtmpFile');
+
+class RtmpFile {
+ constructor(socket_id = false, room = false) {
+ this.socketId = socket_id;
+ this.room = room;
+ this.rtmpUrl = '';
+ this.ffmpegProcess = null;
+ }
+
+ async start(inputStream, rtmpUrl) {
+ if (this.ffmpegProcess) {
+ log.debug('Streaming is already in progress');
+ return false;
+ }
+
+ this.rtmpUrl = rtmpUrl;
+
+ try {
+ this.ffmpegProcess = ffmpeg(inputStream)
+ .inputOptions(['-re']) // Read input at native frame rate
+ .outputOptions([
+ '-c:v libx264', // Encode video to H.264
+ '-preset veryfast', // Set preset to very fast
+ '-maxrate 3000k', // Max bitrate for the video stream
+ '-bufsize 6000k', // Buffer size
+ '-g 50', // GOP size
+ '-c:a aac', // Encode audio to AAC
+ '-b:a 128k', // Bitrate for the audio stream
+ '-f flv', // Output format
+ ])
+ .output(rtmpUrl)
+ .on('start', (commandLine) => log.info('ffmpeg process starting with command:', commandLine))
+ .on('progress', (progress) => {
+ /* log.debug('Processing', progress); */
+ })
+ .on('error', (err, stdout, stderr) => {
+ log.debug('Error: ' + err.message);
+ this.ffmpegProcess = null;
+ if (!err.message.includes('Exiting normally')) {
+ this.handleError(err.message);
+ }
+ })
+ .on('end', () => {
+ log.info('FFmpeg processing finished');
+ this.ffmpegProcess = null;
+ this.handleEnd();
+ })
+ .run();
+
+ log.info('RtmpFile started', rtmpUrl);
+ return true;
+ } catch (error) {
+ log.error('Error starting RtmpFile', error.message);
+ return false;
+ }
+ }
+
+ async stop() {
+ if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
+ try {
+ this.ffmpegProcess.kill('SIGTERM');
+ this.ffmpegProcess = null;
+ log.info('RtmpFile stopped');
+ return true;
+ } catch (error) {
+ log.error('Error stopping RtmpFile', error.message);
+ return false;
+ }
+ } else {
+ log.debug('No RtmpFile process to stop');
+ return true;
+ }
+ }
+
+ handleEnd() {
+ if (!this.room) return;
+ this.room.send(this.socketId, 'endRTMP', { rtmpUrl: this.rtmpUrl });
+ this.room.rtmpFileStreamer = false;
+ }
+
+ handleError(message) {
+ if (!this.room) return;
+ this.room.send(this.socketId, 'errorRTMP', { message });
+ this.room.rtmpFileStreamer = false;
+ log.error('Error: ' + message);
+ }
+}
+
+module.exports = RtmpFile;
diff --git a/app/src/RtmpStreamer.js b/app/src/RtmpStreamer.js
new file mode 100644
index 00000000..4670cfc6
--- /dev/null
+++ b/app/src/RtmpStreamer.js
@@ -0,0 +1,74 @@
+'use strict';
+
+const { PassThrough } = require('stream');
+const ffmpeg = require('fluent-ffmpeg');
+const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
+ffmpeg.setFfmpegPath(ffmpegInstaller.path);
+
+const Logger = require('./Logger');
+const log = new Logger('RtmpStreamer');
+
+class RtmpStreamer {
+ constructor(rtmpUrl, rtmpKey) {
+ this.rtmpUrl = rtmpUrl;
+ this.rtmpKey = rtmpKey;
+ this.log = log;
+ this.stream = new PassThrough();
+ this.ffmpegStream = null;
+ this.initFFmpeg();
+ this.run = true;
+ }
+
+ initFFmpeg() {
+ this.ffmpegStream = ffmpeg()
+ .input(this.stream)
+ .inputOptions('-re')
+ .inputFormat('webm')
+ .videoCodec('libx264')
+ .videoBitrate('3000k')
+ .size('1280x720')
+ .audioCodec('aac')
+ .audioBitrate('128k')
+ .outputOptions(['-f flv'])
+ .output(this.rtmpUrl)
+ .on('start', (commandLine) => this.log.info('ffmpeg command', { id: this.rtmpKey, cmd: commandLine }))
+ .on('progress', (progress) => {
+ /* log.debug('Processing', progress); */
+ })
+ .on('error', (err, stdout, stderr) => {
+ if (!err.message.includes('Exiting normally')) {
+ this.log.error('FFmpeg error:', { id: this.rtmpKey, error: err.message });
+ }
+ this.end();
+ })
+ .on('end', () => {
+ this.log.info('FFmpeg process ended', this.rtmpKey);
+ this.end();
+ })
+ .run();
+ }
+
+ write(data) {
+ if (this.stream) this.stream.write(data);
+ }
+
+ isRunning() {
+ return this.run;
+ }
+
+ end() {
+ if (this.stream) {
+ this.stream.end();
+ this.stream = null;
+ this.log.info('RTMP streaming stopped', this.rtmpKey);
+ }
+ if (this.ffmpegStream && !this.ffmpegStream.killed) {
+ this.ffmpegStream.kill('SIGTERM');
+ this.ffmpegStream = null;
+ this.log.info('FFMPEG closed successfully', this.rtmpKey);
+ }
+ this.run = false;
+ }
+}
+
+module.exports = RtmpStreamer;
diff --git a/app/src/RtmpUrl.js b/app/src/RtmpUrl.js
new file mode 100644
index 00000000..6b1d2ca4
--- /dev/null
+++ b/app/src/RtmpUrl.js
@@ -0,0 +1,93 @@
+'use strict';
+
+const ffmpeg = require('fluent-ffmpeg');
+const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
+ffmpeg.setFfmpegPath(ffmpegInstaller.path);
+
+const Logger = require('./Logger');
+const log = new Logger('RtmpUrl');
+
+class RtmpUrl {
+ constructor(socket_id = false, room = false) {
+ this.room = room;
+ this.socketId = socket_id;
+ this.rtmpUrl = '';
+ this.ffmpegProcess = null;
+ }
+
+ async start(inputVideoURL, rtmpUrl) {
+ if (this.ffmpegProcess) {
+ log.debug('Streaming is already in progress');
+ return false;
+ }
+
+ this.rtmpUrl = rtmpUrl;
+
+ try {
+ this.ffmpegProcess = ffmpeg(inputVideoURL)
+ .inputOptions('-re') // Read input in real-time
+ .audioCodec('aac') // Set audio codec to AAC
+ .audioBitrate('128k') // Set audio bitrate to 128 kbps
+ .videoCodec('libx264') // Set video codec to H.264
+ .videoBitrate('3000k') // Set video bitrate to 3000 kbps
+ .size('1280x720') // Scale video to 1280x720 resolution
+ .format('flv') // Set output format to FLV
+ .output(rtmpUrl)
+ .on('start', (commandLine) => log.info('ffmpeg process starting with command:', commandLine))
+ .on('progress', (progress) => {
+ /* log.debug('Processing', progress); */
+ })
+ .on('error', (err, stdout, stderr) => {
+ log.debug('Error: ' + err.message);
+ this.ffmpegProcess = null;
+ if (!err.message.includes('Exiting normally')) {
+ this.handleError(err.message);
+ }
+ })
+ .on('end', () => {
+ log.info('FFmpeg processing finished');
+ this.ffmpegProcess = null;
+ this.handleEnd();
+ })
+ .run();
+
+ log.info('RtmpUrl started', rtmpUrl);
+ return true;
+ } catch (error) {
+ log.error('Error starting RtmpUrl', error.message);
+ return false;
+ }
+ }
+
+ async stop() {
+ if (this.ffmpegProcess && !this.ffmpegProcess.killed) {
+ try {
+ this.ffmpegProcess.kill('SIGTERM');
+ this.ffmpegProcess = null;
+ log.info('RtmpUrl stopped');
+ return true;
+ } catch (error) {
+ log.error('Error stopping RtmpUrl', error.message);
+ return false;
+ }
+ } else {
+ log.debug('No RtmpUrl process to stop');
+ return true;
+ }
+ }
+
+ handleEnd() {
+ if (!this.room) return;
+ this.room.send(this.socketId, 'endRTMPfromURL', { rtmpUrl: this.rtmpUrl });
+ this.room.rtmpUrlStreamer = false;
+ }
+
+ handleError(message) {
+ if (!this.room) return;
+ this.room.send(this.socketId, 'errorRTMPfromURL', { message });
+ this.room.rtmpUrlStreamer = false;
+ log.error('Error: ' + message);
+ }
+}
+
+module.exports = RtmpUrl;
diff --git a/app/src/Server.js b/app/src/Server.js
index ce5ef5e9..ea1f8a49 100644
--- a/app/src/Server.js
+++ b/app/src/Server.js
@@ -8,6 +8,7 @@
███████ ███████ ██ ██ ████ ███████ ██ ██
dependencies: {
+ @ffmpeg-installer/ffmpeg: https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg
@sentry/node : https://www.npmjs.com/package/@sentry/node
@sentry/integrations : https://www.npmjs.com/package/@sentry/integrations
axios : https://www.npmjs.com/package/axios
@@ -18,6 +19,7 @@ dependencies: {
crypto-js : https://www.npmjs.com/package/crypto-js
express : https://www.npmjs.com/package/express
express-openid-connect : https://www.npmjs.com/package/express-openid-connect
+ fluent-ffmpeg : https://www.npmjs.com/package/fluent-ffmpeg
httpolyglot : https://www.npmjs.com/package/httpolyglot
jsonwebtoken : https://www.npmjs.com/package/jsonwebtoken
js-yaml : https://www.npmjs.com/package/js-yaml
@@ -42,7 +44,7 @@ dependencies: {
* @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
- * @version 1.4.51
+ * @version 1.4.70
*
*/
@@ -50,6 +52,7 @@ const express = require('express');
const { auth, requiresAuth } = require('express-openid-connect');
const cors = require('cors');
const compression = require('compression');
+const socketIo = require('socket.io');
const https = require('httpolyglot');
const mediasoup = require('mediasoup');
const mediasoupClient = require('mediasoup-client');
@@ -75,6 +78,17 @@ const { CaptureConsole } = require('@sentry/integrations');
const restrictAccessByIP = require('./middleware/IpWhitelist.js');
const packageJson = require('../../package.json');
+// Incoming Stream to RTPM
+const { v4: uuidv4 } = require('uuid');
+const crypto = require('crypto-js');
+const RtmpStreamer = require('./RtmpStreamer.js'); // Import the RtmpStreamer class
+const rtmpCfg = config.server.rtmp;
+const rtmpDir = rtmpCfg && rtmpCfg.dir ? rtmpCfg.dir : 'rtmp';
+
+// File and Url Rtmp streams count
+let rtmpFileStreamsCount = 0;
+let rtmpUrlStreamsCount = 0;
+
// Email alerts and notifications
const nodemailer = require('./lib/nodemailer');
@@ -98,7 +112,7 @@ const corsOptions = {
};
const httpsServer = https.createServer(options, app);
-const io = require('socket.io')(httpsServer, {
+const io = socketIo(httpsServer, {
maxHttpBufferSize: 1e7,
transports: ['websocket'],
cors: corsOptions,
@@ -169,7 +183,7 @@ if (config.chatGPT.enabled) {
};
chatGPT = new OpenAI(configuration);
} else {
- log.warning('ChatGPT seems enabled, but you missing the apiKey!');
+ log.warn('ChatGPT seems enabled, but you missing the apiKey!');
}
}
@@ -200,13 +214,16 @@ const views = {
permission: path.join(__dirname, '../../', 'public/views/permission.html'),
privacy: path.join(__dirname, '../../', 'public/views/privacy.html'),
room: path.join(__dirname, '../../', 'public/views/Room.html'),
+ rtmpStreamer: path.join(__dirname, '../../', 'public/views/RtmpStreamer.html'),
};
const authHost = new Host(); // Authenticated IP by Login
const roomList = new Map(); // All Rooms
-const presenters = {}; // collect presenters grp by roomId
+const presenters = {}; // Collect presenters grp by roomId
+
+const streams = {}; // Collect all rtmp streams
const webRtcServerActive = config.mediasoup.webRtcServerActive;
@@ -288,24 +305,28 @@ function startServer() {
// Start the app
app.use(cors(corsOptions));
app.use(compression());
- app.use(express.json());
+ app.use(express.json({ limit: '50mb' })); // Ensure the body parser can handle large files
app.use(express.static(dir.public));
app.use(bodyParser.urlencoded({ extended: true }));
+ app.use(bodyParser.raw({ type: 'video/webm', limit: '50mb' })); // handle raw binary data
+ app.use(bodyParser.raw({ type: 'application/octet-stream', limit: '50mb' })); // handle raw binary data
app.use(restApi.basePath + '/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); // api docs
// IP Whitelist check ...
app.use(restrictAccessByIP);
// Logs requests
+ /*
app.use((req, res, next) => {
log.debug('New request:', {
- // headers: req.headers,
+ headers: req.headers,
body: req.body,
method: req.method,
path: req.originalUrl,
});
next();
});
+ */
// POST start from here...
app.post('*', function (next) {
@@ -407,6 +428,14 @@ function startServer() {
}
});
+ // Route to display rtmp streamer
+ app.get('/rtmp', OIDCAuth, (req, res) => {
+ if (!rtmpCfg || !rtmpCfg.fromStream) {
+ return res.json({ message: 'The RTMP Streamer is currently disabled.' });
+ }
+ return res.sendFile(views.rtmpStreamer);
+ });
+
// set new room name and join
app.get(['/newroom'], OIDCAuth, (req, res) => {
//log.info('/newroom - hostCfg ----->', hostCfg);
@@ -655,6 +684,128 @@ function startServer() {
}
});
+ // ###############################################################
+ // INCOMING STREAM (getUserMedia || getDisplayMedia) TO RTMP
+ // ###############################################################
+
+ function checkRTMPApiSecret(req, res, next) {
+ const expectedApiSecret = rtmpCfg && rtmpCfg.apiSecret;
+ const apiSecret = req.headers.authorization;
+
+ if (!apiSecret || apiSecret !== expectedApiSecret) {
+ log.warn('RTMP apiSecret Unauthorized', {
+ apiSecret: apiSecret,
+ expectedApiSecret: expectedApiSecret,
+ });
+ return res.status(401).send('Unauthorized');
+ }
+ next();
+ }
+
+ function checkMaxStreams(req, res, next) {
+ const maxStreams = (rtmpCfg && rtmpCfg.maxStreams) || 1; // Set your maximum allowed streams here
+ const activeStreams = Object.keys(streams).length;
+ if (activeStreams >= maxStreams) {
+ log.warn('Maximum number of streams reached', activeStreams);
+ return res.status(429).send('Maximum number of streams reached, please try later!');
+ }
+ next();
+ }
+
+ app.get('/activeStreams', checkRTMPApiSecret, (req, res) => {
+ const activeStreams = Object.keys(streams).length;
+ log.info('Active Streams', activeStreams);
+ res.json(activeStreams);
+ });
+
+ app.get('/rtmpEnabled', (req, res) => {
+ const rtmpEnabled = rtmpCfg && rtmpCfg.enabled;
+ log.debug('RTMP enabled', rtmpEnabled);
+ res.json({ enabled: rtmpEnabled });
+ });
+
+ app.post('/initRTMP', checkRTMPApiSecret, checkMaxStreams, async (req, res) => {
+ if (!rtmpCfg || !rtmpCfg.enabled) {
+ return res.status(400).send('RTMP server is not enabled or missing the config');
+ }
+
+ const domainName = config.ngrok.enabled ? 'localhost' : req.headers.host.split(':')[0];
+
+ const rtmpServer = rtmpCfg.server != '' ? rtmpCfg.server : false;
+ const rtmpServerAppName = rtmpCfg.appName != '' ? rtmpCfg.appName : 'live';
+ const rtmpStreamKey = rtmpCfg.streamKey != '' ? rtmpCfg.streamKey : uuidv4();
+ const rtmpServerSecret = rtmpCfg.secret != '' ? rtmpCfg.secret : false;
+ const expirationHours = rtmpCfg.expirationHours || 4;
+ const rtmpServerURL = rtmpServer ? rtmpServer : `rtmp://${domainName}:1935`;
+ const rtmpServerPath = '/' + rtmpServerAppName + '/' + rtmpStreamKey;
+
+ const rtmp = rtmpServerSecret
+ ? generateRTMPUrl(rtmpServerURL, rtmpServerPath, rtmpServerSecret, expirationHours)
+ : rtmpServerURL + rtmpServerPath;
+
+ log.info('initRTMP', {
+ headers: req.headers,
+ rtmpServer,
+ rtmpServerSecret,
+ rtmpServerURL,
+ rtmpServerPath,
+ expirationHours,
+ rtmpStreamKey,
+ rtmp,
+ });
+
+ const stream = new RtmpStreamer(rtmp, rtmpStreamKey);
+ streams[rtmpStreamKey] = stream;
+
+ log.info('Active RTMP Streams', Object.keys(streams).length);
+
+ return res.json({ rtmp });
+ });
+
+ app.post('/streamRTMP', checkRTMPApiSecret, (req, res) => {
+ if (!rtmpCfg || !rtmpCfg.enabled) {
+ return res.status(400).send('RTMP server is not enabled');
+ }
+ if (!req.body || req.body.length === 0) {
+ return res.status(400).send('Invalid video data');
+ }
+
+ const rtmpStreamKey = req.query.key;
+ const stream = streams[rtmpStreamKey];
+
+ if (!stream || !stream.isRunning()) {
+ delete streams[rtmpStreamKey];
+ log.debug('Stream not found', { rtmpStreamKey, streams: Object.keys(streams).length });
+ return res.status(404).send('FFmpeg Stream not found');
+ }
+
+ log.debug('Received video data', {
+ // data: req.body.slice(0, 20).toString('hex'),
+ key: rtmpStreamKey,
+ size: bytesToSize(req.headers['content-length']),
+ });
+
+ stream.write(Buffer.from(req.body));
+ res.sendStatus(200);
+ });
+
+ app.post('/stopRTMP', checkRTMPApiSecret, (req, res) => {
+ if (!rtmpCfg || !rtmpCfg.enabled) {
+ return res.status(400).send('RTMP server is not enabled');
+ }
+
+ const rtmpStreamKey = req.query.key;
+ const stream = streams[rtmpStreamKey];
+
+ if (stream) {
+ stream.end();
+ delete streams[rtmpStreamKey];
+ log.debug('Active RTMP Streams', Object.keys(streams).length);
+ }
+
+ res.sendStatus(200);
+ });
+
// ####################################################
// REST API
// ####################################################
@@ -1098,6 +1249,10 @@ function startServer() {
log.info('[Join] - current active rooms', activeRooms);
+ const activeStreams = getRTMPActiveStreams();
+
+ log.info('[Join] - current active RTMP streams', activeStreams);
+
if (!(socket.room_id in presenters)) presenters[socket.room_id] = {};
// Set the presenters
@@ -2135,6 +2290,93 @@ function startServer() {
}
});
+ socket.on('getRTMP', async ({}, cb) => {
+ if (!roomList.has(socket.room_id)) return;
+ const room = roomList.get(socket.room_id);
+ const rtmpFiles = await room.getRTMP(rtmpDir);
+ cb(rtmpFiles);
+ });
+
+ socket.on('startRTMP', async (dataObject, cb) => {
+ if (!roomList.has(socket.room_id)) return;
+
+ if (rtmpCfg && rtmpFileStreamsCount >= rtmpCfg.maxStreams) {
+ log.warn('RTMP max file streams reached', rtmpFileStreamsCount);
+ return cb(false);
+ }
+
+ const data = checkXSS(dataObject);
+ const { peer_name, peer_uuid, file } = data;
+ const isPresenter = await isPeerPresenter(socket.room_id, socket.id, peer_name, peer_uuid);
+ if (!isPresenter) return cb(false);
+
+ const room = roomList.get(socket.room_id);
+ const host = config.ngrok.enabled ? 'localhost' : socket.handshake.headers.host.split(':')[0];
+ const rtmp = await room.startRTMP(socket.id, room, host, 1935, `../${rtmpDir}/${file}`);
+
+ if (rtmp !== false) rtmpFileStreamsCount++;
+ log.debug('startRTMP - rtmpFileStreamsCount ---->', rtmpFileStreamsCount);
+
+ cb(rtmp);
+ });
+
+ socket.on('stopRTMP', async () => {
+ if (!roomList.has(socket.room_id)) return;
+
+ const room = roomList.get(socket.room_id);
+
+ rtmpFileStreamsCount--;
+ log.debug('stopRTMP - rtmpFileStreamsCount ---->', rtmpFileStreamsCount);
+
+ await room.stopRTMP();
+ });
+
+ socket.on('endOrErrorRTMP', async () => {
+ if (!roomList.has(socket.room_id)) return;
+ rtmpFileStreamsCount--;
+ log.debug('endRTMP - rtmpFileStreamsCount ---->', rtmpFileStreamsCount);
+ });
+
+ socket.on('startRTMPfromURL', async (dataObject, cb) => {
+ if (!roomList.has(socket.room_id)) return;
+
+ if (rtmpCfg && rtmpUrlStreamsCount >= rtmpCfg.maxStreams) {
+ log.warn('RTMP max Url streams reached', rtmpUrlStreamsCount);
+ return cb(false);
+ }
+
+ const data = checkXSS(dataObject);
+ const { peer_name, peer_uuid, inputVideoURL } = data;
+ const isPresenter = await isPeerPresenter(socket.room_id, socket.id, peer_name, peer_uuid);
+ if (!isPresenter) return cb(false);
+
+ const room = roomList.get(socket.room_id);
+ const host = config.ngrok.enabled ? 'localhost' : socket.handshake.headers.host.split(':')[0];
+ const rtmp = await room.startRTMPfromURL(socket.id, room, host, 1935, inputVideoURL);
+
+ if (rtmp !== false) rtmpUrlStreamsCount++;
+ log.debug('startRTMPfromURL - rtmpUrlStreamsCount ---->', rtmpUrlStreamsCount);
+
+ cb(rtmp);
+ });
+
+ socket.on('stopRTMPfromURL', async () => {
+ if (!roomList.has(socket.room_id)) return;
+
+ const room = roomList.get(socket.room_id);
+
+ rtmpUrlStreamsCount--;
+ log.debug('stopRTMPfromURL - rtmpUrlStreamsCount ---->', rtmpUrlStreamsCount);
+
+ await room.stopRTMPfromURL();
+ });
+
+ socket.on('endOrErrorRTMPfromURL', async () => {
+ if (!roomList.has(socket.room_id)) return;
+ rtmpUrlStreamsCount--;
+ log.debug('endRTMPfromURL - rtmpUrlStreamsCount ---->', rtmpUrlStreamsCount);
+ });
+
socket.on('disconnect', async () => {
if (!roomList.has(socket.room_id)) return;
@@ -2152,6 +2394,8 @@ function startServer() {
if (room.getPeers().size === 0) {
//
+ stopRTMPActiveStreams(isPresenter, room);
+
roomList.delete(socket.room_id);
delete presenters[socket.room_id];
@@ -2161,6 +2405,10 @@ function startServer() {
const activeRooms = getActiveRooms();
log.info('[Disconnect] - Last peer - current active rooms', activeRooms);
+
+ const activeStreams = getRTMPActiveStreams();
+
+ log.info('[Disconnect] - Last peer - current active RTMP streams', activeStreams);
}
room.broadCast(socket.id, 'removeMe', removeMeData(room, peer_name, isPresenter));
@@ -2193,6 +2441,8 @@ function startServer() {
if (room.getPeers().size === 0) {
//
+ stopRTMPActiveStreams(isPresenter, room);
+
roomList.delete(socket.room_id);
delete presenters[socket.room_id];
@@ -2202,6 +2452,10 @@ function startServer() {
const activeRooms = getActiveRooms();
log.info('[REMOVE ME] - Last peer - current active rooms', activeRooms);
+
+ const activeStreams = getRTMPActiveStreams();
+
+ log.info('[REMOVE ME] - Last peer - current active RTMP streams', activeStreams);
}
socket.room_id = null;
@@ -2270,15 +2524,54 @@ function startServer() {
log.debug('[REMOVE ME DATA]', data);
return data;
}
-
- function bytesToSize(bytes) {
- let sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
- if (bytes == 0) return '0 Byte';
- let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
- return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
- }
});
+ function generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 4) {
+ const currentTime = Math.floor(Date.now() / 1000);
+ const expirationTime = currentTime + expirationHours * 3600;
+ const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString();
+ const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`;
+
+ log.debug('generateRTMPUrl', {
+ currentTime,
+ expirationTime,
+ hashValue,
+ rtmpUrl,
+ });
+
+ return rtmpUrl;
+ }
+
+ function getRTMPActiveStreams() {
+ return {
+ rtmpStreams: Object.keys(streams).length,
+ rtmpFileStreamsCount,
+ rtmpUrlStreamsCount,
+ };
+ }
+
+ function stopRTMPActiveStreams(isPresenter, room) {
+ if (isPresenter) {
+ if (room.isRtmpFileStreamerActive()) {
+ room.stopRTMP();
+ rtmpFileStreamsCount--;
+ log.info('[REMOVE ME] - Stop RTMP Stream From FIle', rtmpFileStreamsCount);
+ }
+ if (room.isRtmpUrlStreamerActive()) {
+ room.stopRTMPfromURL();
+ rtmpUrlStreamsCount--;
+ log.info('[REMOVE ME] - Stop RTMP Stream From URL', rtmpUrlStreamsCount);
+ }
+ }
+ }
+
+ function bytesToSize(bytes) {
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ if (bytes == 0) return '0 Byte';
+ const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024)));
+ return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i];
+ }
+
function clone(value) {
if (value === undefined) return undefined;
if (Number.isNaN(value)) return NaN;
diff --git a/app/src/config.template.js b/app/src/config.template.js
index c3a7d87a..daffb25b 100644
--- a/app/src/config.template.js
+++ b/app/src/config.template.js
@@ -59,6 +59,42 @@ module.exports = {
endpoint: '', // Change the URL if you want to save the recording to a different server or cloud service (http://localhost:8080), otherwise leave it as is (empty).
dir: 'rec',
},
+ rtmp: {
+ /*
+ Real-Time Messaging Protocol (RTMP) is a communication protocol for streaming audio, video, and data over the Internet. (beta)
+
+ Configuration:
+ - enabled: Enable or disable the RTMP streaming feature. Set to 'true' to enable, 'false' to disable.
+ - fromFile: Enable or disable the RTMP streaming from File. Set to 'true' to enable, 'false' to disable.
+ - fromUrl: Enable or disable the RTMP streaming from Url. Set to 'true' to enable, 'false' to disable.
+ - fromStream: Enable or disable the RTMP Streamer. Set to 'true' to enable, 'false' to disable.
+ - maxStreams: Specifies the maximum number of simultaneous streams permitted for File, URL, and Stream. The default value is 1.
+ - server: The URL of the RTMP server. Leave empty to use the built-in MiroTalk RTMP server (rtmp://localhost:1935). Change the URL to connect to a different RTMP server.
+ - appName: The application name for the RTMP stream. Default is 'mirotalk'.
+ - streamKey: The stream key for the RTMP stream. Leave empty if not required.
+ - secret: The secret key for RTMP streaming. Must match the secret in rtmpServers/node-media-server/src/config.js. Leave empty if no authentication is needed.
+ - apiSecret: The API secret for streaming WebRTC to RTMP through the MiroTalk API.
+ - expirationHours: The number of hours before the RTMP URL expires. Default is 4 hours.
+ - dir: Directory where your video files are stored to be streamed via RTMP.
+
+ Important: Ensure your RTMP server is operational before proceeding. You can start the server by running the following command:
+ - Start: npm run nms-start - Start the RTMP server.
+ - Stop: npm run npm-stop - Stop the RTMP server.
+ - Logs: npm run npm-logs - View the logs of the RTMP server.
+ */
+ enabled: false,
+ fromFile: true,
+ fromUrl: true,
+ fromStream: true,
+ maxStreams: 1,
+ server: 'rtmp://localhost:1935',
+ appName: 'mirotalk',
+ streamKey: '',
+ secret: 'mirotalkRtmpSecret',
+ apiSecret: 'mirotalkRtmpApiSecret',
+ expirationHours: 4,
+ dir: 'rtmp',
+ },
},
middleware: {
/*
@@ -333,6 +369,7 @@ module.exports = {
lobbyButton: true, // presenter
sendEmailInvitation: true, // presenter
micOptionsButton: true, // presenter
+ tabRTMPStreamingBtn: true, // presenter
tabModerator: true, // presenter
tabRecording: true,
host_only_recording: true, // presenter
diff --git a/docker-compose.template.yml b/docker-compose.template.yml
index 1be069fb..b13620af 100644
--- a/docker-compose.template.yml
+++ b/docker-compose.template.yml
@@ -13,6 +13,8 @@ services:
- ./app/src/config.js:/src/app/src/config.js:ro
# These volume is mandatory if server.recording.enabled in the app/src/config.js
# - ./app/rec:/src/app/rec
+ # These volume is mandatory if server.rtmp.enabled fromFile in the app/src/config.js
+ # - ./app/rtmp:/src/app/rtmp
# These volumes are not mandatory, comment if you want to use it
# - ./app/:/src/app/:ro
# - ./public/:/src/public/:ro
diff --git a/package.json b/package.json
index 75f42596..0bfdc45f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "mirotalksfu",
- "version": "1.4.51",
+ "version": "1.4.70",
"description": "WebRTC SFU browser-based video calls",
"main": "Server.js",
"scripts": {
@@ -18,7 +18,16 @@
"docker-run": "docker run -d -p 40000-40100:40000-40100 -p 3010:3010 -v ./app/src/config.js:/src/app/src/config.js:ro --name mirotalksfu mirotalk/sfu:latest",
"docker-run-vm": "docker run -d -p 40000-40100:40000-40100 -p 3010:3010 -v ./app/:/src/app/:ro -v ./public/:/src/public/:ro --name mirotalksfu mirotalk/sfu:latest",
"docker-start": "docker start mirotalksfu",
- "docker-stop": "docker stop mirotalksfu"
+ "docker-stop": "docker stop mirotalksfu",
+ "rtmp-start": "docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml up -d",
+ "rtmp-stop": "docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml down",
+ "rtmp-restart": "docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml down && docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml up -d",
+ "rtmp-logs": "docker logs -f mirotalk-rtmp",
+ "nms-node-start": "node rtmpServers/node-media-server/src/server.js",
+ "nms-start": "docker-compose -f rtmpServers/node-media-server/docker-compose.yml up -d",
+ "nms-stop": "docker-compose -f rtmpServers/node-media-server/docker-compose.yml down",
+ "nms-restart": "docker-compose -f rtmpServers/node-media-server/docker-compose.yml down && docker-compose -f rtmpServers/node-media-server/docker-compose.yml up -d",
+ "nms-logs": "docker logs -f mirotalk-nms"
},
"repository": {
"type": "git",
@@ -33,7 +42,12 @@
"video",
"audio",
"openai",
- "chatgpt"
+ "chatgpt",
+ "rtmp",
+ "client",
+ "server",
+ "streaming",
+ "realtime"
],
"author": "Miroslav Pejic",
"license": "AGPL-3.0",
@@ -41,6 +55,7 @@
"node": ">=18"
},
"dependencies": {
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
"@sentry/integrations": "7.114.0",
"@sentry/node": "7.114.0",
"axios": "^1.7.2",
@@ -51,14 +66,15 @@
"crypto-js": "4.2.0",
"express": "4.19.2",
"express-openid-connect": "^2.17.1",
+ "fluent-ffmpeg": "^2.1.3",
"httpolyglot": "0.1.2",
- "jsonwebtoken": "^9.0.2",
"js-yaml": "^4.1.0",
- "mediasoup": "3.14.7",
- "mediasoup-client": "3.7.10",
+ "jsonwebtoken": "^9.0.2",
+ "mediasoup": "3.14.8",
+ "mediasoup-client": "3.7.12",
"ngrok": "^5.0.0-beta.2",
- "nodemailer": "^6.9.13",
- "openai": "^4.51.0",
+ "nodemailer": "^6.9.14",
+ "openai": "^4.52.2",
"qs": "6.12.1",
"socket.io": "4.7.5",
"swagger-ui-express": "5.0.1",
@@ -67,7 +83,7 @@
},
"devDependencies": {
"node-fetch": "^3.3.2",
- "nodemon": "^3.1.3",
+ "nodemon": "^3.1.4",
"prettier": "3.3.2"
}
}
diff --git a/public/css/Room.css b/public/css/Room.css
index b87ef7ba..df4c0a1b 100644
--- a/public/css/Room.css
+++ b/public/css/Room.css
@@ -31,56 +31,6 @@
}
}
-:root {
- --body-bg: radial-gradient(#393939, #000000);
- --border: 1px solid rgb(255 255 255 / 32%);
- --border-radius: 1rem;
- --msger-width: 800px;
- --msger-height: 700px;
- --msger-bubble-width: 85%;
- --msger-bg: radial-gradient(#393939, #000000);
- --wb-width: 800px;
- --wb-height: 600px;
- --wb-bg: radial-gradient(#393939, #000000);
- --select-bg: #2c2c2c;
- --left-msg-bg: #252d31;
- --right-msg-bg: #056162;
- --private-msg-bg: #6b1226;
- --box-shadow: 0px 8px 16px 0px rgb(0 0 0);
- --btns-hover-scale: scale(1.1);
- --settings-bg: radial-gradient(#393939, #000000);
- --tab-btn-active: rgb(42 42 42 / 70%);
- --btns-bg-color: rgba(0, 0, 0, 0.7);
- /* buttons bar horizontal */
- --btns-top: 50%;
- --btns-right: 0%;
- --btns-left: 10px;
- --btns-margin-left: 0px;
- --btns-width: 60px;
- --btns-flex-direction: column;
- /* buttons bar horizontal
- --btns-top: 95%;
- --btns-right: 25%;
- --btns-left: 50%;
- --btns-margin-left: -160px;
- --btns-width: 320px;
- --btns-flex-direction: row;
- */
-
- --transcription-height: 680px;
- --transcription-width: 420px;
- --transcription-bg: radial-gradient(#393939, #000000);
-
- --vmi-wh: 15vw;
- /* https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit */
- --videoObjFit: cover;
-}
-
-* {
- outline: none;
- font-family: 'Comfortaa';
-}
-
html,
body {
top: 0 !important;
@@ -441,6 +391,77 @@ th {
width: 180px;
}
+/*--------------------------------------------------------------
+# RTMP settings
+--------------------------------------------------------------*/
+
+.file-table {
+ margin-top: 10px;
+ color: #fff;
+ width: 100%;
+ border-collapse: collapse;
+ border: var(--border);
+ table-layout: fixed; /* Ensures equal column width */
+}
+.file-table th,
+.file-table td {
+ border: none;
+ padding: 8px;
+ text-align: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.file-table th {
+ background: var(--select-bg);
+}
+.file-table tbody {
+ display: block;
+ max-height: 80px;
+ overflow-y: auto;
+}
+.file-table tbody::-webkit-scrollbar {
+ width: 8px;
+}
+.file-table tbody::-webkit-scrollbar-thumb {
+ background: #888;
+ border-radius: 4px;
+}
+.file-item {
+ cursor: pointer;
+}
+.file-item:hover,
+.file-item.selected {
+ background: var(--body-bg);
+}
+
+#file-name {
+ margin-top: 20px;
+ margin-left: 5px;
+ font-weight: bold;
+ color: #888;
+}
+
+.input-container {
+ display: flex;
+ align-items: center;
+}
+
+#rtmpStreamURL,
+#rtmp-url {
+ margin-top: 5px;
+ padding: 10px;
+ width: 100%;
+ color: #fff;
+ border: none;
+ background: var(--select-bg) !important;
+}
+
+.input-container button {
+ flex: 1;
+ width: 20px;
+}
+
/*--------------------------------------------------------------
# Dropdown menu
--------------------------------------------------------------*/
diff --git a/public/css/Root.css b/public/css/Root.css
new file mode 100644
index 00000000..66643c8c
--- /dev/null
+++ b/public/css/Root.css
@@ -0,0 +1,51 @@
+@import url('https://fonts.googleapis.com/css?family=Comfortaa:wght@500&display=swap');
+
+:root {
+ --body-bg: radial-gradient(#393939, #000000);
+ --border: 1px solid rgb(255 255 255 / 32%);
+ --border-radius: 1rem;
+ --msger-width: 800px;
+ --msger-height: 700px;
+ --msger-bubble-width: 85%;
+ --msger-bg: radial-gradient(#393939, #000000);
+ --wb-width: 800px;
+ --wb-height: 600px;
+ --wb-bg: radial-gradient(#393939, #000000);
+ --select-bg: #2c2c2c;
+ --left-msg-bg: #252d31;
+ --right-msg-bg: #056162;
+ --private-msg-bg: #6b1226;
+ --box-shadow: 0px 8px 16px 0px rgb(0 0 0);
+ --btns-hover-scale: scale(1.1);
+ --settings-bg: radial-gradient(#393939, #000000);
+ --tab-btn-active: rgb(42 42 42 / 70%);
+ --btns-bg-color: rgba(0, 0, 0, 0.7);
+ /* buttons bar horizontal */
+ --btns-top: 50%;
+ --btns-right: 0%;
+ --btns-left: 10px;
+ --btns-margin-left: 0px;
+ --btns-width: 60px;
+ --btns-flex-direction: column;
+ /* buttons bar horizontal
+ --btns-top: 95%;
+ --btns-right: 25%;
+ --btns-left: 50%;
+ --btns-margin-left: -160px;
+ --btns-width: 320px;
+ --btns-flex-direction: row;
+ */
+
+ --transcription-height: 680px;
+ --transcription-width: 420px;
+ --transcription-bg: radial-gradient(#393939, #000000);
+
+ --vmi-wh: 15vw;
+ /* https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit */
+ --videoObjFit: cover;
+}
+
+* {
+ outline: none;
+ font-family: 'Comfortaa';
+}
diff --git a/public/css/RtmpStreamer.css b/public/css/RtmpStreamer.css
new file mode 100644
index 00000000..86540282
--- /dev/null
+++ b/public/css/RtmpStreamer.css
@@ -0,0 +1,206 @@
+@import url('https://fonts.googleapis.com/css?family=Comfortaa:wght@500&display=swap');
+
+body {
+ font-family: 'Comfortaa'; /*, Arial, sans-serif;*/
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100vh;
+ margin: 0;
+ background: var(--body-bg);
+ color: #fff;
+}
+
+.container {
+ max-width: 800px;
+ margin: 0 auto;
+ text-align: center;
+ padding: 20px;
+ background: var(--body-bg);
+ color: #fff;
+ border-radius: 8px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+}
+
+h1 {
+ margin-top: 10px;
+ color: #ffffff;
+}
+
+video {
+ border: 0.1px solid #ccc;
+ margin: 10px 0;
+ border-radius: 8px;
+ box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
+}
+
+.input-group-inline {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+#apiSecret {
+ flex: 1;
+}
+
+#rtmp {
+ flex: 1;
+}
+
+#copyButton {
+ flex: 1;
+ max-width: 100px;
+}
+
+.input-group-inline > * {
+ margin-bottom: 20px;
+}
+
+input,
+button {
+ padding: 10px;
+ font-size: 16px;
+ border: none;
+ border-radius: 4px;
+ outline: none;
+ box-sizing: border-box;
+}
+
+input[type='text'],
+input[type='password'] {
+ flex: 1;
+ background: #2c2c2c;
+ color: #fff;
+}
+
+input[type='text'][readonly] {
+ background: #2c2c2c;
+ color: #fff;
+}
+
+button {
+ cursor: pointer;
+ background-color: #007bff;
+ color: #fff;
+ transition: background-color 0.3s ease;
+}
+
+button:disabled {
+ background: #2c2c2c;
+ cursor: not-allowed;
+}
+button:hover {
+ background-color: #0056b3;
+}
+.button-group {
+ display: flex;
+ justify-content: center;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.button-group button {
+ width: 100%;
+}
+
+.popup {
+ position: fixed;
+ top: 10px;
+ left: 50%;
+ transform: translateX(-50%);
+ background-color: indianred;
+ color: white;
+ padding: 15px;
+ border-radius: 5px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ min-width: 300px;
+ max-width: 600px;
+ width: 80%;
+}
+
+.popup.success {
+ background-color: mediumseagreen;
+ color: white;
+}
+
+.popup.error {
+ background-color: indianred;
+ color: white;
+}
+
+.popup.warning {
+ background-color: gold;
+ color: white;
+}
+
+.popup.info {
+ background-color: cornflowerblue;
+ color: white;
+}
+
+.popup.hidden {
+ display: none;
+}
+
+#closePopup {
+ background: none;
+ border: none;
+ color: white;
+ font-size: 16px;
+ cursor: pointer;
+ margin-left: 20px;
+}
+
+footer {
+ color: grey;
+}
+
+/* Media Queries for Responsiveness */
+@media (max-width: 1024px) {
+ .container {
+ padding: 15px;
+ }
+ input,
+ button {
+ font-size: 14px;
+ }
+}
+
+@media (max-width: 768px) {
+ .input-group-inline {
+ flex-direction: column;
+ }
+ input,
+ button {
+ width: 100%;
+ font-size: 14px;
+ margin-bottom: 10px;
+ }
+ video {
+ width: 100%;
+ height: auto;
+ }
+ #copyButton {
+ max-width: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .container {
+ padding: 10px;
+ }
+ h1 {
+ font-size: 24px;
+ }
+ input,
+ button {
+ font-size: 12px;
+ padding: 8px;
+ }
+}
diff --git a/public/images/rtmp.png b/public/images/rtmp.png
new file mode 100644
index 00000000..a8e8a0a8
Binary files /dev/null and b/public/images/rtmp.png differ
diff --git a/public/js/Room.js b/public/js/Room.js
index ca48f3c9..a6bb6b58 100644
--- a/public/js/Room.js
+++ b/public/js/Room.js
@@ -11,7 +11,7 @@ if (location.href.substr(0, 5) !== 'https') location.href = 'https' + location.h
* @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon
* @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970
* @author Miroslav Pejic - miroslav.pejic.85@gmail.com
- * @version 1.4.51
+ * @version 1.4.70
*
*/
@@ -1130,6 +1130,17 @@ function copyRoomURL() {
userLog('info', 'Meeting URL copied to clipboard 👍', 'top-end');
}
+function copyToClipboard(txt) {
+ let tmpInput = document.createElement('input');
+ document.body.appendChild(tmpInput);
+ tmpInput.value = txt;
+ tmpInput.select();
+ tmpInput.setSelectionRange(0, 99999); // For mobile devices
+ navigator.clipboard.writeText(tmpInput.value);
+ document.body.removeChild(tmpInput);
+ userLog('info', `${txt} copied to clipboard 👍`, 'top-end');
+}
+
function shareRoomByEmail() {
Swal.fire({
allowOutsideClick: false,
@@ -1265,6 +1276,11 @@ function roomIsReady() {
BUTTONS.chat.chatPinButton && show(chatTogglePin);
BUTTONS.chat.chatMaxButton && show(chatMaxButton);
BUTTONS.settings.pushToTalk && show(pushToTalkDiv);
+ BUTTONS.settings.tabRTMPStreamingBtn &&
+ show(tabRTMPStreamingBtn) &&
+ show(startRtmpButton) &&
+ show(startRtmpURLButton) &&
+ show(streamerRtmpButton);
}
if (DetectRTC.browser.name != 'Safari') {
document.onfullscreenchange = () => {
@@ -1431,6 +1447,10 @@ function handleButtons() {
tabVideoShareBtn.onclick = (e) => {
rc.openTab(e, 'tabVideoShare');
};
+ tabRTMPStreamingBtn.onclick = (e) => {
+ rc.getRTMP();
+ rc.openTab(e, 'tabRTMPStreaming');
+ };
tabAspectBtn.onclick = (e) => {
rc.openTab(e, 'tabAspect');
};
@@ -1655,6 +1675,28 @@ function handleButtons() {
stopScreenButton.onclick = () => {
rc.closeProducer(RoomClient.mediaType.screen);
};
+ copyRtmpUrlButton.onclick = () => {
+ rc.copyRTMPUrl(rtmpStreamURL.value);
+ };
+ startRtmpButton.onclick = () => {
+ if (rc.selectedRtmpFilename == '') {
+ userLog('warning', 'Please select the Video file to stream', 'top-end', 6000);
+ return;
+ }
+ rc.startRTMP();
+ };
+ stopRtmpButton.onclick = () => {
+ rc.stopRTMP();
+ };
+ streamerRtmpButton.onclick = () => {
+ openURL('/rtmp', true);
+ };
+ startRtmpURLButton.onclick = () => {
+ rc.startRTMPfromURL(rtmpStreamURL.value);
+ };
+ stopRtmpURLButton.onclick = () => {
+ rc.stopRTMPfromURL();
+ };
fileShareButton.onclick = () => {
rc.selectFileToShare(socket.id, true);
};
@@ -2723,6 +2765,36 @@ function handleRoomClientEvents() {
hostOnlyRecording = false;
}
});
+ rc.on(RoomClient.EVENTS.startRTMP, () => {
+ console.log('Room event: RTMP started');
+ hide(startRtmpButton);
+ show(stopRtmpButton);
+ });
+ rc.on(RoomClient.EVENTS.stopRTMP, () => {
+ console.log('Room event: RTMP stopped');
+ hide(stopRtmpButton);
+ show(startRtmpButton);
+ });
+ rc.on(RoomClient.EVENTS.endRTMP, () => {
+ console.log('Room event: RTMP ended');
+ hide(stopRtmpButton);
+ show(startRtmpButton);
+ });
+ rc.on(RoomClient.EVENTS.startRTMPfromURL, () => {
+ console.log('Room event: RTMP from URL started');
+ hide(startRtmpURLButton);
+ show(stopRtmpURLButton);
+ });
+ rc.on(RoomClient.EVENTS.stopRTMPfromURL, () => {
+ console.log('Room event: RTMP from URL stopped');
+ hide(stopRtmpURLButton);
+ show(startRtmpURLButton);
+ });
+ rc.on(RoomClient.EVENTS.endRTMPfromURL, () => {
+ console.log('Room event: RTMP from URL ended');
+ hide(stopRtmpURLButton);
+ show(startRtmpURLButton);
+ });
rc.on(RoomClient.EVENTS.exitRoom, () => {
console.log('Room event: Client leave room');
if (rc.isRecording() || recordingStatus.innerText != '0s') {
@@ -3960,7 +4032,7 @@ function showAbout() {
imageUrl: image.about,
customClass: { image: 'img-about' },
position: 'center',
- title: 'WebRTC SFU v1.4.51',
+ title: 'WebRTC SFU v1.4.60',
html: `
${rtmp}
+ `, + showDenyButton: false, + showCancelButton: false, + confirmButtonText: `Copy URL`, + showClass: { popup: 'animate__animated animate__fadeInDown' }, + hideClass: { popup: 'animate__animated animate__fadeOutUp' }, + }).then((result) => { + if (result.isConfirmed) { + copyToClipboard(rtmp); + } + }); + } + sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/public/js/RtmpStreamer.js b/public/js/RtmpStreamer.js new file mode 100644 index 00000000..8f698e9f --- /dev/null +++ b/public/js/RtmpStreamer.js @@ -0,0 +1,256 @@ +'use strict'; + +const videoElement = document.getElementById('video'); +const startCameraButton = document.getElementById('startCamera'); +const startScreenButton = document.getElementById('startScreen'); +const stopButton = document.getElementById('stop'); +const apiSecretInput = document.getElementById('apiSecret'); // Replace with your actual API secret +const rtmpInput = document.getElementById('rtmp'); +const copyButton = document.getElementById('copy'); +const popup = document.getElementById('popup'); +const popupMessage = document.getElementById('popupMessage'); +const closePopup = document.getElementById('closePopup'); + +/* +Low Latency: 1-2 seconds +Standard Use Case: 5 seconds +High Bandwidth/Stability: 10 seconds +*/ +const chunkDuration = 4000; // ms + +let mediaRecorder = null; +let rtmpKey = null; // To store the RTMP key + +function toggleButtons(disabled = true) { + startCameraButton.disabled = disabled; + startScreenButton.disabled = disabled; + stopButton.disabled = disabled; +} + +function showPopup(message, type) { + popup.classList.remove('success', 'error', 'warning', 'info'); + popup.classList.add(type); + popupMessage.textContent = message; + popup.classList.remove('hidden'); + setTimeout(() => { + hidePopup(); + }, 5000); // Hide after 5 seconds +} + +function hidePopup() { + popup.classList.add('hidden'); +} + +function showError(message) { + showPopup(message, 'error'); +} + +function checkBrowserSupport() { + const userAgent = navigator.userAgent.toLowerCase(); + + console.log('UserAgent', userAgent); + + if (userAgent.includes('chrome') && !userAgent.includes('edge') && !userAgent.includes('opr')) { + console.log('Browser is Chrome-based. Proceed with functionality.'); + } else { + toggleButtons(true); + alert( + 'This application requires a Chrome-based browser (Chrome, Edge Chromium, etc.). Please switch to a supported browser.', + ); + // window.open('about:blank', '_self').close(); + } +} + +function checkRTMPEnabled() { + axios + .get('/rtmpEnabled') + .then((response) => { + const { enabled } = response.data; + if (!enabled) { + showPopup('The RTMP streaming feature has been disabled by the administrator', 'info'); + toggleButtons(true); + } + }) + .catch((error) => { + console.error('Error fetching RTMP status:', error); + showError(`Error fetching RTMP status: ${error.message}`); + }); +} + +window.onload = function () { + checkBrowserSupport(); + checkRTMPEnabled(); +}; + +async function startCapture(constraints) { + try { + const stream = await navigator.mediaDevices.getUserMedia(constraints); + videoElement.srcObject = stream; + return stream; + } catch (err) { + console.error('Error accessing media devices.', err); + showError('Error accessing media devices. Please check your camera and microphone permissions.'); + } +} + +async function startScreenCapture(constraints) { + try { + const stream = await navigator.mediaDevices.getDisplayMedia(constraints); + videoElement.srcObject = stream; + return stream; + } catch (err) { + console.error('Error accessing screen media.', err); + showError('Error accessing screen sharing. Please try again or check your screen sharing permissions.'); + } +} + +async function initRTMP(stream) { + const apiSecret = apiSecretInput.value; + try { + const response = await axios.post(`/initRTMP`, null, { + headers: { + authorization: apiSecret, + }, + }); + const { rtmp } = response.data; + console.log('initRTMP response:', { res: response, rtmp: rtmp }); + rtmpInput.value = rtmp; + rtmpKey = new URL(rtmp).pathname.split('/').pop(); // Extract the RTMP key from the URL + toggleButtons(true); + stopButton.disabled = false; // Enable stopButton on successful initialization + return true; + } catch (error) { + if (error.response) { + const { status, data } = error.response; + showPopup(data, 'info'); + console.log('Init RTMP', { + status, + data, + }); + } else { + showError('Error initializing RTMP. Please try again.'); + console.error('Error initializing RTMP:', error); + } + stopStreaming(); + stopTracks(stream); + return false; + } +} + +async function stopRTMP() { + const apiSecret = apiSecretInput.value; + + stopStreaming(); + + try { + await axios.post(`/stopRTMP?key=${rtmpKey}`, null, { + headers: { + authorization: apiSecret, + }, + }); + } catch (error) { + showError('Error stopping RTMP. Please try again.'); + console.error('Error stopping RTMP:', error); + } +} + +async function streamRTMPChunk(data) { + const apiSecret = apiSecretInput.value; + + const arrayBuffer = await data.arrayBuffer(); + const chunkSize = 1000000; // 1mb + const totalChunks = Math.ceil(arrayBuffer.byteLength / chunkSize); + + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const chunk = arrayBuffer.slice(chunkIndex * chunkSize, (chunkIndex + 1) * chunkSize); + try { + await axios.post(`/streamRTMP?key=${rtmpKey}`, chunk, { + headers: { + authorization: apiSecret, + 'Content-Type': 'video/webm', + }, + }); + } catch (error) { + if (mediaRecorder) { + stopStreaming(); + console.error('Error syncing chunk:', error.message); + showError(`Error syncing chunk: ${error.message}`); + } + } + } +} + +function stopStreaming() { + if (mediaRecorder) { + mediaRecorder.stop(); + } + videoElement.srcObject = null; + rtmpInput.value = ''; + toggleButtons(false); + stopButton.disabled = true; +} + +async function startStreaming(stream) { + if (!stream) return; + + const initRTMPStream = await initRTMP(stream); + + if (!initRTMPStream) { + return; + } + + mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm; codecs=vp8,opus' }); + + mediaRecorder.ondataavailable = async (event) => { + if (event.data.size > 0) { + await streamRTMPChunk(event.data); + } + }; + + mediaRecorder.onstop = (event) => { + console.log('Media recorder stopped'); + stopTracks(stream); + mediaRecorder = null; + }; + + mediaRecorder.start(chunkDuration); // Record in chunks of the specified duration +} + +function stopTracks(stream) { + stream.getTracks().forEach((track) => { + track.stop(); + }); +} + +async function startCameraStreaming() { + const stream = await startCapture({ video: true, audio: true }); + await startStreaming(stream); +} + +async function startScreenStreaming() { + const stream = await startScreenCapture({ video: true, audio: true }); + await startStreaming(stream); +} + +function copyRTMP() { + const rtmpInput = document.getElementById('rtmp'); + if (!rtmpInput.value) { + return showPopup('No RTMP URL detected', 'info'); + } + rtmpInput.select(); + document.execCommand('copy'); + showPopup('Copied: ' + rtmpInput.value, 'success'); +} + +startCameraButton.addEventListener('click', startCameraStreaming); +startScreenButton.addEventListener('click', startScreenStreaming); +stopButton.addEventListener('click', stopRTMP); +copyButton.addEventListener('click', copyRTMP); +closePopup.addEventListener('click', hidePopup); + +// Stop RTMP streaming when the browser tab is closed +window.addEventListener('beforeunload', async (event) => { + if (mediaRecorder) { + await stopRTMP(); + } +}); diff --git a/public/js/Rules.js b/public/js/Rules.js index 6aff9469..cf270622 100644 --- a/public/js/Rules.js +++ b/public/js/Rules.js @@ -38,6 +38,7 @@ let BUTTONS = { lobbyButton: true, // presenter sendEmailInvitation: true, // presenter micOptionsButton: true, // presenter + tabRTMPStreamingBtn: true, // presenter tabModerator: true, // presenter tabRecording: true, host_only_recording: true, // presenter @@ -113,6 +114,7 @@ function handleRules(isPresenter) { BUTTONS.settings.lobbyButton = false; BUTTONS.settings.sendEmailInvitation = false; BUTTONS.settings.micOptionsButton = false; + BUTTONS.settings.tabRTMPStreamingBtn = false; BUTTONS.settings.tabModerator = false; BUTTONS.videoOff.muteAudioButton = false; BUTTONS.videoOff.geolocationButton = false; @@ -130,6 +132,7 @@ function handleRules(isPresenter) { // PRESENTER // ################################## BUTTONS.main.shareButton = true; + BUTTONS.settings.tabRTMPStreamingBtn = true; BUTTONS.settings.lockRoomButton = BUTTONS.settings.lockRoomButton && !isRoomLocked; BUTTONS.settings.unlockRoomButton = BUTTONS.settings.lockRoomButton && isRoomLocked; BUTTONS.settings.sendEmailInvitation = true; @@ -177,6 +180,14 @@ function handleRules(isPresenter) { } // main. settings... BUTTONS.main.shareButton ? show(shareButton) : hide(shareButton); + if (BUTTONS.settings.tabRTMPStreamingBtn) { + show(tabRTMPStreamingBtn); + show(startRtmpButton); + show(startRtmpURLButton); + show(streamerRtmpButton); + } else { + hide(tabRTMPStreamingBtn); + } BUTTONS.settings.lockRoomButton ? show(lockRoomButton) : hide(lockRoomButton); BUTTONS.settings.unlockRoomButton ? show(unlockRoomButton) : hide(unlockRoomButton); BUTTONS.settings.broadcastingButton ? show(broadcastingButton) : hide(broadcastingButton); @@ -217,6 +228,7 @@ function handleRulesBroadcasting() { BUTTONS.settings.lockRoomButton = false; BUTTONS.settings.unlockRoomButton = false; BUTTONS.settings.lobbyButton = false; + BUTTONS.settings.tabRTMPStreamingBtn = false; BUTTONS.videoOff.muteAudioButton = false; BUTTONS.videoOff.geolocationButton = false; BUTTONS.videoOff.banButton = false; @@ -248,5 +260,6 @@ function handleRulesBroadcasting() { elemDisplay('unlockRoomButton', false); elemDisplay('lobbyButton', false); elemDisplay('settingsButton', false); + elemDisplay('tabRTMPStreamingBtn', false); //... } diff --git a/public/js/Transcription.js b/public/js/Transcription.js index e86155a9..f7bf06ec 100644 --- a/public/js/Transcription.js +++ b/public/js/Transcription.js @@ -18,6 +18,9 @@ class Transcription { ['en-ZA', 'South Africa'], ['en-GB', 'United Kingdom'], ['en-US', 'United States'], + ['en-NG', 'Nigeria'], + ['en-GH', 'Ghana'], + ['en-KE', 'Kenya'], ], [ 'Español', diff --git a/public/sfu/MediasoupClient.js b/public/sfu/MediasoupClient.js index 761fb9c6..655eb38a 100644 --- a/public/sfu/MediasoupClient.js +++ b/public/sfu/MediasoupClient.js @@ -1944,7 +1944,8 @@ return result; }; Object.defineProperty(exports, '__esModule', { value: true }); - exports.Device = exports.detectDevice = void 0; + exports.Device = void 0; + exports.detectDevice = detectDevice; const ua_parser_js_1 = require('ua-parser-js'); const Logger_1 = require('./Logger'); const EnhancedEventEmitter_1 = require('./EnhancedEventEmitter'); @@ -2097,7 +2098,6 @@ return undefined; } } - exports.detectDevice = detectDevice; class Device { /** * Create a new Device to connect to mediasoup server. @@ -12226,7 +12226,8 @@ return result; }; Object.defineProperty(exports, '__esModule', { value: true }); - exports.mangleRtpParameters = exports.getCapabilities = void 0; + exports.getCapabilities = getCapabilities; + exports.mangleRtpParameters = mangleRtpParameters; const utils = __importStar(require('../../utils')); /** * Normalize ORTC based Edge's RTCRtpReceiver.getCapabilities() to produce a full @@ -12263,7 +12264,6 @@ } return caps; } - exports.getCapabilities = getCapabilities; /** * Generate RTCRtpParameters as ORTC based Edge likes. */ @@ -12294,7 +12294,6 @@ } return params; } - exports.mangleRtpParameters = mangleRtpParameters; }, { '../../utils': 42 }, ], @@ -12302,7 +12301,7 @@ function (require, module, exports) { 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); - exports.addNackSuppportForOpus = void 0; + exports.addNackSuppportForOpus = addNackSuppportForOpus; /** * This function adds RTCP NACK support for OPUS codec in given capabilities. */ @@ -12320,7 +12319,6 @@ } } } - exports.addNackSuppportForOpus = addNackSuppportForOpus; }, {}, ], @@ -13346,11 +13344,10 @@ return result; }; Object.defineProperty(exports, '__esModule', { value: true }); - exports.applyCodecParameters = - exports.getCname = - exports.extractDtlsParameters = - exports.extractRtpCapabilities = - void 0; + exports.extractRtpCapabilities = extractRtpCapabilities; + exports.extractDtlsParameters = extractDtlsParameters; + exports.getCname = getCname; + exports.applyCodecParameters = applyCodecParameters; const sdpTransform = __importStar(require('sdp-transform')); /** * This function must be called with an SDP with 1 m=audio and 1 m=video @@ -13459,7 +13456,6 @@ }; return rtpCapabilities; } - exports.extractRtpCapabilities = extractRtpCapabilities; function extractDtlsParameters({ sdpObject }) { let setup = sdpObject.setup; let fingerprint = sdpObject.fingerprint; @@ -13501,7 +13497,6 @@ }; return dtlsParameters; } - exports.extractDtlsParameters = extractDtlsParameters; function getCname({ offerMediaObject }) { const ssrcCnameLine = (offerMediaObject.ssrcs || []).find((line) => line.attribute === 'cname'); if (!ssrcCnameLine) { @@ -13509,7 +13504,6 @@ } return ssrcCnameLine.value; } - exports.getCname = getCname; /** * Apply codec parameters in the given SDP m= section answer based on the * given RTP parameters of an offer. @@ -13552,7 +13546,6 @@ } } } - exports.applyCodecParameters = applyCodecParameters; }, { 'sdp-transform': 47 }, ], @@ -13560,7 +13553,8 @@ function (require, module, exports) { 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); - exports.addLegacySimulcast = exports.getRtpEncodings = void 0; + exports.getRtpEncodings = getRtpEncodings; + exports.addLegacySimulcast = addLegacySimulcast; function getRtpEncodings({ offerMediaObject, track }) { // First media SSRC (or the only one). let firstSsrc; @@ -13615,7 +13609,6 @@ } return encodings; } - exports.getRtpEncodings = getRtpEncodings; /** * Adds multi-ssrc based simulcast into the given SDP media section offer. */ @@ -13709,7 +13702,6 @@ }); } } - exports.addLegacySimulcast = addLegacySimulcast; }, {}, ], @@ -13717,7 +13709,8 @@ function (require, module, exports) { 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); - exports.addLegacySimulcast = exports.getRtpEncodings = void 0; + exports.getRtpEncodings = getRtpEncodings; + exports.addLegacySimulcast = addLegacySimulcast; function getRtpEncodings({ offerMediaObject }) { const ssrcs = new Set(); for (const line of offerMediaObject.ssrcs || []) { @@ -13761,7 +13754,6 @@ } return encodings; } - exports.getRtpEncodings = getRtpEncodings; /** * Adds multi-ssrc based simulcast into the given SDP media section offer. */ @@ -13841,7 +13833,6 @@ }); } } - exports.addLegacySimulcast = addLegacySimulcast; }, {}, ], @@ -13922,7 +13913,7 @@ /** * Expose mediasoup-client version. */ - exports.version = '3.7.10'; + exports.version = '3.7.12'; /** * Expose parseScalabilityMode() function. */ @@ -13981,19 +13972,18 @@ return result; }; Object.defineProperty(exports, '__esModule', { value: true }); - exports.canReceive = - exports.canSend = - exports.generateProbatorRtpParameters = - exports.reduceCodecs = - exports.getSendingRemoteRtpParameters = - exports.getSendingRtpParameters = - exports.getRecvRtpCapabilities = - exports.getExtendedRtpCapabilities = - exports.validateSctpCapabilities = - exports.validateSctpStreamParameters = - exports.validateRtpParameters = - exports.validateRtpCapabilities = - void 0; + exports.validateRtpCapabilities = validateRtpCapabilities; + exports.validateRtpParameters = validateRtpParameters; + exports.validateSctpStreamParameters = validateSctpStreamParameters; + exports.validateSctpCapabilities = validateSctpCapabilities; + exports.getExtendedRtpCapabilities = getExtendedRtpCapabilities; + exports.getRecvRtpCapabilities = getRecvRtpCapabilities; + exports.getSendingRtpParameters = getSendingRtpParameters; + exports.getSendingRemoteRtpParameters = getSendingRemoteRtpParameters; + exports.reduceCodecs = reduceCodecs; + exports.generateProbatorRtpParameters = generateProbatorRtpParameters; + exports.canSend = canSend; + exports.canReceive = canReceive; const h264 = __importStar(require('h264-profile-level-id')); const utils = __importStar(require('./utils')); const RTP_PROBATOR_MID = 'probator'; @@ -14027,7 +14017,6 @@ validateRtpHeaderExtension(ext); } } - exports.validateRtpCapabilities = validateRtpCapabilities; /** * Validates RtpParameters. It may modify given data by adding missing * fields with default values. @@ -14074,7 +14063,6 @@ } validateRtcpParameters(params.rtcp); } - exports.validateRtpParameters = validateRtpParameters; /** * Validates SctpStreamParameters. It may modify given data by adding missing * fields with default values. @@ -14120,7 +14108,6 @@ throw new TypeError('invalid params.protocol'); } } - exports.validateSctpStreamParameters = validateSctpStreamParameters; /** * Validates SctpCapabilities. It may modify given data by adding missing * fields with default values. @@ -14136,7 +14123,6 @@ } validateNumSctpStreams(caps.numStreams); } - exports.validateSctpCapabilities = validateSctpCapabilities; /** * Generate extended RTP capabilities for sending and receiving. */ @@ -14225,7 +14211,6 @@ } return extendedRtpCapabilities; } - exports.getExtendedRtpCapabilities = getExtendedRtpCapabilities; /** * Generate RTP capabilities for receiving media based on the given extended * RTP capabilities. @@ -14279,7 +14264,6 @@ } return rtpCapabilities; } - exports.getRecvRtpCapabilities = getRecvRtpCapabilities; /** * Generate RTP parameters of the given kind for sending media. * NOTE: mid, encodings and rtcp fields are left empty. @@ -14337,7 +14321,6 @@ } return rtpParameters; } - exports.getSendingRtpParameters = getSendingRtpParameters; /** * Generate RTP parameters of the given kind suitable for the remote SDP answer. */ @@ -14419,7 +14402,6 @@ } return rtpParameters; } - exports.getSendingRemoteRtpParameters = getSendingRemoteRtpParameters; /** * Reduce given codecs by returning an array of codecs "compatible" with the * given capability codec. If no capability codec is given, take the first @@ -14456,7 +14438,6 @@ } return filteredCodecs; } - exports.reduceCodecs = reduceCodecs; /** * Create RTP parameters for a Consumer for the RTP probator. */ @@ -14477,14 +14458,12 @@ rtpParameters.headerExtensions = videoRtpParameters.headerExtensions; return rtpParameters; } - exports.generateProbatorRtpParameters = generateProbatorRtpParameters; /** * Whether media can be sent based on the given RTP capabilities. */ function canSend(kind, extendedRtpCapabilities) { return extendedRtpCapabilities.codecs.some((codec) => codec.kind === kind); } - exports.canSend = canSend; /** * Whether the given RTP parameters can be received with the given RTP * capabilities. @@ -14500,7 +14479,6 @@ (codec) => codec.remotePayloadType === firstMediaCodec.payloadType, ); } - exports.canReceive = canReceive; /** * Validates RtpCodecCapability. It may modify given data by adding missing * fields with default values. @@ -14883,7 +14861,7 @@ function (require, module, exports) { 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); - exports.parse = void 0; + exports.parse = parse; const ScalabilityModeRegex = new RegExp('^[LS]([1-9]\\d{0,1})T([1-9]\\d{0,1})'); function parse(scalabilityMode) { const match = ScalabilityModeRegex.exec(scalabilityMode || ''); @@ -14899,7 +14877,6 @@ }; } } - exports.parse = parse; }, {}, ], @@ -14962,7 +14939,9 @@ function (require, module, exports) { 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); - exports.deepFreeze = exports.generateRandomNumber = exports.clone = void 0; + exports.clone = clone; + exports.generateRandomNumber = generateRandomNumber; + exports.deepFreeze = deepFreeze; /** * Clones the given value. */ @@ -14978,14 +14957,12 @@ return JSON.parse(JSON.stringify(value)); } } - exports.clone = clone; /** * Generates a random positive integer. */ function generateRandomNumber() { return Math.round(Math.random() * 10000000); } - exports.generateRandomNumber = generateRandomNumber; /** * Make an object or array recursively immutable. * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze. @@ -15002,7 +14979,6 @@ } return Object.freeze(object); } - exports.deepFreeze = deepFreeze; }, {}, ], diff --git a/public/views/Room.html b/public/views/Room.html index b7aea163..32ae8a07 100644 --- a/public/views/Room.html +++ b/public/views/Room.html @@ -38,6 +38,7 @@ + @@ -215,6 +216,9 @@ access to use this app. + @@ -941,7 +945,64 @@ access to use this app.Close Video or Audio
-| Video Files: | +
|---|
Stream from URL:
+ + + +| Stream | +Bitrate (kbps) | +Bytes | +Client | +BW (kbps) | +Time | +
|---|---|---|---|---|---|