From 392921263110f7c4f388f2079893eae5a1f73a9a Mon Sep 17 00:00:00 2001 From: Miroslav Pejic Date: Sat, 29 Jun 2024 18:49:10 +0200 Subject: [PATCH] [mirotalksfu] - add RTMP server and multi-source streaming!, update dep --- .gitignore | 3 +- README.md | 2 + app/src/Room.js | 175 ++++++++++ app/src/RtmpFile.js | 97 ++++++ app/src/RtmpStreamer.js | 74 ++++ app/src/RtmpUrl.js | 93 +++++ app/src/Server.js | 319 +++++++++++++++++- app/src/config.template.js | 37 ++ docker-compose.template.yml | 2 + package.json | 34 +- public/css/Room.css | 121 ++++--- public/css/Root.css | 51 +++ public/css/RtmpStreamer.css | 206 +++++++++++ public/images/rtmp.png | Bin 0 -> 1267 bytes public/js/Room.js | 76 ++++- public/js/RoomClient.js | 268 ++++++++++++++- public/js/RtmpStreamer.js | 256 ++++++++++++++ public/js/Rules.js | 13 + public/js/Transcription.js | 3 + public/sfu/MediasoupClient.js | 84 ++--- public/views/Room.html | 63 +++- public/views/RtmpStreamer.html | 97 ++++++ rtmpServers/README.md | 9 + .../demo/client-server-axios/client/client.js | 256 ++++++++++++++ .../client-server-axios/client/index.html | 51 +++ .../demo/client-server-axios/client/logo.svg | 1 + .../demo/client-server-axios/client/style.css | 205 +++++++++++ .../server/RtmpStreamer.js | 67 ++++ .../client-server-axios/server/package.json | 28 ++ .../demo/client-server-axios/server/server.js | 182 ++++++++++ .../client-server-socket/client/client.js | 212 ++++++++++++ .../client-server-socket/client/index.html | 52 +++ .../demo/client-server-socket/client/logo.svg | 1 + .../client-server-socket/client/style.css | 205 +++++++++++ .../server/RtmpStreamer.js | 68 ++++ .../client-server-socket/server/package.json | 30 ++ .../client-server-socket/server/server.js | 179 ++++++++++ rtmpServers/nginx-rtmp/Dockerfile | 61 ++++ rtmpServers/nginx-rtmp/README.md | 30 ++ .../nginx-rtmp/docker-compose.template.yml | 14 + rtmpServers/nginx-rtmp/nginx.conf | 34 ++ rtmpServers/nginx-rtmp/stat.xsl | 30 ++ rtmpServers/node-media-server/Dockerfile | 26 ++ rtmpServers/node-media-server/README.md | 47 +++ .../docker-compose.template.yml | 13 + rtmpServers/node-media-server/package.json | 26 ++ rtmpServers/node-media-server/src/cert.pem | 21 ++ .../node-media-server/src/config.template.js | 30 ++ rtmpServers/node-media-server/src/key.pem | 27 ++ rtmpServers/node-media-server/src/server.js | 81 +++++ rtmpServers/node-media-server/src/sign.js | 58 ++++ rtmpServers/rtmpStreaming.jpeg | Bin 0 -> 61671 bytes 52 files changed, 3986 insertions(+), 132 deletions(-) create mode 100644 app/src/RtmpFile.js create mode 100644 app/src/RtmpStreamer.js create mode 100644 app/src/RtmpUrl.js create mode 100644 public/css/Root.css create mode 100644 public/css/RtmpStreamer.css create mode 100644 public/images/rtmp.png create mode 100644 public/js/RtmpStreamer.js create mode 100644 public/views/RtmpStreamer.html create mode 100644 rtmpServers/README.md create mode 100644 rtmpServers/demo/client-server-axios/client/client.js create mode 100644 rtmpServers/demo/client-server-axios/client/index.html create mode 100644 rtmpServers/demo/client-server-axios/client/logo.svg create mode 100644 rtmpServers/demo/client-server-axios/client/style.css create mode 100644 rtmpServers/demo/client-server-axios/server/RtmpStreamer.js create mode 100644 rtmpServers/demo/client-server-axios/server/package.json create mode 100644 rtmpServers/demo/client-server-axios/server/server.js create mode 100644 rtmpServers/demo/client-server-socket/client/client.js create mode 100644 rtmpServers/demo/client-server-socket/client/index.html create mode 100644 rtmpServers/demo/client-server-socket/client/logo.svg create mode 100644 rtmpServers/demo/client-server-socket/client/style.css create mode 100644 rtmpServers/demo/client-server-socket/server/RtmpStreamer.js create mode 100644 rtmpServers/demo/client-server-socket/server/package.json create mode 100644 rtmpServers/demo/client-server-socket/server/server.js create mode 100644 rtmpServers/nginx-rtmp/Dockerfile create mode 100644 rtmpServers/nginx-rtmp/README.md create mode 100644 rtmpServers/nginx-rtmp/docker-compose.template.yml create mode 100644 rtmpServers/nginx-rtmp/nginx.conf create mode 100644 rtmpServers/nginx-rtmp/stat.xsl create mode 100644 rtmpServers/node-media-server/Dockerfile create mode 100644 rtmpServers/node-media-server/README.md create mode 100644 rtmpServers/node-media-server/docker-compose.template.yml create mode 100644 rtmpServers/node-media-server/package.json create mode 100644 rtmpServers/node-media-server/src/cert.pem create mode 100644 rtmpServers/node-media-server/src/config.template.js create mode 100644 rtmpServers/node-media-server/src/key.pem create mode 100644 rtmpServers/node-media-server/src/server.js create mode 100644 rtmpServers/node-media-server/src/sign.js create mode 100644 rtmpServers/rtmpStreaming.jpeg 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 0000000000000000000000000000000000000000..a8e8a0a8047bb32a10290cf212909bf5f831e96e GIT binary patch literal 1267 zcmeAS@N?(olHy`uVBq!ia0vp^CxFw-W4XIz%5{MUOP4qKMh zoZf$Bj2`BXV+5fWp}r`Nc64Tc>^8mA|jt*<(ib^^43tc{iShKNIND6pojy zNq_ZrZScH~e=GZ6zfJS1dRnR`rxt(1Tb?T?Y)VCFSF`Zy!%8dMT2`1guXhdkR>aVA zs30v+#np7NkgH!^Q{KFsU+Hxo*{l15S8QLszc)DP_41BiK`)rEvrfEhk!t_PZ|CC$ zHWTfZXw0)-Dr?Z}?Is}=HQ}I3M=VFBSeD}we))*#$(MEo?c(@Yk|g{@_~llG_N8;{ zHJw_IZcT&2wabUX)YaxBNkyM?uQOO)mE~<$cZU|7>Ae;o{Tq@u}XI zWfjZWKSwL&&XyBc+R5{rvpfCrf@MkEHcHm5S9|wOI(BEtx;^z;9r+)Gm;3s5iA_;k zv`oeJaWwM|mn{=QiVjXJw^ZJ9s8rHcUg~#t{od{!E*e|rJb2I0VR85G$F*kruGcpSxt@92urX(cbmYq4_z13-Cr>+NWAc+Rp?>fV^0;! zckbM`-mKtOedN0$rnvh;;riUQin662U9Q|0$b4A%{blTe^dGxjU+nNV$$IhMYoc*y zO3;b8pPQ9r*M! { @@ -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: `
diff --git a/public/js/RoomClient.js b/public/js/RoomClient.js index d57dbcdd..5a5f9dc2 100644 --- a/public/js/RoomClient.js +++ b/public/js/RoomClient.js @@ -9,7 +9,7 @@ * @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 * */ @@ -99,6 +99,7 @@ const image = { broadcasting: '../images/broadcasting.png', geolocation: '../images/geolocation.png', network: '../images/network.gif', + rtmp: '../images/rtmp.png', }; const mediaType = { @@ -137,6 +138,12 @@ const _EVENTS = { roomUnlock: 'roomUnlock', hostOnlyRecordingOn: 'hostOnlyRecordingOn', hostOnlyRecordingOff: 'hostOnlyRecordingOff', + startRTMP: 'startRTMP', + stopRTMP: 'stopRTMP', + endRTMP: 'endRTMP', + startRTMPfromURL: 'startRTMPfromURL', + stopRTMPfromURL: 'stopRTMPfromURL', + endRTMPfromURL: 'endRTMPfromURL', }; // Enums @@ -198,6 +205,9 @@ class RoomClient { this.peer_uuid = peer_uuid; this.peer_info = peer_info; + // RTMP selected file name + this.selectedRtmpFilename = ''; + // Moderator this._moderator = { audio_start_muted: false, @@ -273,6 +283,10 @@ class RoomClient { this.transcription = transcription; + // RTMP Streamer + this.rtmpFileStreamer = false; + this.rtmpUrltSreamer = false; + // File transfer settings this.fileToSend = null; this.fileReader = null; @@ -525,6 +539,18 @@ class RoomClient { VideoAI.enabled = false; elemDisplay('tabVideoAIBtn', false); } + // Check che RTMP config + if (room.rtmp) { + console.log('RTMP config', room.rtmp); + const { enabled, fromFile, fromUrl, fromStream } = room.rtmp; + elemDisplay('tabRTMPStreamingBtn', enabled); + elemDisplay('rtmpFromFile', fromFile); + elemDisplay('rtmpFromUrl', fromUrl); + elemDisplay('rtmpFromStream', fromStream); + if (!fromFile && !fromUrl && !fromStream) { + elemDisplay('tabRTMPStreamingBtn', false); + } + } } // PARTICIPANTS @@ -838,6 +864,10 @@ class RoomClient { this.socket.on('recordingAction', this.handleRecordingActionData); this.socket.on('connect', this.handleSocketConnect); this.socket.on('disconnect', this.handleSocketDisconnect); + this.socket.on('endRTMP', this.handleEndRTMP); + this.socket.on('errorRTMP', this.handleErrorRTMP); + this.socket.on('endRTMPfromURL', this.handleEndRTMPfromURL); + this.socket.on('errorRTMPfromURL', this.handleErrorRTMPfromURL); } // #################################################### @@ -988,6 +1018,22 @@ class RoomClient { this.saveRecording('Socket disconnected'); }; + handleEndRTMP = (data) => { + this.endRTMP(data); + }; + + handleErrorRTMP = (data) => { + this.errorRTMP(data); + }; + + handleEndRTMPfromURL = (data) => { + this.endRTMPfromURL(data); + }; + + handleErrorRTMPfromURL = (data) => { + this.errorRTMPfromURL(data); + }; + // #################################################### // SERVER AWAY/MAINTENANCE // #################################################### @@ -2533,6 +2579,8 @@ class RoomClient { exit(offline = false) { if (VideoAI.active) this.stopSession(); + if (this.rtmpFilestreamer) this.stopRTMP(); + if (this.rtmpUrlstreamer) this.stopRTMPfromURL(); const clean = () => { this._isConnected = false; @@ -4707,7 +4755,7 @@ class RoomClient { function handleDragEnter(e) { e.preventDefault(); e.stopPropagation(); - e.target.style.background = '#f0f0f0'; + e.target.style.background = 'var(--body-bg)'; } function handleDragOver(e) { @@ -7375,6 +7423,222 @@ class RoomClient { VideoAI.active = false; } + // ############################################## + // RTMP from FILE + // ############################################## + + getRTMP() { + this.socket.request('getRTMP').then(function (filenames) { + console.log('RTMP files', filenames); + if (filenames.length === 0) { + const fileNameDiv = rc.getId('file-name'); + fileNameDiv.textContent = 'No file found to stream'; + //elemDisplay('startRtmpButton', false); + } + + //const f = Array.from({ length: 20 }, (_, index) => `My-file-video-to-stream-to-rtmp-server ${index + 1}`); + + const fileListTbody = rc.getId('file-list'); + fileListTbody.innerHTML = ''; + + filenames.forEach((filename) => { + const fileRow = document.createElement('tr'); + const fileCell = document.createElement('td'); + fileCell.textContent = filename; + fileCell.className = 'file-item'; + fileCell.onclick = () => showFilename(fileCell, filename); + fileRow.appendChild(fileCell); + fileListTbody.appendChild(fileRow); + }); + + function showFilename(clickedItem, filename) { + const fileNameDiv = rc.getId('file-name'); + fileNameDiv.textContent = `Selected file: ${filename}`; + rc.selectedRtmpFilename = filename; + const fileItems = document.querySelectorAll('.file-item'); + fileItems.forEach((item) => item.classList.remove('selected')); + + if (clickedItem) { + clickedItem.classList.add('selected'); + } + } + }); + } + + async startRTMP() { + if (!this.isRTMPVideoSupported(this.selectedRtmpFilename)) { + this.getId('file-name').textContent = ''; + return this.userLog( + 'warning', + "The provided File is not valid. Please ensure it's .mp4, webm or ogg video file", + 'top-end', + ); + } + + this.socket + .request('startRTMP', { + file: this.selectedRtmpFilename, + peer_name: this.peer_name, + peer_uuid: this.peer_uuid, + }) + .then(function (rtmp) { + rc.event(_EVENTS.startRTMP); + rc.showRTMP(rtmp, 'file'); + rc.rtmpFileStreamer = true; + }); + } + + stopRTMP() { + if (this.rtmpFileStreamer) { + this.socket.request('stopRTMP'); + this.rtmpFileStreamer = false; + this.cleanRTMPUrl(); + console.log('RTMP STOP'); + this.event(_EVENTS.stopRTMP); + } + } + + endRTMP(data) { + const rtmpMessage = `${data.rtmpUrl} processing finished!`; + this.rtmpFileStreamer = false; + this.userLog('info', rtmpMessage, 'top-end'); + console.log(rtmpMessage); + this.cleanRTMPUrl(); + this.socket.request('endOrErrorRTMP'); + this.event(_EVENTS.endRTMP); + } + + errorRTMP(data) { + const rtmpError = `${data.message}`; + this.rtmpFileStreamer = false; + this.userLog('error', rtmpError, 'top-end'); + console.error(rtmpError); + this.cleanRTMPUrl(); + this.socket.request('endOrErrorRTMP'); + this.event(_EVENTS.endRTMP); + } + + // ############################################## + // RTMP from URL + // ############################################## + + startRTMPfromURL(inputVideoURL) { + if (!this.isRTMPVideoSupported(inputVideoURL)) { + this.getId('rtmpStreamURL').value = ''; + return this.userLog( + 'warning', + 'The provided URL is not valid. Please ensure it links to an .mp4 video file', + 'top-end', + ); + } + + this.socket + .request('startRTMPfromURL', { + inputVideoURL: inputVideoURL, + peer_name: this.peer_name, + peer_uuid: this.peer_uuid, + }) + .then(function (rtmp) { + rc.event(_EVENTS.startRTMPfromURL); + rc.showRTMP(rtmp, 'url'); + rc.rtmpUrlStreamer = true; + }); + } + + stopRTMPfromURL() { + if (this.rtmpUrlStreamer) { + this.socket.request('stopRTMPfromURL'); + this.rtmpUrlStreamer = false; + this.cleanRTMPUrl(); + console.log('RTMP from URL STOP'); + this.event(_EVENTS.stopRTMPfromURL); + } + } + + endRTMPfromURL(data) { + const rtmpMessage = `${data.rtmpUrl} processing finished!`; + this.rtmpUrlStreamer = false; + this.userLog('info', rtmpMessage, 'top-end'); + console.log(rtmpMessage); + this.cleanRTMPUrl(); + this.socket.request('endOrErrorRTMPfromURL'); + this.event(_EVENTS.endRTMPfromURL); + } + + errorRTMPfromURL(data) { + const rtmpError = `${data.message}`; + this.rtmpUrlStreamer = false; + this.userLog('error', rtmpError, 'top-end'); + console.error(rtmpError); + this.cleanRTMPUrl(); + this.socket.request('endOrErrorRTMPfromURL'); + this.event(_EVENTS.endRTMPfromURL); + } + + // ############################################## + // RTMP common + // ############################################## + + isRTMPVideoSupported(video) { + if (video.endsWith('.mp4') || video.endsWith('.webm')) return true; + return false; + } + + copyRTMPUrl(url) { + if (!url) return this.userLog('info', 'No RTMP URL detected', 'top-end'); + copyToClipboard(url); + } + + cleanRTMPUrl() { + const rtmpUrl = rc.getId('rtmp-url'); + rtmpUrl.value = ''; + } + + showRTMP(rtmp, type = 'file') { + console.log('rtmp', rtmp); + + if (!rtmp) { + switch (type) { + case 'file': + this.event(_EVENTS.endRTMP); + break; + case 'url': + this.event(_EVENTS.endRTMPfromURL); + break; + default: + break; + } + return this.userLog( + 'warning', + 'Unable to start the RTMP stream. Please ensure the RTMP server is running. If the problem persists, contact the administrator', + 'top-end', + 6000, + ); + } + + const rtmpUrl = rc.getId('rtmp-url'); + rtmpUrl.value = rtmp; + + Swal.fire({ + background: swalBackground, + imageUrl: image.rtmp, + position: 'center', + title: 'LIVE', + 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:

+ + + +
+
+
+ +
+
+
diff --git a/public/views/RtmpStreamer.html b/public/views/RtmpStreamer.html new file mode 100644 index 00000000..7c536918 --- /dev/null +++ b/public/views/RtmpStreamer.html @@ -0,0 +1,97 @@ + + + + + + + + + MiroTalk RTMP Streamer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

MiroTalk RTMP Streamer

+
+ +
+
+ + +
+ + + +
+ + + +
+
+
+

© 2024 MiroTalk SFU, all rights reserved

+
+ + diff --git a/rtmpServers/README.md b/rtmpServers/README.md new file mode 100644 index 00000000..250bf9d0 --- /dev/null +++ b/rtmpServers/README.md @@ -0,0 +1,9 @@ +# MiroTalk RTMP Servers + +![rtmp](./rtmpStreaming.jpeg) + +### How to start the RTMP server? + +[https://docs.mirotalk.com/mirotalk-sfu/rtmp/](https://docs.mirotalk.com/mirotalk-sfu/rtmp/) + +--- diff --git a/rtmpServers/demo/client-server-axios/client/client.js b/rtmpServers/demo/client-server-axios/client/client.js new file mode 100644 index 00000000..0bc1df8c --- /dev/null +++ b/rtmpServers/demo/client-server-axios/client/client.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(); + if (userAgent.includes('chrome') && !userAgent.includes('edge') && !userAgent.includes('opr')) { + console.log('Browser is Chrome-based. Proceed with functionality.'); + } else { + showError( + 'This application requires a Chrome-based browser (Chrome, Edge Chromium, etc.). Please switch to a supported browser.', + ); + toggleButtons(true); + } +} + +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() { + if (mediaRecorder) { + mediaRecorder.stop(); + const apiSecret = apiSecretInput.value; + videoElement.srcObject = null; + rtmpInput.value = ''; + try { + await axios.post(`/stopRTMP?key=${rtmpKey}`, null, { + headers: { + authorization: apiSecret, + }, + }); + toggleButtons(false); + stopButton.disabled = true; + } 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/rtmpServers/demo/client-server-axios/client/index.html b/rtmpServers/demo/client-server-axios/client/index.html new file mode 100644 index 00000000..6ee37988 --- /dev/null +++ b/rtmpServers/demo/client-server-axios/client/index.html @@ -0,0 +1,51 @@ + + + + + + MiroTalk RTMP Streamer + + + + + + + + +
+

MiroTalk RTMP Streamer

+
+ +
+
+ + +
+ +
+ + + +
+
+
+

© 2024 MiroTalk SFU, all rights reserved

+
+ + diff --git a/rtmpServers/demo/client-server-axios/client/logo.svg b/rtmpServers/demo/client-server-axios/client/logo.svg new file mode 100644 index 00000000..e2e655cb --- /dev/null +++ b/rtmpServers/demo/client-server-axios/client/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rtmpServers/demo/client-server-axios/client/style.css b/rtmpServers/demo/client-server-axios/client/style.css new file mode 100644 index 00000000..78f83a7b --- /dev/null +++ b/rtmpServers/demo/client-server-axios/client/style.css @@ -0,0 +1,205 @@ +@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: radial-gradient(#393939, #000000); + color: #fff; +} + +.container { + max-width: 800px; + margin: 0 auto; + text-align: center; + padding: 20px; + background: radial-gradient(#393939, #000000); + 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; +} + +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/rtmpServers/demo/client-server-axios/server/RtmpStreamer.js b/rtmpServers/demo/client-server-axios/server/RtmpStreamer.js new file mode 100644 index 00000000..bd8a46e4 --- /dev/null +++ b/rtmpServers/demo/client-server-axios/server/RtmpStreamer.js @@ -0,0 +1,67 @@ +'use strict'; + +const { PassThrough } = require('stream'); +const ffmpeg = require('fluent-ffmpeg'); +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +class RtmpStreamer { + constructor(rtmpUrl, rtmpKey) { + this.rtmpUrl = rtmpUrl; + this.rtmpKey = rtmpKey; + 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) => console.info('ffmpeg command', { id: this.rtmpKey, cmd: commandLine })) + .on('error', (err, stdout, stderr) => { + if (!err.message.includes('Exiting normally')) { + console.error('FFmpeg error:', { id: this.rtmpKey, error: err.message }); + } + this.end(); + }) + .on('end', () => { + console.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; + console.info('RTMP streaming stopped', this.rtmpKey); + } + if (this.ffmpegStream && !this.ffmpegStream.killed) { + this.ffmpegStream.kill('SIGTERM'); + this.ffmpegStream = null; + console.info('FFMPEG closed successfully', this.rtmpKey); + } + this.run = false; + } +} + +module.exports = RtmpStreamer; diff --git a/rtmpServers/demo/client-server-axios/server/package.json b/rtmpServers/demo/client-server-axios/server/package.json new file mode 100644 index 00000000..fc8bddb3 --- /dev/null +++ b/rtmpServers/demo/client-server-axios/server/package.json @@ -0,0 +1,28 @@ +{ + "name": "mirotalk-rtmp-streamer-server", + "version": "1.0.0", + "description": "MiroTalk RTMP Streamer Server", + "main": "server.js", + "scripts": { + "start": "node server.js", + "start-dev": "nodemon server.js", + "nms-start": "docker-compose -f ../../../node-media-server/docker-compose.yml up -d", + "nms-stop": "docker-compose -f ../../../node-media-server/docker-compose.yml down", + "nms-restart": "docker-compose -f ../../../node-media-server/docker-compose.yml down && docker-compose -f ../../node-media-server/docker-compose.yml up -d", + "nms-logs": "docker logs -f mirotalk-nms" + }, + "keywords": [ + "rtmp", + "server" + ], + "author": "Miroslav Pejic", + "license": "AGPLv3", + "dependencies": { + "body-parser": "1.20.2", + "cors": "2.8.5" + }, + "devDependencies": { + "uuid": "10.0.0", + "nodemon": "^3.1.3" + } +} diff --git a/rtmpServers/demo/client-server-axios/server/server.js b/rtmpServers/demo/client-server-axios/server/server.js new file mode 100644 index 00000000..9f1efd18 --- /dev/null +++ b/rtmpServers/demo/client-server-axios/server/server.js @@ -0,0 +1,182 @@ +'use strict'; + +const express = require('express'); +const cors = require('cors'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto-js'); +const RtmpStreamer = require('./RtmpStreamer.js'); // Import the RtmpStreamer class +const bodyParser = require('body-parser'); +const path = require('path'); + +const port = 9999; + +const rtmpCfg = { + enabled: true, + maxStreams: 1, + server: 'rtmp://localhost:1935', + appName: 'mirotalk', + streamKey: '', + secret: 'mirotalkRtmpSecret', // Must match the key in node-media-server/src/config.js if play and publish are set to true, otherwise leave it '' + apiSecret: 'mirotalkRtmpApiSecret', // Must match the apiSecret specified in the Client side. + expirationHours: 4, +}; + +const dir = { + client: path.join(__dirname, '../', 'client'), + index: path.join(__dirname, '../', 'client/index.html'), +}; + +const app = express(); + +const corsOptions = { origin: '*', methods: ['GET', 'POST'] }; + +app.use(cors(corsOptions)); +app.use(express.static(dir.client)); // Expose client +app.use(express.json({ limit: '50mb' })); // Ensure the body parser can handle large files +app.use(bodyParser.raw({ type: 'video/webm', limit: '50mb' })); // handle raw binary data + +const streams = {}; // Collect all rtmp streams + +function checkRTMPApiSecret(req, res, next) { + const expectedApiSecret = rtmpCfg && rtmpCfg.apiSecret; + const apiSecret = req.headers.authorization; + + if (!apiSecret || apiSecret !== expectedApiSecret) { + console.log('RTMP apiSecret Unauthorized', { + apiSecret: apiSecret, + expectedApiSecret: expectedApiSecret, + }); + return res.status(401).send('Unauthorized'); + } + next(); +} + +function checkMaxStreams(req, res, next) { + const maxStreams = rtmpCfg.maxStreams || 10; // Set your maximum allowed streams here + if (Object.keys(streams).length >= maxStreams) { + console.log('Maximum number of streams reached', streams); + return res.status(429).send('Maximum number of streams reached, please try later!'); + } + next(); +} + +// Define a route handler for the default home page +app.get('/', (req, res) => { + res.sendFile(dir.index); +}); + +app.get('/rtmpEnabled', (req, res) => { + const rtmpEnabled = rtmpCfg && rtmpCfg.enabled; + console.debug('RTMP enabled', rtmpEnabled); + res.json({ enabled: rtmpEnabled }); +}); + +app.post('/initRTMP', checkRTMPApiSecret, checkMaxStreams, (req, res) => { + if (!rtmpCfg || !rtmpCfg.enabled) { + return res.status(400).send('RTMP server is not enabled or missing the config'); + } + + const domainName = 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; + + console.info('initRTMP', { + headers: req.headers, + rtmpServer, + rtmpServerSecret, + rtmpServerURL, + rtmpServerPath, + expirationHours, + rtmpStreamKey, + rtmp, + }); + + const stream = new RtmpStreamer(rtmp, rtmpStreamKey); + streams[rtmpStreamKey] = stream; + + console.log('Active RTMP Streams', Object.keys(streams).length); + + 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]; + console.debug('Stream not found', { rtmpStreamKey, streams: Object.keys(streams).length }); + return res.status(404).send('FFmpeg Stream not found'); + } + + console.debug('Received video data', { + // data: req.body.slice(0, 20).toString('hex'), + rtmpStreamKey: 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]; + console.debug('Active RTMP Streams', Object.keys(streams).length); + } + + res.sendStatus(200); +}); + +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}`; + + console.debug('generateRTMPUrl', { + currentTime, + expirationTime, + hashValue, + rtmpUrl, + }); + + return rtmpUrl; +} + +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]; +} + +// Start the server and listen on port 3000 +app.listen(port, () => { + console.log(`Server is running on http://localhost:${port}`, rtmpCfg); +}); diff --git a/rtmpServers/demo/client-server-socket/client/client.js b/rtmpServers/demo/client-server-socket/client/client.js new file mode 100644 index 00000000..50be7169 --- /dev/null +++ b/rtmpServers/demo/client-server-socket/client/client.js @@ -0,0 +1,212 @@ +'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'); +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; +let socket = null; + +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(); + if (userAgent.includes('chrome') && !userAgent.includes('edge') && !userAgent.includes('opr')) { + console.log('Browser is Chrome-based. Proceed with functionality.'); + } else { + showError( + 'This application requires a Chrome-based browser (Chrome, Edge Chromium, etc.). Please switch to a supported browser.', + ); + toggleButtons(true); + } +} + +window.onload = checkBrowserSupport; + +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() { + const apiSecret = apiSecretInput.value; + socket.emit('initRTMP', { apiSecret }); +} + +async function streamRTMP(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); + socket.emit('streamRTMP', { apiSecret: apiSecret, rtmpStreamKey: rtmpKey, data: chunk }); + } +} + +async function stopRTMP() { + if (mediaRecorder) { + const apiSecret = apiSecretInput.value; + mediaRecorder.stop(); + socket.emit('stopRTMP', { apiSecret: apiSecret, rtmpStreamKey: rtmpKey }); + } + videoElement.srcObject = null; + rtmpInput.value = ''; + toggleButtons(false); + stopButton.disabled = true; +} + +async function startStreaming(stream) { + if (!stream) return; + + try { + socket = io({ transports: ['websocket'] }); + + socket.on('connect', () => { + console.log('Connected to server'); + initRTMP(); + }); + + socket.on('initRTMP', (data) => { + console.log('initRTMP', data); + const { rtmp, rtmpStreamKey } = data; + rtmpInput.value = rtmp; + rtmpKey = rtmpStreamKey; + toggleButtons(true); + stopButton.disabled = false; + startMediaRecorder(stream); + }); + + socket.on('stopRTMP', () => { + console.log('RTMP stopped successfully!'); + }); + + socket.on('error', (error) => { + console.error('Error:', error); + showError(error); + stopRTMP(); + }); + + socket.on('disconnect', () => { + console.log('Disconnected from server'); + stopRTMP(); + }); + } catch (error) { + showPopup('Error start Streaming: ' + error.message, 'error'); + console.error('Error start Streaming', error); + stopRTMP(); + } +} + +async function startMediaRecorder(stream) { + if (!stream) return; + + mediaRecorder = new MediaRecorder(stream, { mimeType: 'video/webm; codecs=vp8,opus' }); + + mediaRecorder.ondataavailable = async (event) => { + if (event.data.size > 0) { + await streamRTMP(event.data); + } + }; + + mediaRecorder.onstop = (event) => { + console.log('Media recorder stopped'); + stopTracks(stream); + }; + + 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); + +window.addEventListener('beforeunload', async () => { + if (mediaRecorder) { + await stopRTMP(); + } +}); diff --git a/rtmpServers/demo/client-server-socket/client/index.html b/rtmpServers/demo/client-server-socket/client/index.html new file mode 100644 index 00000000..391a712c --- /dev/null +++ b/rtmpServers/demo/client-server-socket/client/index.html @@ -0,0 +1,52 @@ + + + + + + MiroTalk RTMP Streamer + + + + + + + + + +
+

MiroTalk RTMP Streamer

+
+ +
+
+ + +
+ +
+ + + +
+
+
+

© 2024 MiroTalk SFU, all rights reserved

+
+ + diff --git a/rtmpServers/demo/client-server-socket/client/logo.svg b/rtmpServers/demo/client-server-socket/client/logo.svg new file mode 100644 index 00000000..e2e655cb --- /dev/null +++ b/rtmpServers/demo/client-server-socket/client/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/rtmpServers/demo/client-server-socket/client/style.css b/rtmpServers/demo/client-server-socket/client/style.css new file mode 100644 index 00000000..78f83a7b --- /dev/null +++ b/rtmpServers/demo/client-server-socket/client/style.css @@ -0,0 +1,205 @@ +@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: radial-gradient(#393939, #000000); + color: #fff; +} + +.container { + max-width: 800px; + margin: 0 auto; + text-align: center; + padding: 20px; + background: radial-gradient(#393939, #000000); + 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; +} + +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/rtmpServers/demo/client-server-socket/server/RtmpStreamer.js b/rtmpServers/demo/client-server-socket/server/RtmpStreamer.js new file mode 100644 index 00000000..0c33916f --- /dev/null +++ b/rtmpServers/demo/client-server-socket/server/RtmpStreamer.js @@ -0,0 +1,68 @@ +'use strict'; + +const { PassThrough } = require('stream'); +const ffmpeg = require('fluent-ffmpeg'); +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +class RtmpStreamer { + constructor(rtmpUrl, rtmpKey, socket) { + (this.socket = socket), (this.rtmpUrl = rtmpUrl); + this.rtmpKey = rtmpKey; + 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) => console.info('ffmpeg command', { id: this.rtmpKey, cmd: commandLine })) + .on('error', (err, stdout, stderr) => { + if (!err.message.includes('Exiting normally')) { + console.error('FFmpeg error:', { id: this.rtmpKey, error: err.message }); + this.socket.emit('error', err.message); + } + this.end(); + }) + .on('end', () => { + console.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; + console.info('RTMP streaming stopped', this.rtmpKey); + } + if (this.ffmpegStream && !this.ffmpegStream.killed) { + this.ffmpegStream.kill('SIGTERM'); + this.ffmpegStream = null; + console.info('FFMPEG closed successfully', this.rtmpKey); + } + this.run = false; + } +} + +module.exports = RtmpStreamer; diff --git a/rtmpServers/demo/client-server-socket/server/package.json b/rtmpServers/demo/client-server-socket/server/package.json new file mode 100644 index 00000000..b441baec --- /dev/null +++ b/rtmpServers/demo/client-server-socket/server/package.json @@ -0,0 +1,30 @@ +{ + "name": "mirotalk-rtmp-server-streamer", + "version": "1.0.0", + "description": "MiroTalk RTMP Server Streamer", + "main": "server.js", + "scripts": { + "start": "node server.js", + "start-dev": "nodemon server.js", + "nms-start": "docker-compose -f ../../../node-media-server/docker-compose.yml up -d", + "nms-stop": "docker-compose -f ../../../node-media-server/docker-compose.yml down", + "nms-restart": "docker-compose -f ../../../node-media-server/docker-compose.yml down && docker-compose -f ../../node-media-server/docker-compose.yml up -d", + "nms-logs": "docker logs -f mirotalk-nms" + }, + "keywords": [ + "rtmp", + "server", + "streaming" + ], + "author": "Miroslav Pejic", + "license": "AGPLv3", + "dependencies": { + "body-parser": "1.20.2", + "cors": "2.8.5", + "socket.io": "4.7.5" + }, + "devDependencies": { + "uuid": "10.0.0", + "nodemon": "^3.1.3" + } +} diff --git a/rtmpServers/demo/client-server-socket/server/server.js b/rtmpServers/demo/client-server-socket/server/server.js new file mode 100644 index 00000000..2ffb28dc --- /dev/null +++ b/rtmpServers/demo/client-server-socket/server/server.js @@ -0,0 +1,179 @@ +'use strict'; + +const express = require('express'); +const cors = require('cors'); +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto-js'); +const RtmpStreamer = require('./RtmpStreamer.js'); +const http = require('http'); +const path = require('path'); +const socketIo = require('socket.io'); + +const port = 9999; + +const rtmpCfg = { + enabled: true, + maxStreams: 1, + server: 'rtmp://localhost:1935', + appName: 'mirotalk', + streamKey: '', + secret: 'mirotalkRtmpSecret', // Must match the key in node-media-server/src/config.js if play and publish are set to true, otherwise leave it '' + apiSecret: 'mirotalkRtmpApiSecret', // Must match the apiSecret specified in the Client side. + expirationHours: 4, +}; + +const dir = { + client: path.join(__dirname, '../', 'client'), + index: path.join(__dirname, '../', 'client/index.html'), +}; + +const streams = {}; // Collect all rtmp streams +const corsOptions = { origin: '*', methods: ['GET'] }; + +const app = express(); +const server = http.createServer(app); +const io = socketIo(server, { + transports: ['websocket'], + cors: corsOptions, +}); + +app.use(express.static(dir.client)); +app.use(cors(corsOptions)); + +// Logs requests +app.use((req, res, next) => { + console.debug('New request:', { + method: req.method, + path: req.originalUrl, + }); + next(); +}); + +function checkRTMPApiSecret(apiSecret) { + return apiSecret && apiSecret === rtmpCfg.apiSecret; +} + +function checkMaxStreams() { + return Object.keys(streams).length < rtmpCfg.maxStreams; +} + +app.get('/', (req, res) => { + res.sendFile(dir.index); +}); + +io.on('connection', (socket) => { + console.log(`Socket connected: ${socket.id}`); + + socket.on('initRTMP', ({ apiSecret }) => { + // + if (!checkRTMPApiSecret(apiSecret)) { + return socket.emit('error', 'Unauthorized'); + } + + if (!checkMaxStreams()) { + return socket.emit('error', 'Maximum number of streams reached, please try later!'); + } + + const hostHeader = socket.handshake.headers.host; + const domainName = hostHeader.split(':')[0]; // Extract domain name + + 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; + + console.info('initRTMP', { + rtmpServer, + rtmpServerSecret, + rtmpServerURL, + rtmpServerPath, + expirationHours, + rtmpStreamKey, + rtmp, + }); + + const stream = new RtmpStreamer(rtmp, rtmpStreamKey, socket); + streams[rtmpStreamKey] = stream; + + console.log('Active RTMP Streams', Object.keys(streams).length); + + return socket.emit('initRTMP', { rtmp, rtmpStreamKey }); + }); + + socket.on('streamRTMP', async ({ apiSecret, rtmpStreamKey, data }) => { + if (!checkRTMPApiSecret(apiSecret)) { + return socket.emit('error', 'Unauthorized'); + } + + const stream = streams[rtmpStreamKey]; + + if (!stream || !stream.isRunning()) { + delete streams[rtmpStreamKey]; + console.debug('Stream not found', { rtmpStreamKey, streams: Object.keys(streams).length }); + return; + } + + console.debug('Received video data via Socket.IO', { + // data: data.slice(0, 20).toString('hex'), + key: rtmpStreamKey, + size: bytesToSize(data.length), + }); + + stream.write(Buffer.from(data)); + socket.emit('ack'); + }); + + socket.on('stopRTMP', ({ apiSecret, rtmpStreamKey }) => { + if (!checkRTMPApiSecret(apiSecret)) { + return socket.emit('error', 'Unauthorized'); + } + + const stream = streams[rtmpStreamKey]; + + if (stream) { + stream.end(); + delete streams[rtmpStreamKey]; + console.debug('Streams', Object.keys(streams).length); + } + + socket.emit('stopRTMP'); + }); + + socket.on('disconnect', () => { + console.log(`Socket disconnected: ${socket.id}`); + }); +}); + +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}`; + + console.debug('generateRTMPUrl', { + currentTime, + expirationTime, + hashValue, + rtmpUrl, + }); + + return rtmpUrl; +} + +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]; +} + +server.listen(port, () => { + console.log(`Server is running on http://localhost:${port}`, rtmpCfg); +}); diff --git a/rtmpServers/nginx-rtmp/Dockerfile b/rtmpServers/nginx-rtmp/Dockerfile new file mode 100644 index 00000000..99aeb292 --- /dev/null +++ b/rtmpServers/nginx-rtmp/Dockerfile @@ -0,0 +1,61 @@ +FROM buildpack-deps:bullseye + +# Versions of Nginx and nginx-rtmp-module to use +ENV NGINX_VERSION=1.24.0 +ENV NGINX_RTMP_MODULE_VERSION=1.2.2 + +# Install dependencies +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + openssl \ + libssl-dev \ + wget \ + && rm -rf /var/lib/apt/lists/* + +# Download and decompress Nginx and RTMP module +RUN mkdir -p /tmp/build && \ + cd /tmp/build && \ + wget https://nginx.org/download/nginx-${NGINX_VERSION}.tar.gz && \ + tar -zxf nginx-${NGINX_VERSION}.tar.gz && \ + wget https://github.com/arut/nginx-rtmp-module/archive/v${NGINX_RTMP_MODULE_VERSION}.tar.gz && \ + tar -zxf v${NGINX_RTMP_MODULE_VERSION}.tar.gz + +# Build and install Nginx with RTMP module +RUN cd /tmp/build/nginx-${NGINX_VERSION} && \ + ./configure \ + --sbin-path=/usr/local/sbin/nginx \ + --conf-path=/etc/nginx/nginx.conf \ + --error-log-path=/var/log/nginx/error.log \ + --pid-path=/var/run/nginx/nginx.pid \ + --lock-path=/var/lock/nginx/nginx.lock \ + --http-log-path=/var/log/nginx/access.log \ + --http-client-body-temp-path=/tmp/nginx-client-body \ + --with-http_ssl_module \ + --with-threads \ + --with-ipv6 \ + --add-module=/tmp/build/nginx-rtmp-module-${NGINX_RTMP_MODULE_VERSION} \ + --with-debug && \ + make -j $(getconf _NPROCESSORS_ONLN) && \ + make install && \ + mkdir /var/lock/nginx && \ + rm -rf /tmp/build + +# Forward logs to Docker +RUN ln -sf /dev/stdout /var/log/nginx/access.log && \ + ln -sf /dev/stderr /var/log/nginx/error.log + +# ------------------------ +# FROM tiangolo/nginx-rtmp +# ------------------------ + +# Copy nginx.conf with RTMP configuration and stat.xsl +COPY nginx.conf /etc/nginx/nginx.conf +COPY stat.xsl /usr/share/nginx/html/stat.xsl + +# Rtmp port +EXPOSE 1935 +# Http port +EXPOSE 8081 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/rtmpServers/nginx-rtmp/README.md b/rtmpServers/nginx-rtmp/README.md new file mode 100644 index 00000000..1c45a40b --- /dev/null +++ b/rtmpServers/nginx-rtmp/README.md @@ -0,0 +1,30 @@ +# RTMP Streaming + +![rtmpStreaming](../rtmpStreaming.jpeg) + +For running an `RTMP` (Real-Time Messaging Protocol) server in Docker, **Nginx with the RTMP module** is one of the best options. It is widely used for streaming video content due to its high performance and flexibility. + +## Setting up Nginx with RTMP in Docker + +```sh +# Copy the docker.compose.yml +$ cp docker-compose.template.yml docker-compose.yml + +# Pull the official mirotalk rtmp image +$ docker pull mirotalk/rtmp:latest + +# Create and start containers +$ docker-compose up -d + +# Check the logs +$ docker logs -f mirotalk-rtmp + +# To stop and remove resources +$ docker-compose down +``` + +## Custom Configuration + +Modify the `nginx.conf` to suit your specific needs, such as enabling recording, adding authentication, or configuring HLS (HTTP Live Streaming). + +By using Nginx with the RTMP module in Docker, you can quickly and easily set up a robust RTMP server for live video streaming. diff --git a/rtmpServers/nginx-rtmp/docker-compose.template.yml b/rtmpServers/nginx-rtmp/docker-compose.template.yml new file mode 100644 index 00000000..40e29faa --- /dev/null +++ b/rtmpServers/nginx-rtmp/docker-compose.template.yml @@ -0,0 +1,14 @@ +version: '3' + +services: + mirotalk-rtmp: + container_name: mirotalk-rtmp + #image: tiangolo/nginx-rtmp + image: mirotalk/rtmp:latest + volumes: + - ./nginx.conf/:/etc/nginx/nginx.conf/:ro + - ./stat.xsl/:/usr/share/nginx/html/stat.xsl/:ro + ports: + - '1935:1935' + - '8081:8081' + restart: unless-stopped diff --git a/rtmpServers/nginx-rtmp/nginx.conf b/rtmpServers/nginx-rtmp/nginx.conf new file mode 100644 index 00000000..e9195a30 --- /dev/null +++ b/rtmpServers/nginx-rtmp/nginx.conf @@ -0,0 +1,34 @@ +worker_processes auto; + +rtmp_auto_push on; + +events { + worker_connections 1024; +} + +rtmp { + server { + listen 1935; + listen [::]:1935 ipv6only=on; + + application live { + live on; + record off; + } + } +} + +http { + server { + listen 8081; + + location /stat { + rtmp_stat all; + rtmp_stat_stylesheet stat.xsl; + } + + location /stat.xsl { + root /usr/share/nginx/html; + } + } +} \ No newline at end of file diff --git a/rtmpServers/nginx-rtmp/stat.xsl b/rtmpServers/nginx-rtmp/stat.xsl new file mode 100644 index 00000000..9220fa2d --- /dev/null +++ b/rtmpServers/nginx-rtmp/stat.xsl @@ -0,0 +1,30 @@ + + + + +

RTMP Statistics

+ + + + + + + + + + + + + + + + + + + +
StreamBitrate (kbps)BytesClientBW (kbps)Time
+ + +
+
+ diff --git a/rtmpServers/node-media-server/Dockerfile b/rtmpServers/node-media-server/Dockerfile new file mode 100644 index 00000000..0c4287ce --- /dev/null +++ b/rtmpServers/node-media-server/Dockerfile @@ -0,0 +1,26 @@ +# Use a lightweight Node.js image +FROM node:20-slim + +# Set working directory +WORKDIR /app + +# Copy package.json and install npm dependencies +COPY package.json . +RUN npm install + +# Cleanup unnecessary packages and files +RUN npm cache clean --force \ +&& rm -rf /tmp/* /var/tmp/* /usr/share/doc/* + +# Copy the application code +COPY src src + +# Rtmp port +EXPOSE 1935 +# Http port +EXPOSE 8081 +# Https port +EXPOSE 8043 + +# Set default command to start the application +CMD ["npm", "start"] \ No newline at end of file diff --git a/rtmpServers/node-media-server/README.md b/rtmpServers/node-media-server/README.md new file mode 100644 index 00000000..c18a3bdb --- /dev/null +++ b/rtmpServers/node-media-server/README.md @@ -0,0 +1,47 @@ +# RTMP Streaming + +![rtmpStreaming](../rtmpStreaming.jpeg) + +For running an `RTMP` (Real-Time Messaging Protocol) server in Node, **[Node-Media-Server](https://github.com/illuspas/Node-Media-Server)** is one of the best options. + +## Quick Start + +```sh +# Create the config file for the server +$ cp config.template.js config.js +# Install the dependencies +$ npm install +# Start the RTMP Server +$ npm start +``` + +## Using Docker + +```sh +# Create the config file for the server +$ cp config.template.js config.js + +# Copy the docker.compose.yml +$ cp docker-compose.template.yml docker-compose.yml + +# Pull the official mirotalk rtmp image +$ docker pull mirotalk/nms:latest + +# Create and start containers +$ docker-compose up -d + +# Check the logs +$ docker logs -f mirotalk-nms + +# To stop and remove resources +$ docker-compose down +``` + +## Dashboard & API + +[http://localhost:8081/admin](http://localhost:8081/admin) +[http://localhost:8081/api/server](http://localhost:8081/api/server) + +## Custom Configuration + +Modify the `config.js` to suit your specific needs. diff --git a/rtmpServers/node-media-server/docker-compose.template.yml b/rtmpServers/node-media-server/docker-compose.template.yml new file mode 100644 index 00000000..39145c8e --- /dev/null +++ b/rtmpServers/node-media-server/docker-compose.template.yml @@ -0,0 +1,13 @@ +version: '3' + +services: + mirotalk-nms: + container_name: mirotalk-nms + image: mirotalk/nms:latest + volumes: + - ./src/config.js/:/app/src/config.js/:ro + ports: + - '1935:1935' + - '8081:8081' + - '8043:8043' + restart: unless-stopped diff --git a/rtmpServers/node-media-server/package.json b/rtmpServers/node-media-server/package.json new file mode 100644 index 00000000..624e0dd8 --- /dev/null +++ b/rtmpServers/node-media-server/package.json @@ -0,0 +1,26 @@ +{ + "name": "mirotalk-rtmp-nms", + "version": "1.0.0", + "description": "MiroTalk RTMP Node Media Server", + "main": "server.js", + "scripts": { + "start": "node src/server.js", + "start-dev": "nodemon src/server.js" + }, + "keywords": [ + "rtmp", + "node", + "media", + "server" + ], + "author": "Miroslav Pejic", + "license": "AGPLv3", + "dependencies": { + "crypto": "^1.0.1", + "node-media-server": "^2.7.0" + }, + "devDependencies": { + "uuid": "10.0.0", + "nodemon": "^3.1.4" + } +} diff --git a/rtmpServers/node-media-server/src/cert.pem b/rtmpServers/node-media-server/src/cert.pem new file mode 100644 index 00000000..282430d0 --- /dev/null +++ b/rtmpServers/node-media-server/src/cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDfzCCAmcCFHLVDcza5/3VZ8U5Vd2LnWRvwME1MA0GCSqGSIb3DQEBCwUAMHsx +CzAJBgNVBAYTAklUMQ4wDAYDVQQIDAVJdGFseTERMA8GA1UECgwITWlyb1RhbGsx +HTAbBgNVBAMMFE1pcm9UYWxrIFJUTVAgU2VydmVyMSowKAYJKoZIhvcNAQkBFhtt +aXJvc2xhdi5wZWppYy44NUBnbWFpbC5jb20wIBcNMjQwNjIwMjE1MDQ4WhgPMjA1 +MTExMDUyMTUwNDhaMHsxCzAJBgNVBAYTAklUMQ4wDAYDVQQIDAVJdGFseTERMA8G +A1UECgwITWlyb1RhbGsxHTAbBgNVBAMMFE1pcm9UYWxrIFJUTVAgU2VydmVyMSow +KAYJKoZIhvcNAQkBFhttaXJvc2xhdi5wZWppYy44NUBnbWFpbC5jb20wggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDJfFke3Df+0tgei1djU1Af0i4OukeI +UHxWzr3mibVxJ8qtNwukXc0F9XnRXA9kF7WJa+vzuQXQgClH75mrrTNzwV1IWXSZ +nKokogyweHC+XZ/Frdv+yCWyJcC7YrIJVTJNCU+Z4wDBLira35Z2tBTd7UV9gURB +oQZhqeG0b653D1kvb9oEzTJFbmcst5YzEHUfs4CeF6RvOK4q0wD1CXImJYpXrJ02 +YbpLyZ8hLZuGq3uDdPOjHXQApxmLhSUgJHnIcHxR/xVFdLyAqP+aTKnozLCS3PZC +yB1lJbuEmPO5WfeQDmL7W3COEtdAdlCNF8VZ09z1AlCKLnp75vi9M04hAgMBAAEw +DQYJKoZIhvcNAQELBQADggEBAHOVKxSt+BGtDFynltp0pfHGyFo1sr5ULUams67s +LQuiOm0Iuw1kXRA9Yf/hAcL12/taEBfNqYxveQXe8xbodwobkOpHmyYYLZ+50a8I ++hP15UkmlJb0iy7OkjoalDqVFFN2WQTJK3OqMg4RdJlTMpzDibNYzZWZ6Xaxl670 +FDh3xJO9/MweHO/ScGS5RVIdYIdDbFGzzcYHiWpsbcYgYdvsNVofNsZpotWd37/x +CbYImc1RKhRnBQTcnnK0u+6ugD26Yho3eB5f0nbj2gkikDYueYYZG+7uV2w+9QKI +e+nipac/6/ACwo1ZMsEYR3arjdLN8Rxr39s5PStP63EkGv4= +-----END CERTIFICATE----- diff --git a/rtmpServers/node-media-server/src/config.template.js b/rtmpServers/node-media-server/src/config.template.js new file mode 100644 index 00000000..696666b0 --- /dev/null +++ b/rtmpServers/node-media-server/src/config.template.js @@ -0,0 +1,30 @@ +'use strict'; + +const config = { + rtmp: { + port: 1935, + chunk_size: 60000, + gop_cache: true, + ping: 60, + ping_timeout: 30, + }, + http: { + port: 8081, + allow_origin: '*', + }, + https: { + port: 8043, + key: __dirname + '/key.pem', + cert: __dirname + '/cert.pem', + }, + auth: { + api: true, + api_user: 'mirotalk', + api_pass: 'mirotalkRtmpPassword', // http://localhost:8081/admin + play: false, // Require authentication for playing streams + publish: false, // Require authentication for publishing streams + secret: 'mirotalkRtmpSecret', // Check the sign.js file to generate a valid RTMP URL + }, +}; + +module.exports = config; diff --git a/rtmpServers/node-media-server/src/key.pem b/rtmpServers/node-media-server/src/key.pem new file mode 100644 index 00000000..9a88acc6 --- /dev/null +++ b/rtmpServers/node-media-server/src/key.pem @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAyXxZHtw3/tLYHotXY1NQH9IuDrpHiFB8Vs695om1cSfKrTcL +pF3NBfV50VwPZBe1iWvr87kF0IApR++Zq60zc8FdSFl0mZyqJKIMsHhwvl2fxa3b +/sglsiXAu2KyCVUyTQlPmeMAwS4q2t+WdrQU3e1FfYFEQaEGYanhtG+udw9ZL2/a +BM0yRW5nLLeWMxB1H7OAnhekbziuKtMA9QlyJiWKV6ydNmG6S8mfIS2bhqt7g3Tz +ox10AKcZi4UlICR5yHB8Uf8VRXS8gKj/mkyp6Mywktz2QsgdZSW7hJjzuVn3kA5i ++1twjhLXQHZQjRfFWdPc9QJQii56e+b4vTNOIQIDAQABAoIBAQC98pSyGzpO6ccF +RKfl460t0p/JEqRNRlNyIwW0SS7ctn7EPZikJCoc7Acj8H4yBogGPc/7vPpWTfyc +7K0aw/Y1sp2Wj371MlTUpFECLQlc7japzfYQg+/FuwGvpqPhWIhLR/PbR752YGfW +X+MhlTP25LEWWL9Yf83cVKOLz53Sbt9KH/baJhpFhXrrxl/1rcR6U7MhZOLlq1aa +XbYwglzd/l20cED1wbCzbxVwl/M73ZIOgn+2vgSxkNCxkZVpGYvE0Tx9S9b3rmtl +OD9WqTS5beoKABXbMPTMdbyekPAnK2pAUs+WmKD8iJ69djKeMj/lO1vQBZOBJzlL +pxteIS7hAoGBAPvbO27SPnMI76lfXDAECNHCq1YLiVpqhctFvcJlM1aQMbWKqYOX +XVho+drGlh/Mf9JpY3rtfd7VNZKbJQRT/6Wf7j7L7WOldClxIhBXxziqOJ/bemJP +ELRau321q5x2aNLGZbioaDgB9fzEm/aPyjRC8JnIvePyksAzXJNw2mtzAoGBAMzM +9w7nyfa16pG14hAdiYkCtB052jZ71sz+Y9XbA12D36EDLxkQ3l5I6FNrvWvu30N8 +snG+SMmk8LSjy3b4bv5DPP1Bnh+HQG/5quoG61uODkRC7aIgLCgdbmnggWrI+gV7 +E+YM6HMZFVk3Lvo1GobyxsBCLBRCPdfW15nQ0eMbAoGBAJiXtIOpeFLEOEibUUR6 +PUmxs5N3e+m/Hn8RKy6LmDY7ORLwB1KGM/Ur7S3jIfP0OAGo/q/tElUfQs0nmJ7t +sbeMlZGQhqzYAvBU7jmOpVKst5ALLzQ/CTTswCojFu2+RDZoJBtkVXiRn5NdH82c +Qvu1Dwdtu7dPMiCnPdDLEFsHAoGBAJjHhr7N13J+f0C4CK6w+jsFk0wCLnFarQE7 +/Uo6GiaXDCrXbzkpxllb1kT1KNft2QxFZ/FGXJJgw1heoJhd+J8hlcvwOX+XrFBc +Vk5DXyxrquTtcMzzZz19xzKg0qrQxwNzr4J8uqOyYKSvcBIjr2hgkDg4pR1v1SbB +FRGgIBNlAoGAeMJrhQy5RU6xCG9l8+jH42PhG4+F9pV5EQI0v421KQ4hklgY+pT6 +KrTuZp6tjX7hErYNNd77ELDRLZ3p8VlqxuvF3UI6s+I7rRxpXpjSve3si8USYS4L +aKAp6qDc3Vt1e6uin9NwZS6jtDvH8VOIMOHTQYJwUTnjpSLuYIxOzU0= +-----END RSA PRIVATE KEY----- diff --git a/rtmpServers/node-media-server/src/server.js b/rtmpServers/node-media-server/src/server.js new file mode 100644 index 00000000..e1d780bc --- /dev/null +++ b/rtmpServers/node-media-server/src/server.js @@ -0,0 +1,81 @@ +'use strict'; + +const NodeMediaServer = require('node-media-server'); + +const config = require('./config'); + +console.log('Rtmp Server config', { + config: config, + http: { + admin: 'http://localhost:8081/admin', + stats: 'http://localhost:8081/api/server', + streams: 'http://localhost:8081/api/streams', + }, + https: { + admin: 'https://localhost:8043/admin', + stats: 'https://localhost:8043/api/server', + streams: 'http://localhost:8043/api/streams', + }, +}); + +const nms = new NodeMediaServer(config); + +nms.run(); + +nms.on('preConnect', (id, args) => { + console.log('[NodeEvent on preConnect]', `id=${id} args=${JSON.stringify(args)}`); + // let session = nms.getSession(id); + // session.reject(); +}); + +nms.on('postConnect', (id, args) => { + console.log('[NodeEvent on postConnect]', `id=${id} args=${JSON.stringify(args)}`); +}); + +nms.on('doneConnect', (id, args) => { + console.log('[NodeEvent on doneConnect]', `id=${id} args=${JSON.stringify(args)}`); +}); + +nms.on('prePublish', (id, StreamPath, args) => { + console.log('[NodeEvent on prePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); + // let session = nms.getSession(id); + // session.reject(); +}); + +nms.on('postPublish', (id, StreamPath, args) => { + console.log('[NodeEvent on postPublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); +}); + +nms.on('donePublish', (id, StreamPath, args) => { + console.log('[NodeEvent on donePublish]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); +}); + +nms.on('prePlay', (id, StreamPath, args) => { + console.log('[NodeEvent on prePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); +}); + +nms.on('postPlay', (id, StreamPath, args) => { + console.log('[NodeEvent on postPlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); + // let session = nms.getSession(id); + // session.reject(); +}); + +nms.on('donePlay', (id, StreamPath, args) => { + console.log('[NodeEvent on donePlay]', `id=${id} StreamPath=${StreamPath} args=${JSON.stringify(args)}`); +}); + +nms.on('logMessage', (...args) => { + // custom logger log message handler +}); + +nms.on('errorMessage', (...args) => { + // custom logger error message handler +}); + +nms.on('debugMessage', (...args) => { + // custom logger debug message handler +}); + +nms.on('ffDebugMessage', (...args) => { + // custom logger ffmpeg debug message handler +}); diff --git a/rtmpServers/node-media-server/src/sign.js b/rtmpServers/node-media-server/src/sign.js new file mode 100644 index 00000000..58a13e1b --- /dev/null +++ b/rtmpServers/node-media-server/src/sign.js @@ -0,0 +1,58 @@ +'use strict'; + +const crypto = require('crypto-js'); + +const { v4: uuidv4 } = require('uuid'); + +/** + * Generates an RTMP URL with an expiration timestamp and a hash value. + * + * @param {string} baseURL - The base URL of the RTMP server. + * @param {string} streamPath - The path to the stream. + * @param {string} secretKey - The secret key used for generating the hash. + * @param {number} expirationHours - The number of hours until the URL expires. + * @returns {string} - The generated RTMP URL for Node Media Server. + */ +function generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 8) { + // Current timestamp in seconds + const currentTime = Math.floor(Date.now() / 1000); + + // Expiration time (current time + expirationHours in seconds) + const expirationTime = currentTime + expirationHours * 3600; + + // Generate the hash value + const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString(); + + // Construct the final request address + const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`; + + // Print some log + log.debug('generateRTMPUrl', { + currentTime: currentTime, + expirationTime: expirationTime, + hashValue: hashValue, + rtmpUrl: rtmpUrl, + }); + + return rtmpUrl; +} + +// Example usage +const baseURL = 'rtmp://localhost:1935'; +const streamKey = uuidv4(); +const streamPath = '/live/' + streamKey; // path/stream-key +const secretKey = 'mirotalkRtmpSecret'; +const expirationHours = 8; + +// Run: node sign.js +const rtmpUrl = generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours); +console.log('Generated RTMP URL:', rtmpUrl); + +/* +OBS: + - Server: rtmp://localhost:1935/live + - StreamKey: demo?sign=1719169535-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +FFMPEG: + - ffmpeg -re -i input.mp4 -c:v libx264 -preset veryfast -maxrate 3000k -bufsize 6000k -vf "scale=-2:720" -g 50 -c:a aac -b:a 128k -f flv "rtmp://localhost:1935/live/demo?sign=1719169535-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +*/ diff --git a/rtmpServers/rtmpStreaming.jpeg b/rtmpServers/rtmpStreaming.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..451682cf51153e139f15098420696370265ddcf9 GIT binary patch literal 61671 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ȣ=ME;*n*$vnu>AM^>FLSU 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

&#ge0$En<*y3|t66=%GuVJ58 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