[mirotalksfu] - add RTMP server and multi-source streaming!, update dep
هذا الالتزام موجود في:
9
rtmpServers/README.md
Normal file
9
rtmpServers/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# MiroTalk RTMP Servers
|
||||
|
||||

|
||||
|
||||
### How to start the RTMP server?
|
||||
|
||||
[https://docs.mirotalk.com/mirotalk-sfu/rtmp/](https://docs.mirotalk.com/mirotalk-sfu/rtmp/)
|
||||
|
||||
---
|
||||
256
rtmpServers/demo/client-server-axios/client/client.js
Normal file
256
rtmpServers/demo/client-server-axios/client/client.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
51
rtmpServers/demo/client-server-axios/client/index.html
Normal file
51
rtmpServers/demo/client-server-axios/client/index.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MiroTalk RTMP Streamer</title>
|
||||
<link id="icon" rel="shortcut icon" href="./logo.svg" />
|
||||
<link id="appleTouchIcon" rel="apple-touch-icon" href="./logo.svg" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script defer src="./client.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="popup" class="popup hidden">
|
||||
<span id="popupMessage"></span>
|
||||
<button id="closePopup">X</button>
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1>MiroTalk RTMP Streamer</h1>
|
||||
<div class="input-group-inline">
|
||||
<input
|
||||
id="apiSecret"
|
||||
type="password"
|
||||
value="mirotalkRtmpApiSecret"
|
||||
placeholder="API Secret"
|
||||
title="Enter the API secret here"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-inline">
|
||||
<input
|
||||
id="rtmp"
|
||||
type="text"
|
||||
value=""
|
||||
placeholder="rtmp://server:port/app/streamKey"
|
||||
readonly
|
||||
title="This is your RTMP live URL. It cannot be edited."
|
||||
/>
|
||||
<button id="copy" title="Click to copy the RTMP URL">Copy</button>
|
||||
</div>
|
||||
<video id="video" width="640" height="480" autoplay></video>
|
||||
<div class="button-group">
|
||||
<button id="startCamera" title="Click to start camera streaming">Start Camera Streaming</button>
|
||||
<button id="startScreen" title="Click to start screen streaming">Start Screen Streaming</button>
|
||||
<button id="stop" disabled title="Click to stop streaming">Stop Streaming</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>© 2024 MiroTalk SFU, all rights reserved</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
1
rtmpServers/demo/client-server-axios/client/logo.svg
Normal file
1
rtmpServers/demo/client-server-axios/client/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="28" height="32" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a"><stop stop-color="#00BFFB" offset="0%"/><stop stop-color="#0270D7" offset="100%"/></linearGradient><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b"><stop stop-color="#1F232A" stop-opacity=".48" offset="0%"/><stop stop-color="#1F2329" stop-opacity="0" offset="100%"/></linearGradient><linearGradient x1="87.665%" y1="103.739%" x2="-3.169%" y2="38.807%" id="c"><stop stop-color="#FFF" stop-opacity="0" offset="0%"/><stop stop-color="#FFF" stop-opacity=".64" offset="100%"/></linearGradient><linearGradient x1="-14.104%" y1="111.262%" x2="109.871%" y2="26.355%" id="d"><stop stop-color="#0270D7" offset="0%"/><stop stop-color="#0270D7" stop-opacity="0" offset="100%"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" transform="rotate(90 14 16)" d="M6 2l-8 13.999L6 30h16l8-14.001L22 2z"/><path fill="url(#b)" d="M14 0v32L0 24V8z"/><path fill="url(#c)" d="M28 24L0 8l14.001-8L28 8z"/><path fill-opacity=".48" fill="url(#d)" style="mix-blend-mode:multiply" d="M28 8L0 23.978V8l14.001-8L28 8z"/></g></svg>
|
||||
|
بعد العرض: | الارتفاع: | الحجم: 1.1 KiB |
205
rtmpServers/demo/client-server-axios/client/style.css
Normal file
205
rtmpServers/demo/client-server-axios/client/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
67
rtmpServers/demo/client-server-axios/server/RtmpStreamer.js
Normal file
67
rtmpServers/demo/client-server-axios/server/RtmpStreamer.js
Normal file
@@ -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;
|
||||
28
rtmpServers/demo/client-server-axios/server/package.json
Normal file
28
rtmpServers/demo/client-server-axios/server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
182
rtmpServers/demo/client-server-axios/server/server.js
Normal file
182
rtmpServers/demo/client-server-axios/server/server.js
Normal file
@@ -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);
|
||||
});
|
||||
212
rtmpServers/demo/client-server-socket/client/client.js
Normal file
212
rtmpServers/demo/client-server-socket/client/client.js
Normal file
@@ -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();
|
||||
}
|
||||
});
|
||||
52
rtmpServers/demo/client-server-socket/client/index.html
Normal file
52
rtmpServers/demo/client-server-socket/client/index.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MiroTalk RTMP Streamer</title>
|
||||
<link id="icon" rel="shortcut icon" href="./logo.svg" />
|
||||
<link id="appleTouchIcon" rel="apple-touch-icon" href="./logo.svg" />
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script defer src="/socket.io/socket.io.js"></script>
|
||||
<script defer src="./client.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="popup" class="popup hidden">
|
||||
<span id="popupMessage"></span>
|
||||
<button id="closePopup">X</button>
|
||||
</div>
|
||||
<div class="container">
|
||||
<h1>MiroTalk RTMP Streamer</h1>
|
||||
<div class="input-group-inline">
|
||||
<input
|
||||
id="apiSecret"
|
||||
type="password"
|
||||
value="mirotalkRtmpApiSecret"
|
||||
placeholder="API Secret"
|
||||
title="Enter the API secret here"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group-inline">
|
||||
<input
|
||||
id="rtmp"
|
||||
type="text"
|
||||
value=""
|
||||
placeholder="rtmp://server:port/app/streamKey"
|
||||
readonly
|
||||
title="This is your RTMP live URL. It cannot be edited."
|
||||
/>
|
||||
<button id="copy" title="Click to copy the RTMP URL">Copy</button>
|
||||
</div>
|
||||
<video id="video" width="640" height="480" autoplay></video>
|
||||
<div class="button-group">
|
||||
<button id="startCamera" title="Click to start camera streaming">Start Camera Streaming</button>
|
||||
<button id="startScreen" title="Click to start screen streaming">Start Screen Streaming</button>
|
||||
<button id="stop" disabled title="Click to stop streaming">Stop Streaming</button>
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<p>© 2024 MiroTalk SFU, all rights reserved</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
1
rtmpServers/demo/client-server-socket/client/logo.svg
Normal file
1
rtmpServers/demo/client-server-socket/client/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="28" height="32" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="a"><stop stop-color="#00BFFB" offset="0%"/><stop stop-color="#0270D7" offset="100%"/></linearGradient><linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="b"><stop stop-color="#1F232A" stop-opacity=".48" offset="0%"/><stop stop-color="#1F2329" stop-opacity="0" offset="100%"/></linearGradient><linearGradient x1="87.665%" y1="103.739%" x2="-3.169%" y2="38.807%" id="c"><stop stop-color="#FFF" stop-opacity="0" offset="0%"/><stop stop-color="#FFF" stop-opacity=".64" offset="100%"/></linearGradient><linearGradient x1="-14.104%" y1="111.262%" x2="109.871%" y2="26.355%" id="d"><stop stop-color="#0270D7" offset="0%"/><stop stop-color="#0270D7" stop-opacity="0" offset="100%"/></linearGradient></defs><g fill="none" fill-rule="evenodd"><path fill="url(#a)" transform="rotate(90 14 16)" d="M6 2l-8 13.999L6 30h16l8-14.001L22 2z"/><path fill="url(#b)" d="M14 0v32L0 24V8z"/><path fill="url(#c)" d="M28 24L0 8l14.001-8L28 8z"/><path fill-opacity=".48" fill="url(#d)" style="mix-blend-mode:multiply" d="M28 8L0 23.978V8l14.001-8L28 8z"/></g></svg>
|
||||
|
بعد العرض: | الارتفاع: | الحجم: 1.1 KiB |
205
rtmpServers/demo/client-server-socket/client/style.css
Normal file
205
rtmpServers/demo/client-server-socket/client/style.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
68
rtmpServers/demo/client-server-socket/server/RtmpStreamer.js
Normal file
68
rtmpServers/demo/client-server-socket/server/RtmpStreamer.js
Normal file
@@ -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;
|
||||
30
rtmpServers/demo/client-server-socket/server/package.json
Normal file
30
rtmpServers/demo/client-server-socket/server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
179
rtmpServers/demo/client-server-socket/server/server.js
Normal file
179
rtmpServers/demo/client-server-socket/server/server.js
Normal file
@@ -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);
|
||||
});
|
||||
61
rtmpServers/nginx-rtmp/Dockerfile
Normal file
61
rtmpServers/nginx-rtmp/Dockerfile
Normal file
@@ -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;"]
|
||||
30
rtmpServers/nginx-rtmp/README.md
Normal file
30
rtmpServers/nginx-rtmp/README.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# RTMP Streaming
|
||||
|
||||

