[mirotalksfu] - #221 - Fix server side rec. duration
هذا الالتزام موجود في:
64
app/src/FixDurationOrRemux.js
Normal file
64
app/src/FixDurationOrRemux.js
Normal file
@@ -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 };
|
||||
192
app/src/FixWebmDurationBuffer.js
Normal file
192
app/src/FixWebmDurationBuffer.js
Normal file
@@ -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);
|
||||
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم