diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..24e6e9b8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +# Recording... +app/rec \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9df74517..536c2c51 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ package-lock.json # personal config.js docker-compose.yml -docker-push.sh \ No newline at end of file +docker-push.sh +rec \ No newline at end of file diff --git a/README.md b/README.md index 94fb893a..757c82a5 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ - Choose your audio input, output, and video source. - Supports video quality up to 4K. - Supports advance Picture-in-Picture (PiP) offering a more streamlined and flexible viewing experience. -- Record your screen, audio, and video. +- Record your screen, audio, and video locally or on your Server. - Snapshot video frames and save them as PNG images. - Chat with an Emoji Picker for expressing feelings, private messages, Markdown support, and conversation saving. - ChatGPT (powered by OpenAI) for answering questions, providing information, and connecting users to relevant resources. diff --git a/app/src/Room.js b/app/src/Room.js index 2e193125..312d6cd3 100644 --- a/app/src/Room.js +++ b/app/src/Room.js @@ -19,6 +19,9 @@ module.exports = class Room { this._isLobbyEnabled = false; this._roomPassword = null; this._hostOnlyRecording = false; + // ########################## + this._recSyncServerRecording = config?.server?.recording?.enabled || false; + // ########################## this._moderator = { audio_start_muted: false, video_start_hidden: false, @@ -155,6 +158,7 @@ module.exports = class Room { return { id: this.id, broadcasting: this._isBroadcasting, + recSyncServerRecording: this._recSyncServerRecording, config: { isLocked: this._isLocked, isLobbyEnabled: this._isLobbyEnabled, diff --git a/app/src/Server.js b/app/src/Server.js index 3b2305bd..df91f95b 100644 --- a/app/src/Server.js +++ b/app/src/Server.js @@ -41,7 +41,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.3.67 + * @version 1.3.68 * */ @@ -158,8 +158,17 @@ if (config.chatGPT.enabled) { // directory const dir = { public: path.join(__dirname, '../../', 'public'), + rec: path.join(__dirname, '../', config?.server?.recording?.dir ? config.server.recording.dir + '/' : 'rec/'), }; +// rec directory create +const serverRecordingEnabled = config?.server?.recording?.enabled; +if (serverRecordingEnabled) { + if (!fs.existsSync(dir.rec)) { + fs.mkdirSync(dir.rec, { recursive: true }); + } +} + // html views const views = { about: path.join(__dirname, '../../', 'public/views/about.html'), @@ -434,6 +443,43 @@ function startServer() { } }); + // #################################################### + // KEEP RECORDING ON SERVER DIR + // #################################################### + + app.post(['/recSync'], (req, res) => { + // Store recording... + if (serverRecordingEnabled) { + if (!fs.existsSync(dir.rec)) fs.mkdirSync(dir.rec, { recursive: true }); + + const { fileName } = req.query; + + if (!fileName) { + return res.status(400).send('Filename not provided'); + } + + try { + const filePath = dir.rec + fileName; + const writeStream = fs.createWriteStream(filePath, { flags: 'a' }); + + req.pipe(writeStream); + + writeStream.on('error', (err) => { + log.error('Error writing to file:', err.message); + res.status(500).send('Internal Server Error'); + }); + + writeStream.on('finish', () => { + log.debug('File saved successfully:', fileName); + res.status(200).send('File uploaded successfully'); + }); + } catch (err) { + log.error('Error processing upload', err.message); + res.status(500).send('Internal Server Error'); + } + } + }); + // #################################################### // API // #################################################### @@ -533,11 +579,8 @@ function startServer() { await ngrok.authtoken(config.ngrok.authToken); await ngrok.connect(config.server.listen.port); const api = ngrok.getApi(); - // const data = JSON.parse(await api.get('api/tunnels')); // v3 - const data = await api.listTunnels(); // v4 - const pu0 = data.tunnels[0].public_url; - const pu1 = data.tunnels[1].public_url; - const tunnel = pu0.startsWith('https') ? pu0 : pu1; + const list = await api.listTunnels(); + const tunnel = list.tunnels[0].public_url; log.info('Listening on', { app_version: packageJson.version, node_version: process.versions.node, @@ -559,9 +602,11 @@ function startServer() { stats_enabled: config.stats.enabled, chatGPT_enabled: config.chatGPT.enabled, configUI: config.ui, + serverRec: config?.server?.recording, }); } catch (err) { log.error('Ngrok Start error: ', err.body); + await ngrok.kill(); process.exit(1); } } @@ -608,6 +653,7 @@ function startServer() { stats_enabled: config.stats.enabled, chatGPT_enabled: config.chatGPT.enabled, configUI: config.ui, + serverRec: config?.server?.recording, }); }); diff --git a/app/src/config.template.js b/app/src/config.template.js index 4160f415..12766427 100644 --- a/app/src/config.template.js +++ b/app/src/config.template.js @@ -35,6 +35,11 @@ module.exports = { cert: '../ssl/cert.pem', key: '../ssl/key.pem', }, + // The recording will be saved to the directory designated within your Server app/ + recording: { + dir: 'rec', + enabled: false, + }, }, host: { /* @@ -105,8 +110,8 @@ module.exports = { micOptionsButton: true, // presenter tabModerator: true, // presenter tabRecording: true, - pushToTalk: true, host_only_recording: true, // presenter + pushToTalk: true, }, producerVideo: { videoPictureInPicture: true, diff --git a/package.json b/package.json index 926bd8e3..efe1212c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mirotalksfu", - "version": "1.3.67", + "version": "1.3.68", "description": "WebRTC SFU browser-based video calls", "main": "Server.js", "scripts": { @@ -38,8 +38,8 @@ "author": "Miroslav Pejic", "license": "AGPL-3.0", "dependencies": { - "@sentry/integrations": "7.100.1", - "@sentry/node": "7.100.1", + "@sentry/integrations": "7.101.0", + "@sentry/node": "7.101.0", "axios": "^1.6.7", "body-parser": "1.20.2", "colors": "1.4.0", @@ -51,8 +51,8 @@ "jsonwebtoken": "^9.0.2", "mediasoup": "3.13.19", "mediasoup-client": "3.7.2", - "ngrok": "^4.3.3", - "openai": "^4.27.0", + "ngrok": "^5.0.0-beta.2", + "openai": "^4.28.0", "qs": "6.11.2", "socket.io": "4.7.4", "swagger-ui-express": "5.0.0", diff --git a/public/js/LocalStorage.js b/public/js/LocalStorage.js index 96698749..aa50edbb 100644 --- a/public/js/LocalStorage.js +++ b/public/js/LocalStorage.js @@ -43,7 +43,8 @@ class LocalStorage { pitch_bar: true, // volume indicator sounds: true, // room notify sounds host_ony_recording: false, // presenter - rec_prioritize_h264: false, // Prioritize h.264 with AAC or h.264 with Opus codecs over VP8 with Opus or VP9 with Opus codecs. + rec_prioritize_h264: false, // Prioritize h.264 with AAC or h.264 with Opus codecs over VP8 with Opus or VP9 with Opus codecs + rec_server: false, // The recording will be stored on the server rather than locally video_obj_fit: 2, // cover video_controls: 0, // off theme: 0, // dark diff --git a/public/js/Room.js b/public/js/Room.js index ca5429a0..6686c706 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.3.67 + * @version 1.3.68 * */ @@ -281,6 +281,7 @@ function initClient() { 'Prioritize h.264 with AAC or h.264 with Opus codecs over VP8 with Opus or VP9 with Opus codecs', 'right', ); + setTippy('switchServerRecording', 'The recording will be stored on the server rather than locally', 'right'); setTippy('whiteboardGhostButton', 'Toggle transparent background', 'bottom'); setTippy('wbBackgroundColorEl', 'Background color', 'bottom'); setTippy('wbDrawingColorEl', 'Drawing color', 'bottom'); @@ -1189,10 +1190,11 @@ function roomIsReady() { BUTTONS.settings.broadcastingButton && show(broadcastingButton); BUTTONS.settings.lobbyButton && show(lobbyButton); if (BUTTONS.settings.host_only_recording) { - show(roomRecording); show(recordingImage); + show(roomHostOnlyRecording); show(roomRecordingOptions); } + if (rc.recSyncServerRecording) show(roomRecordingServer); BUTTONS.main.aboutButton && show(aboutButton); if (!DetectRTC.isMobileDevice) show(pinUnpinGridDiv); if (!isSpeechSynthesisSupported) hide(speechMsgDiv); @@ -1973,6 +1975,13 @@ function handleSelects() { lS.setSettings(localStorageSettings); e.target.blur(); }; + switchServerRecording.onchange = (e) => { + rc.recSyncServerRecording = e.currentTarget.checked; + rc.roomMessage('recSyncServer', rc.recSyncServerRecording); + localStorageSettings.rec_server = rc.recSyncServerRecording; + lS.setSettings(localStorageSettings); + e.target.blur(); + }; // styling keepCustomTheme.onchange = (e) => { themeCustom.keep = e.currentTarget.checked; @@ -2277,6 +2286,8 @@ function loadSettingsFromLocalStorage() { recPrioritizeH264 = localStorageSettings.rec_prioritize_h264; switchH264Recording.checked = recPrioritizeH264; + switchServerRecording.checked = localStorageSettings.rec_server; + keepCustomTheme.checked = themeCustom.keep; selectTheme.disabled = themeCustom.keep; themeCustom.input.value = themeCustom.color; @@ -2456,9 +2467,10 @@ function handleRoomClientEvents() { rc.saveRecording('Room event: host only recording enabled, going to stop recording'); } hide(startRecButton); - hide(roomRecording); hide(recordingImage); + hide(roomHostOnlyRecording); hide(roomRecordingOptions); + hide(roomRecordingServer); show(recordingMessage); hostOnlyRecording = true; } @@ -2468,8 +2480,7 @@ function handleRoomClientEvents() { console.log('Room event: host only recording disabled'); show(startRecButton); show(recordingImage); - show(roomRecordingOptions); - hide(roomRecording); + hide(roomHostOnlyRecording); hide(recordingMessage); hostOnlyRecording = false; } @@ -2569,6 +2580,13 @@ function getDataTimeString() { return `${date}-${time}`; } +function getDataTimeStringFormat() { + const d = new Date(); + const date = d.toISOString().split('T')[0].replace(/-/g, '_'); + const time = d.toTimeString().split(' ')[0].replace(/:/g, '_'); + return `${date}_${time}`; +} + function getUUID() { const uuid4 = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16), diff --git a/public/js/RoomClient.js b/public/js/RoomClient.js index c2bfdb68..41390969 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.3.67 + * @version 1.3.68 * */ @@ -63,6 +63,7 @@ const icons = { broadcaster: '', codecs: '', theme: '', + recSync: '', }; const image = { @@ -243,9 +244,6 @@ class RoomClient { this.localVideoStream = null; this.localAudioStream = null; this.localScreenStream = null; - this.mediaRecorder = null; - this.recScreenStream = null; - this._isRecording = false; this.RoomPassword = false; @@ -264,7 +262,13 @@ class RoomClient { this.chunkSize = 1024 * 16; // 16kb/s // Recording + this._isRecording = false; + this.mediaRecorder = null; this.audioRecorder = null; + this.recScreenStream = null; + this.recSyncServerRecording = false; + this.recSyncTime = 4000; // 4 sec + this.recSyncChunkSize = 1000000; // 1MB // Encodings this.forceVP8 = false; // Force VP8 codec for webcam and screen sharing @@ -423,6 +427,7 @@ class RoomClient { this.getId('isUserPresenter').innerText = isPresenter; window.localStorage.isReconnected = false; handleRules(isPresenter); + // ################################################################################################### isBroadcastingEnabled = isPresenter && !room.broadcasting ? isBroadcastingEnabled : room.broadcasting; console.log('07.1 ----> ROOM BROADCASTING', isBroadcastingEnabled); @@ -435,6 +440,18 @@ class RoomClient { : this.event(_EVENTS.hostOnlyRecordingOff); } + // ################################################################################################### + if (room.recSyncServerRecording) { + console.log('07.1 WARNING ----> SERVER SYNC RECORDING ENABLED!'); + this.recSyncServerRecording = localStorageSettings.rec_server; + if (BUTTONS.settings.tabRecording && !room.config.hostOnlyRecording) { + show(roomRecordingServer); + } + switchServerRecording.checked = this.recSyncServerRecording; + } + console.log('07.1 ----> SERVER SYNC RECORDING', this.recSyncServerRecording); + // ################################################################################################### + // Handle Room moderator rules if (room.moderator && (!isRulesActive || !isPresenter)) { console.log('07.2 ----> MODERATOR', room.moderator); @@ -4123,89 +4140,170 @@ class RoomClient { handleMediaRecorder() { if (this.mediaRecorder) { - this.mediaRecorder.start(); + this.recServerFileName = this.getServerRecFileName(); + rc.recSyncServerRecording ? this.mediaRecorder.start(this.recSyncTime) : this.mediaRecorder.start(); this.mediaRecorder.addEventListener('start', this.handleMediaRecorderStart); this.mediaRecorder.addEventListener('dataavailable', this.handleMediaRecorderData); this.mediaRecorder.addEventListener('stop', this.handleMediaRecorderStop); } } + getServerRecFileName() { + const dateTime = getDataTimeStringFormat(); + return `Rec_${dateTime}.webm`; + } + handleMediaRecorderStart(evt) { console.log('MediaRecorder started: ', evt); + rc.cleanLastRecordingInfo(); + rc.disableRecordingOptions(); } handleMediaRecorderData(evt) { - console.log('MediaRecorder data: ', evt); - if (evt.data && evt.data.size > 0) recordedBlobs.push(evt.data); + // console.log('MediaRecorder data: ', evt); + if (evt.data && evt.data.size > 0) { + rc.recSyncServerRecording ? rc.syncRecordingInCloud(evt.data) : recordedBlobs.push(evt.data); + } + } + + async syncRecordingInCloud(data) { + const arrayBuffer = data; + const chunkSize = rc.recSyncChunkSize; + const fileReader = new FileReader(); + fileReader.readAsArrayBuffer(arrayBuffer); + fileReader.onload = async (event) => { + const arrayBuffer = event.target.result; + 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('/recSync?fileName=' + rc.recServerFileName, chunk, { + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': chunk.length, + }, + }); + } catch (error) { + console.error('Error syncing chunk:', error.message); + } + } + }; } handleMediaRecorderStop(evt) { try { console.log('MediaRecorder stopped: ', evt); - console.log('MediaRecorder Blobs: ', recordedBlobs); - - const dateTime = getDataTimeString(); - const type = recordedBlobs[0].type.includes('mp4') ? 'mp4' : 'webm'; - const blob = new Blob(recordedBlobs, { type: 'video/' + type }); - const recFileName = `${dateTime}-REC.${type}`; - const currentDevice = DetectRTC.isMobileDevice ? 'MOBILE' : 'PC'; - const blobFileSize = bytesToSize(blob.size); - const recTime = document.getElementById('recordingStatus'); - - const recordingInfo = ` -

