diff --git a/.gitignore b/.gitignore index 536c2c51..7a2ceca7 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,5 @@ package-lock.json config.js docker-compose.yml docker-push.sh -rec \ No newline at end of file +rec +rtmp \ No newline at end of file diff --git a/README.md b/README.md index e09e601a..4faed206 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,8 @@ - Push-to-talk functionality, similar to a walkie-talkie. - Advanced collaborative whiteboard for teachers. - Real-time sharing of YouTube embed videos, video files (MP4, WebM, OGG), and audio files (MP3). +- Integrated RTMP server, fully compatible with **[OBS](https://obsproject.com)**. +- Supports RTMP streaming from files, URLs, webcams, screens, and windows. - Full-screen mode with one-click video element zooming and pin/unpin. - Customizable UI themes. - Right-click options on video elements for additional controls. diff --git a/app/src/Room.js b/app/src/Room.js index 8df6c7b2..07d64586 100644 --- a/app/src/Room.js +++ b/app/src/Room.js @@ -1,6 +1,12 @@ 'use strict'; const config = require('./config'); +const crypto = require('crypto-js'); +const RtmpFile = require('./RtmpFile'); +const RtmpUrl = require('./RtmpUrl'); +const fs = require('fs'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); const Logger = require('./Logger'); const log = new Logger('Room'); @@ -50,6 +56,11 @@ module.exports = class Room { this.router = null; this.routerSettings = config.mediasoup.router; this.createTheRouter(); + + // RTMP configuration + this.rtmpFileStreamer = null; + this.rtmpUrlStreamer = null; + this.rtmp = config.server.rtmp || false; } // #################################################### @@ -66,6 +77,12 @@ module.exports = class Room { isLobbyEnabled: this._isLobbyEnabled, hostOnlyRecording: this._hostOnlyRecording, }, + rtmp: { + enabled: this.rtmp && this.rtmp.enabled, + fromFile: this.rtmp && this.rtmp.fromFile, + fromUrl: this.rtmp && this.rtmp.fromUrl, + fromStream: this.rtmp && this.rtmp.fromStream, + }, moderator: this._moderator, survey: this.survey, redirect: this.redirect, @@ -74,6 +91,164 @@ module.exports = class Room { }; } + // ############################################## + // RTMP from FILE + // ############################################## + + isRtmpFileStreamerActive() { + return this.rtmpFileStreamer; + } + + async getRTMP(dir = 'rtmp') { + const folderPath = path.join(__dirname, '..', dir); + + // Create dir if not exists + if (!fs.existsSync(folderPath)) { + fs.mkdirSync(folderPath, { recursive: true }); + } + + try { + const files = fs.readdirSync(folderPath); + log.info('RTMP files', files); + return files; + } catch (error) { + log.error(`[getRTMP] Error reading directory: ${error.message}`); + return []; + } + } + + async startRTMP(socket_id, room, host = 'localhost', port = 1935, file = '../rtmp/BigBuckBunny.mp4') { + if (!this.rtmp || !this.rtmp.enabled) { + log.debug('RTMP server is not enabled or missing the config'); + return false; + } + + if (this.rtmpFileStreamer) { + log.debug('RtmpFile is already in progress'); + return false; + } + + const inputFilePath = path.join(__dirname, file); + + if (!fs.existsSync(inputFilePath)) { + log.error(`File not found: ${inputFilePath}`); + return false; + } + + this.rtmpFileStreamer = new RtmpFile(socket_id, room); + + const inputStream = fs.createReadStream(inputFilePath); + + const rtmpUrl = this.getRTMPUrl(host, port); + + const rtmpRun = await this.rtmpFileStreamer.start(inputStream, rtmpUrl); + + if (!rtmpRun) { + this.rtmpFileStreamer = false; + return this.rtmpFileStreamer; + } + return rtmpUrl; + } + + stopRTMP() { + if (!this.rtmp || !this.rtmp.enabled) { + log.debug('RTMP server is not enabled or missing the config'); + return false; + } + if (this.rtmpFileStreamer) { + this.rtmpFileStreamer.stop(); + this.rtmpFileStreamer = null; + log.debug('RTMP File Streamer Stopped successfully!'); + return true; + } else { + log.debug('No RtmpFile process to stop'); + return false; + } + } + + // #################################################### + // RTMP from URL + // #################################################### + + isRtmpUrlStreamerActive() { + return this.rtmpUrlStreamer; + } + + async startRTMPfromURL( + socket_id, + room, + host = 'localhost', + port = 1935, + inputVideoURL = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4', + ) { + if (!this.rtmp || !this.rtmp.enabled) { + log.debug('RTMP server is not enabled or missing the config'); + return false; + } + + if (this.rtmpUrlStreamer) { + log.debug('RtmpFile is already in progress'); + return false; + } + + this.rtmpUrlStreamer = new RtmpUrl(socket_id, room); + + const rtmpUrl = this.getRTMPUrl(host, port); + + const rtmpRun = await this.rtmpUrlStreamer.start(inputVideoURL, rtmpUrl); + + if (!rtmpRun) { + this.rtmpUrlStreamer = false; + return this.rtmpUrlStreamer; + } + return rtmpUrl; + } + + stopRTMPfromURL() { + if (!this.rtmp || !this.rtmp.enabled) { + log.debug('RTMP server is not enabled or missing the config'); + return false; + } + if (this.rtmpUrlStreamer) { + this.rtmpUrlStreamer.stop(); + this.rtmpUrlStreamer = null; + log.debug('RTMP Url Streamer Stopped successfully!'); + return true; + } else { + log.debug('No RtmpUrl process to stop'); + return false; + } + } + + // #################################################### + // RTMP COMMON + // #################################################### + + getRTMPUrl(host, port) { + const rtmpServer = this.rtmp.server != '' ? this.rtmp.server : false; + const rtmpAppName = this.rtmp.appName != '' ? this.rtmp.appName : 'live'; + const rtmpStreamKey = this.rtmp.streamKey != '' ? this.rtmp.streamKey : uuidv4(); + const rtmpServerSecret = this.rtmp.secret != '' ? this.rtmp.secret : false; + const expirationHours = this.rtmp.expirationHours || 4; + const rtmpServerURL = rtmpServer ? rtmpServer : `rtmp://${host}:${port}`; + const rtmpServerPath = '/' + rtmpAppName + '/' + rtmpStreamKey; + + const rtmpUrl = rtmpServerSecret + ? this.generateRTMPUrl(rtmpServerURL, rtmpServerPath, rtmpServerSecret, expirationHours) + : rtmpServerURL + rtmpServerPath; + + log.info('RTMP Url generated', rtmpUrl); + return rtmpUrl; + } + + generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 8) { + const currentTime = Math.floor(Date.now() / 1000); + const expirationTime = currentTime + expirationHours * 3600; + const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString(); + const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`; + return rtmpUrl; + } + // #################################################### // ROUTER // #################################################### diff --git a/app/src/RtmpFile.js b/app/src/RtmpFile.js new file mode 100644 index 00000000..c802cbfc --- /dev/null +++ b/app/src/RtmpFile.js @@ -0,0 +1,97 @@ +'use strict'; + +const ffmpeg = require('fluent-ffmpeg'); +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +const Logger = require('./Logger'); +const log = new Logger('RtmpFile'); + +class RtmpFile { + constructor(socket_id = false, room = false) { + this.socketId = socket_id; + this.room = room; + this.rtmpUrl = ''; + this.ffmpegProcess = null; + } + + async start(inputStream, rtmpUrl) { + if (this.ffmpegProcess) { + log.debug('Streaming is already in progress'); + return false; + } + + this.rtmpUrl = rtmpUrl; + + try { + this.ffmpegProcess = ffmpeg(inputStream) + .inputOptions(['-re']) // Read input at native frame rate + .outputOptions([ + '-c:v libx264', // Encode video to H.264 + '-preset veryfast', // Set preset to very fast + '-maxrate 3000k', // Max bitrate for the video stream + '-bufsize 6000k', // Buffer size + '-g 50', // GOP size + '-c:a aac', // Encode audio to AAC + '-b:a 128k', // Bitrate for the audio stream + '-f flv', // Output format + ]) + .output(rtmpUrl) + .on('start', (commandLine) => log.info('ffmpeg process starting with command:', commandLine)) + .on('progress', (progress) => { + /* log.debug('Processing', progress); */ + }) + .on('error', (err, stdout, stderr) => { + log.debug('Error: ' + err.message); + this.ffmpegProcess = null; + if (!err.message.includes('Exiting normally')) { + this.handleError(err.message); + } + }) + .on('end', () => { + log.info('FFmpeg processing finished'); + this.ffmpegProcess = null; + this.handleEnd(); + }) + .run(); + + log.info('RtmpFile started', rtmpUrl); + return true; + } catch (error) { + log.error('Error starting RtmpFile', error.message); + return false; + } + } + + async stop() { + if (this.ffmpegProcess && !this.ffmpegProcess.killed) { + try { + this.ffmpegProcess.kill('SIGTERM'); + this.ffmpegProcess = null; + log.info('RtmpFile stopped'); + return true; + } catch (error) { + log.error('Error stopping RtmpFile', error.message); + return false; + } + } else { + log.debug('No RtmpFile process to stop'); + return true; + } + } + + handleEnd() { + if (!this.room) return; + this.room.send(this.socketId, 'endRTMP', { rtmpUrl: this.rtmpUrl }); + this.room.rtmpFileStreamer = false; + } + + handleError(message) { + if (!this.room) return; + this.room.send(this.socketId, 'errorRTMP', { message }); + this.room.rtmpFileStreamer = false; + log.error('Error: ' + message); + } +} + +module.exports = RtmpFile; diff --git a/app/src/RtmpStreamer.js b/app/src/RtmpStreamer.js new file mode 100644 index 00000000..4670cfc6 --- /dev/null +++ b/app/src/RtmpStreamer.js @@ -0,0 +1,74 @@ +'use strict'; + +const { PassThrough } = require('stream'); +const ffmpeg = require('fluent-ffmpeg'); +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +const Logger = require('./Logger'); +const log = new Logger('RtmpStreamer'); + +class RtmpStreamer { + constructor(rtmpUrl, rtmpKey) { + this.rtmpUrl = rtmpUrl; + this.rtmpKey = rtmpKey; + this.log = log; + this.stream = new PassThrough(); + this.ffmpegStream = null; + this.initFFmpeg(); + this.run = true; + } + + initFFmpeg() { + this.ffmpegStream = ffmpeg() + .input(this.stream) + .inputOptions('-re') + .inputFormat('webm') + .videoCodec('libx264') + .videoBitrate('3000k') + .size('1280x720') + .audioCodec('aac') + .audioBitrate('128k') + .outputOptions(['-f flv']) + .output(this.rtmpUrl) + .on('start', (commandLine) => this.log.info('ffmpeg command', { id: this.rtmpKey, cmd: commandLine })) + .on('progress', (progress) => { + /* log.debug('Processing', progress); */ + }) + .on('error', (err, stdout, stderr) => { + if (!err.message.includes('Exiting normally')) { + this.log.error('FFmpeg error:', { id: this.rtmpKey, error: err.message }); + } + this.end(); + }) + .on('end', () => { + this.log.info('FFmpeg process ended', this.rtmpKey); + this.end(); + }) + .run(); + } + + write(data) { + if (this.stream) this.stream.write(data); + } + + isRunning() { + return this.run; + } + + end() { + if (this.stream) { + this.stream.end(); + this.stream = null; + this.log.info('RTMP streaming stopped', this.rtmpKey); + } + if (this.ffmpegStream && !this.ffmpegStream.killed) { + this.ffmpegStream.kill('SIGTERM'); + this.ffmpegStream = null; + this.log.info('FFMPEG closed successfully', this.rtmpKey); + } + this.run = false; + } +} + +module.exports = RtmpStreamer; diff --git a/app/src/RtmpUrl.js b/app/src/RtmpUrl.js new file mode 100644 index 00000000..6b1d2ca4 --- /dev/null +++ b/app/src/RtmpUrl.js @@ -0,0 +1,93 @@ +'use strict'; + +const ffmpeg = require('fluent-ffmpeg'); +const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg'); +ffmpeg.setFfmpegPath(ffmpegInstaller.path); + +const Logger = require('./Logger'); +const log = new Logger('RtmpUrl'); + +class RtmpUrl { + constructor(socket_id = false, room = false) { + this.room = room; + this.socketId = socket_id; + this.rtmpUrl = ''; + this.ffmpegProcess = null; + } + + async start(inputVideoURL, rtmpUrl) { + if (this.ffmpegProcess) { + log.debug('Streaming is already in progress'); + return false; + } + + this.rtmpUrl = rtmpUrl; + + try { + this.ffmpegProcess = ffmpeg(inputVideoURL) + .inputOptions('-re') // Read input in real-time + .audioCodec('aac') // Set audio codec to AAC + .audioBitrate('128k') // Set audio bitrate to 128 kbps + .videoCodec('libx264') // Set video codec to H.264 + .videoBitrate('3000k') // Set video bitrate to 3000 kbps + .size('1280x720') // Scale video to 1280x720 resolution + .format('flv') // Set output format to FLV + .output(rtmpUrl) + .on('start', (commandLine) => log.info('ffmpeg process starting with command:', commandLine)) + .on('progress', (progress) => { + /* log.debug('Processing', progress); */ + }) + .on('error', (err, stdout, stderr) => { + log.debug('Error: ' + err.message); + this.ffmpegProcess = null; + if (!err.message.includes('Exiting normally')) { + this.handleError(err.message); + } + }) + .on('end', () => { + log.info('FFmpeg processing finished'); + this.ffmpegProcess = null; + this.handleEnd(); + }) + .run(); + + log.info('RtmpUrl started', rtmpUrl); + return true; + } catch (error) { + log.error('Error starting RtmpUrl', error.message); + return false; + } + } + + async stop() { + if (this.ffmpegProcess && !this.ffmpegProcess.killed) { + try { + this.ffmpegProcess.kill('SIGTERM'); + this.ffmpegProcess = null; + log.info('RtmpUrl stopped'); + return true; + } catch (error) { + log.error('Error stopping RtmpUrl', error.message); + return false; + } + } else { + log.debug('No RtmpUrl process to stop'); + return true; + } + } + + handleEnd() { + if (!this.room) return; + this.room.send(this.socketId, 'endRTMPfromURL', { rtmpUrl: this.rtmpUrl }); + this.room.rtmpUrlStreamer = false; + } + + handleError(message) { + if (!this.room) return; + this.room.send(this.socketId, 'errorRTMPfromURL', { message }); + this.room.rtmpUrlStreamer = false; + log.error('Error: ' + message); + } +} + +module.exports = RtmpUrl; diff --git a/app/src/Server.js b/app/src/Server.js index ce5ef5e9..ea1f8a49 100644 --- a/app/src/Server.js +++ b/app/src/Server.js @@ -8,6 +8,7 @@ ███████ ███████ ██  ██   ████   ███████ ██  ██                                        dependencies: { + @ffmpeg-installer/ffmpeg: https://www.npmjs.com/package/@ffmpeg-installer/ffmpeg @sentry/node : https://www.npmjs.com/package/@sentry/node @sentry/integrations : https://www.npmjs.com/package/@sentry/integrations axios : https://www.npmjs.com/package/axios @@ -18,6 +19,7 @@ dependencies: { crypto-js : https://www.npmjs.com/package/crypto-js express : https://www.npmjs.com/package/express express-openid-connect : https://www.npmjs.com/package/express-openid-connect + fluent-ffmpeg : https://www.npmjs.com/package/fluent-ffmpeg httpolyglot : https://www.npmjs.com/package/httpolyglot jsonwebtoken : https://www.npmjs.com/package/jsonwebtoken js-yaml : https://www.npmjs.com/package/js-yaml @@ -42,7 +44,7 @@ dependencies: { * @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon * @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970 * @author Miroslav Pejic - miroslav.pejic.85@gmail.com - * @version 1.4.51 + * @version 1.4.70 * */ @@ -50,6 +52,7 @@ const express = require('express'); const { auth, requiresAuth } = require('express-openid-connect'); const cors = require('cors'); const compression = require('compression'); +const socketIo = require('socket.io'); const https = require('httpolyglot'); const mediasoup = require('mediasoup'); const mediasoupClient = require('mediasoup-client'); @@ -75,6 +78,17 @@ const { CaptureConsole } = require('@sentry/integrations'); const restrictAccessByIP = require('./middleware/IpWhitelist.js'); const packageJson = require('../../package.json'); +// Incoming Stream to RTPM +const { v4: uuidv4 } = require('uuid'); +const crypto = require('crypto-js'); +const RtmpStreamer = require('./RtmpStreamer.js'); // Import the RtmpStreamer class +const rtmpCfg = config.server.rtmp; +const rtmpDir = rtmpCfg && rtmpCfg.dir ? rtmpCfg.dir : 'rtmp'; + +// File and Url Rtmp streams count +let rtmpFileStreamsCount = 0; +let rtmpUrlStreamsCount = 0; + // Email alerts and notifications const nodemailer = require('./lib/nodemailer'); @@ -98,7 +112,7 @@ const corsOptions = { }; const httpsServer = https.createServer(options, app); -const io = require('socket.io')(httpsServer, { +const io = socketIo(httpsServer, { maxHttpBufferSize: 1e7, transports: ['websocket'], cors: corsOptions, @@ -169,7 +183,7 @@ if (config.chatGPT.enabled) { }; chatGPT = new OpenAI(configuration); } else { - log.warning('ChatGPT seems enabled, but you missing the apiKey!'); + log.warn('ChatGPT seems enabled, but you missing the apiKey!'); } } @@ -200,13 +214,16 @@ const views = { permission: path.join(__dirname, '../../', 'public/views/permission.html'), privacy: path.join(__dirname, '../../', 'public/views/privacy.html'), room: path.join(__dirname, '../../', 'public/views/Room.html'), + rtmpStreamer: path.join(__dirname, '../../', 'public/views/RtmpStreamer.html'), }; const authHost = new Host(); // Authenticated IP by Login const roomList = new Map(); // All Rooms -const presenters = {}; // collect presenters grp by roomId +const presenters = {}; // Collect presenters grp by roomId + +const streams = {}; // Collect all rtmp streams const webRtcServerActive = config.mediasoup.webRtcServerActive; @@ -288,24 +305,28 @@ function startServer() { // Start the app app.use(cors(corsOptions)); app.use(compression()); - app.use(express.json()); + app.use(express.json({ limit: '50mb' })); // Ensure the body parser can handle large files app.use(express.static(dir.public)); app.use(bodyParser.urlencoded({ extended: true })); + app.use(bodyParser.raw({ type: 'video/webm', limit: '50mb' })); // handle raw binary data + app.use(bodyParser.raw({ type: 'application/octet-stream', limit: '50mb' })); // handle raw binary data app.use(restApi.basePath + '/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); // api docs // IP Whitelist check ... app.use(restrictAccessByIP); // Logs requests + /* app.use((req, res, next) => { log.debug('New request:', { - // headers: req.headers, + headers: req.headers, body: req.body, method: req.method, path: req.originalUrl, }); next(); }); + */ // POST start from here... app.post('*', function (next) { @@ -407,6 +428,14 @@ function startServer() { } }); + // Route to display rtmp streamer + app.get('/rtmp', OIDCAuth, (req, res) => { + if (!rtmpCfg || !rtmpCfg.fromStream) { + return res.json({ message: 'The RTMP Streamer is currently disabled.' }); + } + return res.sendFile(views.rtmpStreamer); + }); + // set new room name and join app.get(['/newroom'], OIDCAuth, (req, res) => { //log.info('/newroom - hostCfg ----->', hostCfg); @@ -655,6 +684,128 @@ function startServer() { } }); + // ############################################################### + // INCOMING STREAM (getUserMedia || getDisplayMedia) TO RTMP + // ############################################################### + + function checkRTMPApiSecret(req, res, next) { + const expectedApiSecret = rtmpCfg && rtmpCfg.apiSecret; + const apiSecret = req.headers.authorization; + + if (!apiSecret || apiSecret !== expectedApiSecret) { + log.warn('RTMP apiSecret Unauthorized', { + apiSecret: apiSecret, + expectedApiSecret: expectedApiSecret, + }); + return res.status(401).send('Unauthorized'); + } + next(); + } + + function checkMaxStreams(req, res, next) { + const maxStreams = (rtmpCfg && rtmpCfg.maxStreams) || 1; // Set your maximum allowed streams here + const activeStreams = Object.keys(streams).length; + if (activeStreams >= maxStreams) { + log.warn('Maximum number of streams reached', activeStreams); + return res.status(429).send('Maximum number of streams reached, please try later!'); + } + next(); + } + + app.get('/activeStreams', checkRTMPApiSecret, (req, res) => { + const activeStreams = Object.keys(streams).length; + log.info('Active Streams', activeStreams); + res.json(activeStreams); + }); + + app.get('/rtmpEnabled', (req, res) => { + const rtmpEnabled = rtmpCfg && rtmpCfg.enabled; + log.debug('RTMP enabled', rtmpEnabled); + res.json({ enabled: rtmpEnabled }); + }); + + app.post('/initRTMP', checkRTMPApiSecret, checkMaxStreams, async (req, res) => { + if (!rtmpCfg || !rtmpCfg.enabled) { + return res.status(400).send('RTMP server is not enabled or missing the config'); + } + + const domainName = config.ngrok.enabled ? 'localhost' : req.headers.host.split(':')[0]; + + const rtmpServer = rtmpCfg.server != '' ? rtmpCfg.server : false; + const rtmpServerAppName = rtmpCfg.appName != '' ? rtmpCfg.appName : 'live'; + const rtmpStreamKey = rtmpCfg.streamKey != '' ? rtmpCfg.streamKey : uuidv4(); + const rtmpServerSecret = rtmpCfg.secret != '' ? rtmpCfg.secret : false; + const expirationHours = rtmpCfg.expirationHours || 4; + const rtmpServerURL = rtmpServer ? rtmpServer : `rtmp://${domainName}:1935`; + const rtmpServerPath = '/' + rtmpServerAppName + '/' + rtmpStreamKey; + + const rtmp = rtmpServerSecret + ? generateRTMPUrl(rtmpServerURL, rtmpServerPath, rtmpServerSecret, expirationHours) + : rtmpServerURL + rtmpServerPath; + + log.info('initRTMP', { + headers: req.headers, + rtmpServer, + rtmpServerSecret, + rtmpServerURL, + rtmpServerPath, + expirationHours, + rtmpStreamKey, + rtmp, + }); + + const stream = new RtmpStreamer(rtmp, rtmpStreamKey); + streams[rtmpStreamKey] = stream; + + log.info('Active RTMP Streams', Object.keys(streams).length); + + return res.json({ rtmp }); + }); + + app.post('/streamRTMP', checkRTMPApiSecret, (req, res) => { + if (!rtmpCfg || !rtmpCfg.enabled) { + return res.status(400).send('RTMP server is not enabled'); + } + if (!req.body || req.body.length === 0) { + return res.status(400).send('Invalid video data'); + } + + const rtmpStreamKey = req.query.key; + const stream = streams[rtmpStreamKey]; + + if (!stream || !stream.isRunning()) { + delete streams[rtmpStreamKey]; + log.debug('Stream not found', { rtmpStreamKey, streams: Object.keys(streams).length }); + return res.status(404).send('FFmpeg Stream not found'); + } + + log.debug('Received video data', { + // data: req.body.slice(0, 20).toString('hex'), + key: rtmpStreamKey, + size: bytesToSize(req.headers['content-length']), + }); + + stream.write(Buffer.from(req.body)); + res.sendStatus(200); + }); + + app.post('/stopRTMP', checkRTMPApiSecret, (req, res) => { + if (!rtmpCfg || !rtmpCfg.enabled) { + return res.status(400).send('RTMP server is not enabled'); + } + + const rtmpStreamKey = req.query.key; + const stream = streams[rtmpStreamKey]; + + if (stream) { + stream.end(); + delete streams[rtmpStreamKey]; + log.debug('Active RTMP Streams', Object.keys(streams).length); + } + + res.sendStatus(200); + }); + // #################################################### // REST API // #################################################### @@ -1098,6 +1249,10 @@ function startServer() { log.info('[Join] - current active rooms', activeRooms); + const activeStreams = getRTMPActiveStreams(); + + log.info('[Join] - current active RTMP streams', activeStreams); + if (!(socket.room_id in presenters)) presenters[socket.room_id] = {}; // Set the presenters @@ -2135,6 +2290,93 @@ function startServer() { } }); + socket.on('getRTMP', async ({}, cb) => { + if (!roomList.has(socket.room_id)) return; + const room = roomList.get(socket.room_id); + const rtmpFiles = await room.getRTMP(rtmpDir); + cb(rtmpFiles); + }); + + socket.on('startRTMP', async (dataObject, cb) => { + if (!roomList.has(socket.room_id)) return; + + if (rtmpCfg && rtmpFileStreamsCount >= rtmpCfg.maxStreams) { + log.warn('RTMP max file streams reached', rtmpFileStreamsCount); + return cb(false); + } + + const data = checkXSS(dataObject); + const { peer_name, peer_uuid, file } = data; + const isPresenter = await isPeerPresenter(socket.room_id, socket.id, peer_name, peer_uuid); + if (!isPresenter) return cb(false); + + const room = roomList.get(socket.room_id); + const host = config.ngrok.enabled ? 'localhost' : socket.handshake.headers.host.split(':')[0]; + const rtmp = await room.startRTMP(socket.id, room, host, 1935, `../${rtmpDir}/${file}`); + + if (rtmp !== false) rtmpFileStreamsCount++; + log.debug('startRTMP - rtmpFileStreamsCount ---->', rtmpFileStreamsCount); + + cb(rtmp); + }); + + socket.on('stopRTMP', async () => { + if (!roomList.has(socket.room_id)) return; + + const room = roomList.get(socket.room_id); + + rtmpFileStreamsCount--; + log.debug('stopRTMP - rtmpFileStreamsCount ---->', rtmpFileStreamsCount); + + await room.stopRTMP(); + }); + + socket.on('endOrErrorRTMP', async () => { + if (!roomList.has(socket.room_id)) return; + rtmpFileStreamsCount--; + log.debug('endRTMP - rtmpFileStreamsCount ---->', rtmpFileStreamsCount); + }); + + socket.on('startRTMPfromURL', async (dataObject, cb) => { + if (!roomList.has(socket.room_id)) return; + + if (rtmpCfg && rtmpUrlStreamsCount >= rtmpCfg.maxStreams) { + log.warn('RTMP max Url streams reached', rtmpUrlStreamsCount); + return cb(false); + } + + const data = checkXSS(dataObject); + const { peer_name, peer_uuid, inputVideoURL } = data; + const isPresenter = await isPeerPresenter(socket.room_id, socket.id, peer_name, peer_uuid); + if (!isPresenter) return cb(false); + + const room = roomList.get(socket.room_id); + const host = config.ngrok.enabled ? 'localhost' : socket.handshake.headers.host.split(':')[0]; + const rtmp = await room.startRTMPfromURL(socket.id, room, host, 1935, inputVideoURL); + + if (rtmp !== false) rtmpUrlStreamsCount++; + log.debug('startRTMPfromURL - rtmpUrlStreamsCount ---->', rtmpUrlStreamsCount); + + cb(rtmp); + }); + + socket.on('stopRTMPfromURL', async () => { + if (!roomList.has(socket.room_id)) return; + + const room = roomList.get(socket.room_id); + + rtmpUrlStreamsCount--; + log.debug('stopRTMPfromURL - rtmpUrlStreamsCount ---->', rtmpUrlStreamsCount); + + await room.stopRTMPfromURL(); + }); + + socket.on('endOrErrorRTMPfromURL', async () => { + if (!roomList.has(socket.room_id)) return; + rtmpUrlStreamsCount--; + log.debug('endRTMPfromURL - rtmpUrlStreamsCount ---->', rtmpUrlStreamsCount); + }); + socket.on('disconnect', async () => { if (!roomList.has(socket.room_id)) return; @@ -2152,6 +2394,8 @@ function startServer() { if (room.getPeers().size === 0) { // + stopRTMPActiveStreams(isPresenter, room); + roomList.delete(socket.room_id); delete presenters[socket.room_id]; @@ -2161,6 +2405,10 @@ function startServer() { const activeRooms = getActiveRooms(); log.info('[Disconnect] - Last peer - current active rooms', activeRooms); + + const activeStreams = getRTMPActiveStreams(); + + log.info('[Disconnect] - Last peer - current active RTMP streams', activeStreams); } room.broadCast(socket.id, 'removeMe', removeMeData(room, peer_name, isPresenter)); @@ -2193,6 +2441,8 @@ function startServer() { if (room.getPeers().size === 0) { // + stopRTMPActiveStreams(isPresenter, room); + roomList.delete(socket.room_id); delete presenters[socket.room_id]; @@ -2202,6 +2452,10 @@ function startServer() { const activeRooms = getActiveRooms(); log.info('[REMOVE ME] - Last peer - current active rooms', activeRooms); + + const activeStreams = getRTMPActiveStreams(); + + log.info('[REMOVE ME] - Last peer - current active RTMP streams', activeStreams); } socket.room_id = null; @@ -2270,15 +2524,54 @@ function startServer() { log.debug('[REMOVE ME DATA]', data); return data; } - - function bytesToSize(bytes) { - let sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - if (bytes == 0) return '0 Byte'; - let i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); - return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]; - } }); + function generateRTMPUrl(baseURL, streamPath, secretKey, expirationHours = 4) { + const currentTime = Math.floor(Date.now() / 1000); + const expirationTime = currentTime + expirationHours * 3600; + const hashValue = crypto.MD5(`${streamPath}-${expirationTime}-${secretKey}`).toString(); + const rtmpUrl = `${baseURL}${streamPath}?sign=${expirationTime}-${hashValue}`; + + log.debug('generateRTMPUrl', { + currentTime, + expirationTime, + hashValue, + rtmpUrl, + }); + + return rtmpUrl; + } + + function getRTMPActiveStreams() { + return { + rtmpStreams: Object.keys(streams).length, + rtmpFileStreamsCount, + rtmpUrlStreamsCount, + }; + } + + function stopRTMPActiveStreams(isPresenter, room) { + if (isPresenter) { + if (room.isRtmpFileStreamerActive()) { + room.stopRTMP(); + rtmpFileStreamsCount--; + log.info('[REMOVE ME] - Stop RTMP Stream From FIle', rtmpFileStreamsCount); + } + if (room.isRtmpUrlStreamerActive()) { + room.stopRTMPfromURL(); + rtmpUrlStreamsCount--; + log.info('[REMOVE ME] - Stop RTMP Stream From URL', rtmpUrlStreamsCount); + } + } + } + + function bytesToSize(bytes) { + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes == 0) return '0 Byte'; + const i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + return Math.round(bytes / Math.pow(1024, i), 2) + ' ' + sizes[i]; + } + function clone(value) { if (value === undefined) return undefined; if (Number.isNaN(value)) return NaN; diff --git a/app/src/config.template.js b/app/src/config.template.js index c3a7d87a..daffb25b 100644 --- a/app/src/config.template.js +++ b/app/src/config.template.js @@ -59,6 +59,42 @@ module.exports = { endpoint: '', // Change the URL if you want to save the recording to a different server or cloud service (http://localhost:8080), otherwise leave it as is (empty). dir: 'rec', }, + rtmp: { + /* + Real-Time Messaging Protocol (RTMP) is a communication protocol for streaming audio, video, and data over the Internet. (beta) + + Configuration: + - enabled: Enable or disable the RTMP streaming feature. Set to 'true' to enable, 'false' to disable. + - fromFile: Enable or disable the RTMP streaming from File. Set to 'true' to enable, 'false' to disable. + - fromUrl: Enable or disable the RTMP streaming from Url. Set to 'true' to enable, 'false' to disable. + - fromStream: Enable or disable the RTMP Streamer. Set to 'true' to enable, 'false' to disable. + - maxStreams: Specifies the maximum number of simultaneous streams permitted for File, URL, and Stream. The default value is 1. + - server: The URL of the RTMP server. Leave empty to use the built-in MiroTalk RTMP server (rtmp://localhost:1935). Change the URL to connect to a different RTMP server. + - appName: The application name for the RTMP stream. Default is 'mirotalk'. + - streamKey: The stream key for the RTMP stream. Leave empty if not required. + - secret: The secret key for RTMP streaming. Must match the secret in rtmpServers/node-media-server/src/config.js. Leave empty if no authentication is needed. + - apiSecret: The API secret for streaming WebRTC to RTMP through the MiroTalk API. + - expirationHours: The number of hours before the RTMP URL expires. Default is 4 hours. + - dir: Directory where your video files are stored to be streamed via RTMP. + + Important: Ensure your RTMP server is operational before proceeding. You can start the server by running the following command: + - Start: npm run nms-start - Start the RTMP server. + - Stop: npm run npm-stop - Stop the RTMP server. + - Logs: npm run npm-logs - View the logs of the RTMP server. + */ + enabled: false, + fromFile: true, + fromUrl: true, + fromStream: true, + maxStreams: 1, + server: 'rtmp://localhost:1935', + appName: 'mirotalk', + streamKey: '', + secret: 'mirotalkRtmpSecret', + apiSecret: 'mirotalkRtmpApiSecret', + expirationHours: 4, + dir: 'rtmp', + }, }, middleware: { /* @@ -333,6 +369,7 @@ module.exports = { lobbyButton: true, // presenter sendEmailInvitation: true, // presenter micOptionsButton: true, // presenter + tabRTMPStreamingBtn: true, // presenter tabModerator: true, // presenter tabRecording: true, host_only_recording: true, // presenter diff --git a/docker-compose.template.yml b/docker-compose.template.yml index 1be069fb..b13620af 100644 --- a/docker-compose.template.yml +++ b/docker-compose.template.yml @@ -13,6 +13,8 @@ services: - ./app/src/config.js:/src/app/src/config.js:ro # These volume is mandatory if server.recording.enabled in the app/src/config.js # - ./app/rec:/src/app/rec + # These volume is mandatory if server.rtmp.enabled fromFile in the app/src/config.js + # - ./app/rtmp:/src/app/rtmp # These volumes are not mandatory, comment if you want to use it # - ./app/:/src/app/:ro # - ./public/:/src/public/:ro diff --git a/package.json b/package.json index 75f42596..0bfdc45f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mirotalksfu", - "version": "1.4.51", + "version": "1.4.70", "description": "WebRTC SFU browser-based video calls", "main": "Server.js", "scripts": { @@ -18,7 +18,16 @@ "docker-run": "docker run -d -p 40000-40100:40000-40100 -p 3010:3010 -v ./app/src/config.js:/src/app/src/config.js:ro --name mirotalksfu mirotalk/sfu:latest", "docker-run-vm": "docker run -d -p 40000-40100:40000-40100 -p 3010:3010 -v ./app/:/src/app/:ro -v ./public/:/src/public/:ro --name mirotalksfu mirotalk/sfu:latest", "docker-start": "docker start mirotalksfu", - "docker-stop": "docker stop mirotalksfu" + "docker-stop": "docker stop mirotalksfu", + "rtmp-start": "docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml up -d", + "rtmp-stop": "docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml down", + "rtmp-restart": "docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml down && docker-compose -f rtmpServers/nginx-rtmp/docker-compose.yml up -d", + "rtmp-logs": "docker logs -f mirotalk-rtmp", + "nms-node-start": "node rtmpServers/node-media-server/src/server.js", + "nms-start": "docker-compose -f rtmpServers/node-media-server/docker-compose.yml up -d", + "nms-stop": "docker-compose -f rtmpServers/node-media-server/docker-compose.yml down", + "nms-restart": "docker-compose -f rtmpServers/node-media-server/docker-compose.yml down && docker-compose -f rtmpServers/node-media-server/docker-compose.yml up -d", + "nms-logs": "docker logs -f mirotalk-nms" }, "repository": { "type": "git", @@ -33,7 +42,12 @@ "video", "audio", "openai", - "chatgpt" + "chatgpt", + "rtmp", + "client", + "server", + "streaming", + "realtime" ], "author": "Miroslav Pejic", "license": "AGPL-3.0", @@ -41,6 +55,7 @@ "node": ">=18" }, "dependencies": { + "@ffmpeg-installer/ffmpeg": "^1.1.0", "@sentry/integrations": "7.114.0", "@sentry/node": "7.114.0", "axios": "^1.7.2", @@ -51,14 +66,15 @@ "crypto-js": "4.2.0", "express": "4.19.2", "express-openid-connect": "^2.17.1", + "fluent-ffmpeg": "^2.1.3", "httpolyglot": "0.1.2", - "jsonwebtoken": "^9.0.2", "js-yaml": "^4.1.0", - "mediasoup": "3.14.7", - "mediasoup-client": "3.7.10", + "jsonwebtoken": "^9.0.2", + "mediasoup": "3.14.8", + "mediasoup-client": "3.7.12", "ngrok": "^5.0.0-beta.2", - "nodemailer": "^6.9.13", - "openai": "^4.51.0", + "nodemailer": "^6.9.14", + "openai": "^4.52.2", "qs": "6.12.1", "socket.io": "4.7.5", "swagger-ui-express": "5.0.1", @@ -67,7 +83,7 @@ }, "devDependencies": { "node-fetch": "^3.3.2", - "nodemon": "^3.1.3", + "nodemon": "^3.1.4", "prettier": "3.3.2" } } diff --git a/public/css/Room.css b/public/css/Room.css index b87ef7ba..df4c0a1b 100644 --- a/public/css/Room.css +++ b/public/css/Room.css @@ -31,56 +31,6 @@ } } -:root { - --body-bg: radial-gradient(#393939, #000000); - --border: 1px solid rgb(255 255 255 / 32%); - --border-radius: 1rem; - --msger-width: 800px; - --msger-height: 700px; - --msger-bubble-width: 85%; - --msger-bg: radial-gradient(#393939, #000000); - --wb-width: 800px; - --wb-height: 600px; - --wb-bg: radial-gradient(#393939, #000000); - --select-bg: #2c2c2c; - --left-msg-bg: #252d31; - --right-msg-bg: #056162; - --private-msg-bg: #6b1226; - --box-shadow: 0px 8px 16px 0px rgb(0 0 0); - --btns-hover-scale: scale(1.1); - --settings-bg: radial-gradient(#393939, #000000); - --tab-btn-active: rgb(42 42 42 / 70%); - --btns-bg-color: rgba(0, 0, 0, 0.7); - /* buttons bar horizontal */ - --btns-top: 50%; - --btns-right: 0%; - --btns-left: 10px; - --btns-margin-left: 0px; - --btns-width: 60px; - --btns-flex-direction: column; - /* buttons bar horizontal - --btns-top: 95%; - --btns-right: 25%; - --btns-left: 50%; - --btns-margin-left: -160px; - --btns-width: 320px; - --btns-flex-direction: row; - */ - - --transcription-height: 680px; - --transcription-width: 420px; - --transcription-bg: radial-gradient(#393939, #000000); - - --vmi-wh: 15vw; - /* https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit */ - --videoObjFit: cover; -} - -* { - outline: none; - font-family: 'Comfortaa'; -} - html, body { top: 0 !important; @@ -441,6 +391,77 @@ th { width: 180px; } +/*-------------------------------------------------------------- +# RTMP settings +--------------------------------------------------------------*/ + +.file-table { + margin-top: 10px; + color: #fff; + width: 100%; + border-collapse: collapse; + border: var(--border); + table-layout: fixed; /* Ensures equal column width */ +} +.file-table th, +.file-table td { + border: none; + padding: 8px; + text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-table th { + background: var(--select-bg); +} +.file-table tbody { + display: block; + max-height: 80px; + overflow-y: auto; +} +.file-table tbody::-webkit-scrollbar { + width: 8px; +} +.file-table tbody::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} +.file-item { + cursor: pointer; +} +.file-item:hover, +.file-item.selected { + background: var(--body-bg); +} + +#file-name { + margin-top: 20px; + margin-left: 5px; + font-weight: bold; + color: #888; +} + +.input-container { + display: flex; + align-items: center; +} + +#rtmpStreamURL, +#rtmp-url { + margin-top: 5px; + padding: 10px; + width: 100%; + color: #fff; + border: none; + background: var(--select-bg) !important; +} + +.input-container button { + flex: 1; + width: 20px; +} + /*-------------------------------------------------------------- # Dropdown menu --------------------------------------------------------------*/ diff --git a/public/css/Root.css b/public/css/Root.css new file mode 100644 index 00000000..66643c8c --- /dev/null +++ b/public/css/Root.css @@ -0,0 +1,51 @@ +@import url('https://fonts.googleapis.com/css?family=Comfortaa:wght@500&display=swap'); + +:root { + --body-bg: radial-gradient(#393939, #000000); + --border: 1px solid rgb(255 255 255 / 32%); + --border-radius: 1rem; + --msger-width: 800px; + --msger-height: 700px; + --msger-bubble-width: 85%; + --msger-bg: radial-gradient(#393939, #000000); + --wb-width: 800px; + --wb-height: 600px; + --wb-bg: radial-gradient(#393939, #000000); + --select-bg: #2c2c2c; + --left-msg-bg: #252d31; + --right-msg-bg: #056162; + --private-msg-bg: #6b1226; + --box-shadow: 0px 8px 16px 0px rgb(0 0 0); + --btns-hover-scale: scale(1.1); + --settings-bg: radial-gradient(#393939, #000000); + --tab-btn-active: rgb(42 42 42 / 70%); + --btns-bg-color: rgba(0, 0, 0, 0.7); + /* buttons bar horizontal */ + --btns-top: 50%; + --btns-right: 0%; + --btns-left: 10px; + --btns-margin-left: 0px; + --btns-width: 60px; + --btns-flex-direction: column; + /* buttons bar horizontal + --btns-top: 95%; + --btns-right: 25%; + --btns-left: 50%; + --btns-margin-left: -160px; + --btns-width: 320px; + --btns-flex-direction: row; + */ + + --transcription-height: 680px; + --transcription-width: 420px; + --transcription-bg: radial-gradient(#393939, #000000); + + --vmi-wh: 15vw; + /* https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit */ + --videoObjFit: cover; +} + +* { + outline: none; + font-family: 'Comfortaa'; +} diff --git a/public/css/RtmpStreamer.css b/public/css/RtmpStreamer.css new file mode 100644 index 00000000..86540282 --- /dev/null +++ b/public/css/RtmpStreamer.css @@ -0,0 +1,206 @@ +@import url('https://fonts.googleapis.com/css?family=Comfortaa:wght@500&display=swap'); + +body { + font-family: 'Comfortaa'; /*, Arial, sans-serif;*/ + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + margin: 0; + background: var(--body-bg); + color: #fff; +} + +.container { + max-width: 800px; + margin: 0 auto; + text-align: center; + padding: 20px; + background: var(--body-bg); + color: #fff; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +h1 { + margin-top: 10px; + color: #ffffff; +} + +video { + border: 0.1px solid #ccc; + margin: 10px 0; + border-radius: 8px; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.input-group-inline { + display: flex; + align-items: center; + gap: 10px; +} + +#apiSecret { + flex: 1; +} + +#rtmp { + flex: 1; +} + +#copyButton { + flex: 1; + max-width: 100px; +} + +.input-group-inline > * { + margin-bottom: 20px; +} + +input, +button { + padding: 10px; + font-size: 16px; + border: none; + border-radius: 4px; + outline: none; + box-sizing: border-box; +} + +input[type='text'], +input[type='password'] { + flex: 1; + background: #2c2c2c; + color: #fff; +} + +input[type='text'][readonly] { + background: #2c2c2c; + color: #fff; +} + +button { + cursor: pointer; + background-color: #007bff; + color: #fff; + transition: background-color 0.3s ease; +} + +button:disabled { + background: #2c2c2c; + cursor: not-allowed; +} +button:hover { + background-color: #0056b3; +} +.button-group { + display: flex; + justify-content: center; + gap: 10px; + margin-top: 20px; +} + +.button-group button { + width: 100%; +} + +.popup { + position: fixed; + top: 10px; + left: 50%; + transform: translateX(-50%); + background-color: indianred; + color: white; + padding: 15px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + z-index: 1000; + display: flex; + align-items: center; + justify-content: space-between; + min-width: 300px; + max-width: 600px; + width: 80%; +} + +.popup.success { + background-color: mediumseagreen; + color: white; +} + +.popup.error { + background-color: indianred; + color: white; +} + +.popup.warning { + background-color: gold; + color: white; +} + +.popup.info { + background-color: cornflowerblue; + color: white; +} + +.popup.hidden { + display: none; +} + +#closePopup { + background: none; + border: none; + color: white; + font-size: 16px; + cursor: pointer; + margin-left: 20px; +} + +footer { + color: grey; +} + +/* Media Queries for Responsiveness */ +@media (max-width: 1024px) { + .container { + padding: 15px; + } + input, + button { + font-size: 14px; + } +} + +@media (max-width: 768px) { + .input-group-inline { + flex-direction: column; + } + input, + button { + width: 100%; + font-size: 14px; + margin-bottom: 10px; + } + video { + width: 100%; + height: auto; + } + #copyButton { + max-width: 100%; + } +} + +@media (max-width: 480px) { + .container { + padding: 10px; + } + h1 { + font-size: 24px; + } + input, + button { + font-size: 12px; + padding: 8px; + } +} diff --git a/public/images/rtmp.png b/public/images/rtmp.png new file mode 100644 index 00000000..a8e8a0a8 Binary files /dev/null and b/public/images/rtmp.png differ diff --git a/public/js/Room.js b/public/js/Room.js index ca48f3c9..a6bb6b58 100644 --- a/public/js/Room.js +++ b/public/js/Room.js @@ -11,7 +11,7 @@ if (location.href.substr(0, 5) !== 'https') location.href = 'https' + location.h * @license For commercial or closed source, contact us at license.mirotalk@gmail.com or purchase directly via CodeCanyon * @license CodeCanyon: https://codecanyon.net/item/mirotalk-sfu-webrtc-realtime-video-conferences/40769970 * @author Miroslav Pejic - miroslav.pejic.85@gmail.com - * @version 1.4.51 + * @version 1.4.70 * */ @@ -1130,6 +1130,17 @@ function copyRoomURL() { userLog('info', 'Meeting URL copied to clipboard 👍', 'top-end'); } +function copyToClipboard(txt) { + let tmpInput = document.createElement('input'); + document.body.appendChild(tmpInput); + tmpInput.value = txt; + tmpInput.select(); + tmpInput.setSelectionRange(0, 99999); // For mobile devices + navigator.clipboard.writeText(tmpInput.value); + document.body.removeChild(tmpInput); + userLog('info', `${txt} copied to clipboard 👍`, 'top-end'); +} + function shareRoomByEmail() { Swal.fire({ allowOutsideClick: false, @@ -1265,6 +1276,11 @@ function roomIsReady() { BUTTONS.chat.chatPinButton && show(chatTogglePin); BUTTONS.chat.chatMaxButton && show(chatMaxButton); BUTTONS.settings.pushToTalk && show(pushToTalkDiv); + BUTTONS.settings.tabRTMPStreamingBtn && + show(tabRTMPStreamingBtn) && + show(startRtmpButton) && + show(startRtmpURLButton) && + show(streamerRtmpButton); } if (DetectRTC.browser.name != 'Safari') { document.onfullscreenchange = () => { @@ -1431,6 +1447,10 @@ function handleButtons() { tabVideoShareBtn.onclick = (e) => { rc.openTab(e, 'tabVideoShare'); }; + tabRTMPStreamingBtn.onclick = (e) => { + rc.getRTMP(); + rc.openTab(e, 'tabRTMPStreaming'); + }; tabAspectBtn.onclick = (e) => { rc.openTab(e, 'tabAspect'); }; @@ -1655,6 +1675,28 @@ function handleButtons() { stopScreenButton.onclick = () => { rc.closeProducer(RoomClient.mediaType.screen); }; + copyRtmpUrlButton.onclick = () => { + rc.copyRTMPUrl(rtmpStreamURL.value); + }; + startRtmpButton.onclick = () => { + if (rc.selectedRtmpFilename == '') { + userLog('warning', 'Please select the Video file to stream', 'top-end', 6000); + return; + } + rc.startRTMP(); + }; + stopRtmpButton.onclick = () => { + rc.stopRTMP(); + }; + streamerRtmpButton.onclick = () => { + openURL('/rtmp', true); + }; + startRtmpURLButton.onclick = () => { + rc.startRTMPfromURL(rtmpStreamURL.value); + }; + stopRtmpURLButton.onclick = () => { + rc.stopRTMPfromURL(); + }; fileShareButton.onclick = () => { rc.selectFileToShare(socket.id, true); }; @@ -2723,6 +2765,36 @@ function handleRoomClientEvents() { hostOnlyRecording = false; } }); + rc.on(RoomClient.EVENTS.startRTMP, () => { + console.log('Room event: RTMP started'); + hide(startRtmpButton); + show(stopRtmpButton); + }); + rc.on(RoomClient.EVENTS.stopRTMP, () => { + console.log('Room event: RTMP stopped'); + hide(stopRtmpButton); + show(startRtmpButton); + }); + rc.on(RoomClient.EVENTS.endRTMP, () => { + console.log('Room event: RTMP ended'); + hide(stopRtmpButton); + show(startRtmpButton); + }); + rc.on(RoomClient.EVENTS.startRTMPfromURL, () => { + console.log('Room event: RTMP from URL started'); + hide(startRtmpURLButton); + show(stopRtmpURLButton); + }); + rc.on(RoomClient.EVENTS.stopRTMPfromURL, () => { + console.log('Room event: RTMP from URL stopped'); + hide(stopRtmpURLButton); + show(startRtmpURLButton); + }); + rc.on(RoomClient.EVENTS.endRTMPfromURL, () => { + console.log('Room event: RTMP from URL ended'); + hide(stopRtmpURLButton); + show(startRtmpURLButton); + }); rc.on(RoomClient.EVENTS.exitRoom, () => { console.log('Room event: Client leave room'); if (rc.isRecording() || recordingStatus.innerText != '0s') { @@ -3960,7 +4032,7 @@ function showAbout() { imageUrl: image.about, customClass: { image: 'img-about' }, position: 'center', - title: 'WebRTC SFU v1.4.51', + title: 'WebRTC SFU v1.4.60', html: `
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

+
+ +
+
+ + +
+ + + +
+ + + +
+
+ + + 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

+
+ +
+
+ + +
+ +
+ + + +
+
+ + + 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

+
+ +
+
+ + +
+ +
+ + + +
+
+ + + 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 00000000..451682cf Binary files /dev/null and b/rtmpServers/rtmpStreaming.jpeg differ