[mirotalksfu] - #221 - Fix server side rec. duration

هذا الالتزام موجود في:
Miroslav Pejic
2025-08-18 16:51:22 +02:00
الأصل 4cc7bff696
التزام f3f39c19c0
8 ملفات معدلة مع 361 إضافات و17 حذوفات

عرض الملف

@@ -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 };

عرض الملف

@@ -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 };

عرض الملف

@@ -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);