From f3f39c19c03002f06ffd285c63346eb1567701e2 Mon Sep 17 00:00:00 2001 From: Miroslav Pejic Date: Mon, 18 Aug 2025 16:51:22 +0200 Subject: [PATCH] [mirotalksfu] - #221 - Fix server side rec. duration --- app/src/FixDurationOrRemux.js | 64 +++++++++++ app/src/FixWebmDurationBuffer.js | 192 +++++++++++++++++++++++++++++++ app/src/Server.js | 53 ++++++++- package-lock.json | 12 +- package.json | 4 +- public/js/Brand.js | 2 +- public/js/Room.js | 4 +- public/js/RoomClient.js | 47 +++++++- 8 files changed, 361 insertions(+), 17 deletions(-) create mode 100644 app/src/FixDurationOrRemux.js create mode 100644 app/src/FixWebmDurationBuffer.js diff --git a/app/src/FixDurationOrRemux.js b/app/src/FixDurationOrRemux.js new file mode 100644 index 00000000..612505ea --- /dev/null +++ b/app/src/FixDurationOrRemux.js @@ -0,0 +1,64 @@ +'use strict'; + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const { fixWebmDurationBuffer } = require('./FixWebmDurationBuffer'); + +const Logger = require('./Logger'); +const log = new Logger('DurationOrRemux'); + +function hasFfmpeg() { + const r = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' }); + return r.status === 0; +} + +function remuxWithFfmpeg(inputPath, format = 'webm') { + const dir = path.dirname(inputPath); + const base = path.basename(inputPath, path.extname(inputPath)); + const out = path.join(dir, `${base}.fixed.${format}`); + + const args = [ + '-hide_banner', + '-loglevel', + 'error', + '-y', + '-i', + inputPath, + '-c', + 'copy', + ...(format === 'mp4' ? ['-movflags', '+faststart'] : []), + out, + ]; + const r = spawnSync('ffmpeg', args); + if (r.status !== 0 || !fs.existsSync(out)) return null; + + fs.renameSync(out, inputPath); + return inputPath; +} + +function fixDurationOrRemux(inputPath, durationMs) { + const ext = path.extname(inputPath).toLowerCase(); + const isWebm = ext === '.webm'; + const isMp4 = ext === '.mp4'; + + if (hasFfmpeg() && (isWebm || isMp4)) { + const ok = remuxWithFfmpeg(inputPath, isMp4 ? 'mp4' : 'webm'); + log.info('ffmpeg detected remuxWithFfmpeg:', ok); + if (ok) return true; + } + + if (isWebm && Number.isFinite(durationMs)) { + const inBuf = fs.readFileSync(inputPath); + const outBuf = fixWebmDurationBuffer(inBuf, Number(durationMs)); + if (outBuf && outBuf.length) { + fs.writeFileSync(inputPath, outBuf); + log.info('No ffmpeg detected fixWebmDurationBuffer - true'); + return true; + } + } + log.info('No ffmpeg detected fixWebmDurationBuffer - false'); + return false; +} + +module.exports = { fixDurationOrRemux }; diff --git a/app/src/FixWebmDurationBuffer.js b/app/src/FixWebmDurationBuffer.js new file mode 100644 index 00000000..a806ef15 --- /dev/null +++ b/app/src/FixWebmDurationBuffer.js @@ -0,0 +1,192 @@ +'use strict'; + +// Minimal Node helper: fixWebmDurationBuffer(buffer: Buffer, durationMs: number) -> Buffer +function padHex(h) { + return h.length % 2 === 1 ? '0' + h : h; +} + +class WebmBase { + constructor(name, type) { + this.name = name || 'Unknown'; + this.type = type || 'Unknown'; + } + updateBySource() {} + setSource(s) { + this.source = s; + this.updateBySource(); + } + updateByData() {} + setData(d) { + this.data = d; + this.updateByData(); + } +} +class WebmUint extends WebmBase { + constructor() { + super('Uint', 'Uint'); + } + updateBySource() { + this.data = ''; + for (let i = 0; i < this.source.length; i++) this.data += padHex(this.source[i].toString(16)); + } + updateByData() { + const len = this.data.length / 2; + this.source = new Uint8Array(len); + for (let i = 0; i < len; i++) this.source[i] = parseInt(this.data.substr(i * 2, 2), 16); + } + getValue() { + return parseInt(this.data, 16); + } + setValue(v) { + this.setData(padHex(v.toString(16))); + } +} +class WebmFloat extends WebmBase { + constructor() { + super('Float', 'Float'); + } + _arrType() { + return this.source && this.source.length === 4 ? Float32Array : Float64Array; + } + updateBySource() { + const bytes = this.source.slice().reverse(); + const T = this._arrType(); + this.data = new T(bytes.buffer)[0]; + } + updateByData() { + const T = this._arrType(); + const fa = new T([this.data]); + const bytes = new Uint8Array(fa.buffer); + this.source = bytes.reverse(); + } + getValue() { + return this.data; + } + setValue(v) { + this.setData(v); + } +} +class WebmContainer extends WebmBase { + constructor(name) { + super(name || 'Container', 'Container'); + } + readByte() { + return this.source[this.offset++]; + } + readVint() { + const b0 = this.readByte(); + const bytes = 8 - b0.toString(2).length; + let v = b0 - (1 << (7 - bytes)); + for (let i = 0; i < bytes; i++) { + v = v * 256 + this.readByte(); + } + return v; + } + updateBySource() { + this.data = []; + for (this.offset = 0; this.offset < this.source.length; ) { + const id = this.readVint(); + const len = this.readVint(); + const end = Math.min(this.offset + len, this.source.length); + const bytes = this.source.slice(this.offset, end); + let ctor = WebmBase; + if (id === ID.Segment || id === ID.Info) ctor = WebmContainer; + else if (id === ID.TimecodeScale) ctor = WebmUint; + else if (id === ID.Duration) ctor = WebmFloat; + const elem = new ctor(); + elem.setSource(bytes); + this.data.push({ id, data: elem }); + this.offset = end; + } + } + writeVint(x, draft) { + let bytes = 1, + flag = 0x80; + while (x >= flag && bytes < 8) { + bytes++; + flag *= 0x80; + } + if (!draft) { + let val = flag + x; + for (let i = bytes - 1; i >= 0; i--) { + const c = val % 256; + this.source[this.offset + i] = c; + val = (val - c) / 256; + } + } + this.offset += bytes; + } + writeSections(draft) { + this.offset = 0; + for (const s of this.data) { + const content = s.data.source; + const len = content.length; + this.writeVint(s.id, draft); + this.writeVint(len, draft); + if (!draft) this.source.set(content, this.offset); + this.offset += len; + } + return this.offset; + } + updateByData() { + const len = this.writeSections(true); + this.source = new Uint8Array(len); + this.writeSections(false); + } + getSectionById(id) { + for (const s of this.data) { + if (s.id === id) return s.data; + } + return null; + } +} +class WebmFile extends WebmContainer { + constructor(src) { + super('File'); + this.setSource(src); + } + toBuffer() { + return Buffer.from(this.source.buffer); + } + fixDuration(durationMs) { + const segment = this.getSectionById(ID.Segment); + if (!segment) return false; + const info = segment.getSectionById(ID.Info); + if (!info) return false; + let scale = info.getSectionById(ID.TimecodeScale); + if (!scale) return false; + scale.setValue(1000000); // 1ms + let dur = info.getSectionById(ID.Duration); + if (dur) { + if (dur.getValue() > 0) return false; + dur.setValue(durationMs); + } else { + dur = new WebmFloat(); + dur.setValue(durationMs); + info.data.push({ id: ID.Duration, data: dur }); + } + info.updateByData(); + segment.updateByData(); + this.updateByData(); + return true; + } +} +const ID = { + Segment: 0x8538067, // 0x18538067 + Info: 0x549a966, // 0x1549A966 + TimecodeScale: 0xad7b1, // 0x2AD7B1 + Duration: 0x489, // 0x4489 +}; + +function fixWebmDurationBuffer(inputBuffer, durationMs) { + if (!Buffer.isBuffer(inputBuffer) || !Number.isFinite(durationMs)) return inputBuffer; + try { + const file = new WebmFile(new Uint8Array(inputBuffer)); + const fixed = file.fixDuration(Math.max(0, Math.round(durationMs))); + return fixed ? file.toBuffer() : inputBuffer; + } catch { + return inputBuffer; + } +} + +module.exports = { fixWebmDurationBuffer }; diff --git a/app/src/Server.js b/app/src/Server.js index 1554dda4..fc858fe5 100644 --- a/app/src/Server.js +++ b/app/src/Server.js @@ -64,7 +64,7 @@ dev 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.9.41 + * @version 1.9.42 * */ @@ -74,6 +74,7 @@ const { withFileLock } = require('./MutexManager'); const { PassThrough } = require('stream'); const { S3Client } = require('@aws-sdk/client-s3'); const { Upload } = require('@aws-sdk/lib-storage'); +const { fixDurationOrRemux } = require('./FixDurationOrRemux'); const cors = require('cors'); const compression = require('compression'); const socketIo = require('socket.io'); @@ -900,7 +901,7 @@ function startServer() { // RECORDING UTILITY // #################################################### - function isValidRequest(req, fileName, roomId, checkContentType = true) { + function isValidRequest(req, fileName, roomId, durationMs = false, checkContentType = true) { const contentType = req.headers['content-type']; if (checkContentType && contentType !== 'application/octet-stream') { throw new Error('Invalid content type'); @@ -913,6 +914,14 @@ function startServer() { if (!roomList || typeof roomList.has !== 'function' || !roomList.has(roomId)) { throw new Error('Invalid room ID'); } + + if ( + durationMs && + typeof durationMs !== 'undefined' && + (!Number.isFinite(Number(durationMs)) || Number(durationMs) <= 0) + ) { + throw new Error('Invalid durationMs'); + } } function getRoomIdFromFilename(fileName) { @@ -1044,6 +1053,34 @@ function startServer() { } }); + app.post('/recSyncFixWebm', async (req, res) => { + try { + const { fileName, durationMs } = checkXSS(req.query); + const roomId = getRoomIdFromFilename(fileName); + + isValidRequest(req, fileName, roomId, durationMs, false); + + const filePath = path.resolve(dir.rec, fileName); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ message: 'File not found' }); + } + + if (durationMs && fs.existsSync(filePath)) { + try { + fixDurationOrRemux(filePath, Number(durationMs)); + } catch (e) { + console.warn('Finalize fix skipped:', e?.message || e); + } + } + + return res.status(200).json({ message: 'OK' }); + } catch (err) { + console.error('recSyncFixWebm error', err); + return res.status(500).json({ message: 'Internal error' }); + } + }); + app.post('/recSyncFinalize', async (req, res) => { try { const shouldUploadToS3 = config?.integrations?.aws?.enabled && config?.media?.recording?.uploadToS3; @@ -1052,10 +1089,10 @@ function startServer() { } const start = Date.now(); - const { fileName } = checkXSS(req.query); + const { fileName, durationMs } = checkXSS(req.query); const roomId = getRoomIdFromFilename(fileName); - isValidRequest(req, fileName, roomId, false); + isValidRequest(req, fileName, roomId, durationMs, false); const filePath = path.resolve(dir.rec, fileName); @@ -1063,6 +1100,14 @@ function startServer() { return res.status(500).json({ error: 'Rec Finalization failed file not exists' }); } + if (durationMs && fs.existsSync(filePath)) { + try { + fixDurationOrRemux(filePath, Number(durationMs)); + } catch (e) { + log.warn('Finalize fix skipped:', e?.message || e); + } + } + const bucket = config?.integrations?.aws?.bucket; const s3 = await uploadToS3(filePath, fileName, roomId, bucket, s3Client); diff --git a/package-lock.json b/package-lock.json index ac0b690c..15ba04bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mirotalksfu", - "version": "1.9.40", + "version": "1.9.42", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mirotalksfu", - "version": "1.9.40", + "version": "1.9.42", "license": "AGPL-3.0", "dependencies": { "@aws-sdk/client-s3": "^3.864.0", @@ -54,7 +54,7 @@ "proxyquire": "^2.1.3", "should": "^13.2.3", "sinon": "^21.0.0", - "webpack": "^5.101.2", + "webpack": "^5.101.3", "webpack-cli": "^6.0.1" }, "engines": { @@ -10525,9 +10525,9 @@ } }, "node_modules/webpack": { - "version": "5.101.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.2.tgz", - "integrity": "sha512-4JLXU0tD6OZNVqlwzm3HGEhAHufSiyv+skb7q0d2367VDMzrU1Q/ZeepvkcHH0rZie6uqEtTQQe0OEOOluH3Mg==", + "version": "5.101.3", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", + "integrity": "sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index c98726cc..96218d39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mirotalksfu", - "version": "1.9.41", + "version": "1.9.42", "description": "WebRTC SFU browser-based video calls", "main": "Server.js", "scripts": { @@ -102,7 +102,7 @@ "proxyquire": "^2.1.3", "should": "^13.2.3", "sinon": "^21.0.0", - "webpack": "^5.101.2", + "webpack": "^5.101.3", "webpack-cli": "^6.0.1" } } diff --git a/public/js/Brand.js b/public/js/Brand.js index 8e9861da..cba65f4d 100644 --- a/public/js/Brand.js +++ b/public/js/Brand.js @@ -76,7 +76,7 @@ let BRAND = { }, about: { imageUrl: '../images/mirotalk-logo.gif', - title: 'WebRTC SFU v1.9.41', + title: 'WebRTC SFU v1.9.42', html: `