|
||||
|
||||
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.
|
||||
14
rtmpServers/nginx-rtmp/docker-compose.template.yml
Normal file
14
rtmpServers/nginx-rtmp/docker-compose.template.yml
Normal file
@@ -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
|
||||
34
rtmpServers/nginx-rtmp/nginx.conf
Normal file
34
rtmpServers/nginx-rtmp/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
rtmpServers/nginx-rtmp/stat.xsl
Normal file
30
rtmpServers/nginx-rtmp/stat.xsl
Normal file
@@ -0,0 +1,30 @@
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
|
||||
<xsl:template match="/">
|
||||
<html>
|
||||
<body>
|
||||
<h1>RTMP Statistics</h1>
|
||||
<table border="none">
|
||||
<tr bgcolor="#9acd32">
|
||||
<th>Stream</th>
|
||||
<th>Bitrate (kbps)</th>
|
||||
<th>Bytes</th>
|
||||
<th>Client</th>
|
||||
<th>BW (kbps)</th>
|
||||
<th>Time</th>
|
||||
</tr>
|
||||
<xsl:for-each select="rtmp/server/application/live/">
|
||||
<tr>
|
||||
<td><xsl:value-of select="@name"/></td>
|
||||
<td><xsl:value-of select="bw_in_video"/></td>
|
||||
<td><xsl:value-of select="bytes"/></td>
|
||||
<td><xsl:value-of select="client_ip"/></td>
|
||||
<td><xsl:value-of select="bw_out_video"/></td>
|
||||
<td><xsl:value-of select="time"/></td>
|
||||
</tr>
|
||||
</xsl:for-each>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
|
||||
26
rtmpServers/node-media-server/Dockerfile
Normal file
26
rtmpServers/node-media-server/Dockerfile
Normal file
@@ -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"]
|
||||
47
rtmpServers/node-media-server/README.md
Normal file
47
rtmpServers/node-media-server/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# RTMP Streaming
|
||||
|
||||