- -
- `; - - const lastRecordingInfo = document.getElementById('lastRecordingInfo'); - lastRecordingInfo.style.color = '#FFFFFF'; - lastRecordingInfo.innerHTML = `Last Recording Info: ${recordingInfo}`; - show(lastRecordingInfo); - - if (window.localStorage.isReconnected === 'false') { - Swal.fire({ - background: swalBackground, - position: 'center', - icon: 'success', - title: 'Recording', - html: `
- 🔴 Recording Info: - ${recordingInfo} - Please wait to be processed, then will be downloaded to your ${currentDevice} device. -
`, - showClass: { popup: 'animate__animated animate__fadeInDown' }, - hideClass: { popup: 'animate__animated animate__fadeOutUp' }, - }); - } - - console.log('MediaRecorder Download Blobs'); - const url = window.URL.createObjectURL(blob); - - const downloadLink = document.createElement('a'); - downloadLink.style.display = 'none'; - downloadLink.href = url; - downloadLink.download = recFileName; - document.body.appendChild(downloadLink); - downloadLink.click(); - - setTimeout(() => { - document.body.removeChild(downloadLink); - window.URL.revokeObjectURL(url); - console.log(`🔴 Recording FILE: ${recFileName} done 👍`); - recordedBlobs = []; - recTime.innerText = '0s'; - }, 100); + rc.recSyncServerRecording ? rc.handleServerRecordingStop() : rc.handleLocalRecordingStop(); + rc.disableRecordingOptions(false); } catch (err) { console.error('Recording save failed', err); } } + disableRecordingOptions(disabled = true) { + switchH264Recording.disabled = disabled; + switchServerRecording.disabled = disabled; + switchHostOnlyRecording.disabled = disabled; + } + + handleLocalRecordingStop() { + console.log('MediaRecorder Blobs: ', recordedBlobs); + + const dateTime = getDataTimeString(); + const type = recordedBlobs[0].type.includes('mp4') ? 'mp4' : 'webm'; + const blob = new Blob(recordedBlobs, { type: 'video/' + type }); + const recFileName = `Rec_${dateTime}.${type}`; + const currentDevice = DetectRTC.isMobileDevice ? 'MOBILE' : 'PC'; + const blobFileSize = bytesToSize(blob.size); + const recTime = document.getElementById('recordingStatus'); + const recType = 'Locally'; + const recordingInfo = ` +

+ +
+ `; + const recordingMsg = `Please wait to be processed, then will be downloaded to your ${currentDevice} device.`; + + this.saveLastRecordingInfo(recordingInfo); + this.showRecordingInfo(recType, recordingInfo, recordingMsg); + this.saveRecordingInLocalDevice(blob, recFileName, recTime); + } + + handleServerRecordingStop() { + console.log('MediaRecorder Stop'); + const recTime = document.getElementById('recordingStatus'); + const recType = 'Server'; + const recordingInfo = ` +

+ +
+ `; + this.saveLastRecordingInfo(recordingInfo); + this.showRecordingInfo(recType, recordingInfo); + } + + saveLastRecordingInfo(recordingInfo) { + const lastRecordingInfo = document.getElementById('lastRecordingInfo'); + lastRecordingInfo.style.color = '#FFFFFF'; + lastRecordingInfo.innerHTML = `Last Recording Info: ${recordingInfo}`; + show(lastRecordingInfo); + } + + cleanLastRecordingInfo() { + const lastRecordingInfo = document.getElementById('lastRecordingInfo'); + lastRecordingInfo.innerHTML = ''; + hide(lastRecordingInfo); + } + + showRecordingInfo(recType, recordingInfo, recordingMsg = '') { + if (window.localStorage.isReconnected === 'false') { + Swal.fire({ + background: swalBackground, + position: 'center', + icon: 'success', + title: 'Recording', + html: `
+ 🔴 ${recType} Recording Info: + ${recordingInfo} + ${recordingMsg} +
`, + showClass: { popup: 'animate__animated animate__fadeInDown' }, + hideClass: { popup: 'animate__animated animate__fadeOutUp' }, + }); + } + } + + saveRecordingInLocalDevice(blob, recFileName, recTime) { + console.log('MediaRecorder Download Blobs'); + const url = window.URL.createObjectURL(blob); + + const downloadLink = document.createElement('a'); + downloadLink.style.display = 'none'; + downloadLink.href = url; + downloadLink.download = recFileName; + document.body.appendChild(downloadLink); + downloadLink.click(); + + setTimeout(() => { + document.body.removeChild(downloadLink); + window.URL.revokeObjectURL(url); + console.log(`🔴 Recording FILE: ${recFileName} done 👍`); + recordedBlobs = []; + recTime.innerText = '0s'; + }, 100); + } + pauseRecording() { if (this.mediaRecorder) { this._isRecording = false; @@ -5038,6 +5136,9 @@ class RoomClient { case 'recPrioritizeH264': this.userLog('info', `${icons.codecs} Recording prioritize h.264 ${status}`, 'top-end'); break; + case 'recSyncServer': + this.userLog('info', `${icons.recSync} Server Sync Recording ${status}`, 'top-end'); + break; case 'customThemeKeep': this.userLog('info', `${icons.theme} Custom theme keep ${status}`, 'top-end'); break; diff --git a/public/js/Rules.js b/public/js/Rules.js index 79c73df9..dd241151 100644 --- a/public/js/Rules.js +++ b/public/js/Rules.js @@ -39,8 +39,8 @@ let BUTTONS = { micOptionsButton: true, // presenter tabModerator: true, // presenter tabRecording: true, - pushToTalk: true, host_only_recording: true, // presenter + pushToTalk: true, }, producerVideo: { videoPictureInPicture: true, diff --git a/public/views/Room.html b/public/views/Room.html index 25513e5d..68e81458 100644 --- a/public/views/Room.html +++ b/public/views/Room.html @@ -772,7 +772,8 @@ access to use this app. -