|
||||
|
||||
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.
|
||||
13
rtmpServers/node-media-server/docker-compose.template.yml
Normal file
13
rtmpServers/node-media-server/docker-compose.template.yml
Normal file
@@ -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
|
||||
26
rtmpServers/node-media-server/package.json
Normal file
26
rtmpServers/node-media-server/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
21
rtmpServers/node-media-server/src/cert.pem
Normal file
21
rtmpServers/node-media-server/src/cert.pem
Normal file
@@ -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-----
|
||||
30
rtmpServers/node-media-server/src/config.template.js
Normal file
30
rtmpServers/node-media-server/src/config.template.js
Normal file
@@ -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;
|
||||
27
rtmpServers/node-media-server/src/key.pem
Normal file
27
rtmpServers/node-media-server/src/key.pem
Normal file
@@ -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-----
|
||||
81
rtmpServers/node-media-server/src/server.js
Normal file
81
rtmpServers/node-media-server/src/server.js
Normal file
@@ -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
|
||||
});
|
||||
58
rtmpServers/node-media-server/src/sign.js
Normal file
58
rtmpServers/node-media-server/src/sign.js
Normal file
@@ -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"
|
||||
*/
|
||||
ثنائية
rtmpServers/rtmpStreaming.jpeg
Normal file
ثنائية
rtmpServers/rtmpStreaming.jpeg
Normal file
ملف ثنائي غير معروض.
|
بعد العرض: | الارتفاع: | الحجم: 60 KiB |
المرجع في مشكلة جديدة
حظر مستخدم