本地音视频合并测试

This commit is contained in:
2026-06-02 21:42:03 +08:00
parent d74a0c8121
commit 83cf098c5f
7 changed files with 1881 additions and 20 deletions

View File

@@ -2,12 +2,14 @@ import * as express from 'express';
import * as path from 'path';
import * as fs from 'fs';
import * as morgan from 'morgan';
import { spawn } from 'child_process';
import { v4 as uuid } from 'uuid';
import signaling from './signaling';
import { log, LogLevel } from './log';
import Options from './class/options';
import { reset as resetHandler } from './class/httphandler';
import { initSwagger } from './swagger';
import { ServerAudioRecorderManager } from './class/serveraudiorecorder';
const cors = require('cors');
const multer = require('multer');
@@ -139,7 +141,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
return '';
}
return String(value).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, maxLength);
return String(value)
.split('')
.filter((char) => {
const code = char.charCodeAt(0);
return code > 31 && code !== 127;
})
.join('')
.trim()
.slice(0, maxLength);
}
function sanitizeRecordingPerson(value: any, fallbackRole: string): RecordingPerson | undefined {
@@ -285,6 +295,75 @@ function removeEmptyDirectory(directory: string): void {
}
}
function removeFileIfExists(filePath: string | undefined): void {
if (!filePath) {
return;
}
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch (error) {
log(LogLevel.warn, 'Failed to remove temporary file:', error);
}
}
function getMergedRecordingExtension(videoExt: string): string {
return videoExt.toLowerCase() === '.webm' ? '.webm' : '.mp4';
}
function runFfmpeg(args: string[]): Promise<void> {
return new Promise((resolve, reject) => {
const ffmpegPath = process.env.FFMPEG_PATH || 'ffmpeg';
const child = spawn(ffmpegPath, args, { windowsHide: true });
let stderr = '';
child.stderr.on('data', (chunk) => {
stderr += chunk.toString();
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`ffmpeg exited with code ${code}: ${stderr.slice(-2000)}`));
});
});
}
function mergeVideoWithServerAudio(videoPath: string, audioPath: string, outputPath: string, outputExt: string): Promise<void> {
const isWebmOutput = outputExt.toLowerCase() === '.webm';
const args = isWebmOutput
? [
'-y',
'-i', videoPath,
'-i', audioPath,
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'copy',
'-c:a', 'libopus',
'-shortest',
outputPath
]
: [
'-y',
'-i', videoPath,
'-i', audioPath,
'-map', '0:v:0',
'-map', '1:a:0',
'-c:v', 'copy',
'-c:a', 'aac',
'-shortest',
'-movflags', '+faststart',
outputPath
];
return runFfmpeg(args);
}
export const createServer = (config: Options): express.Express => {
const app: express.Express = express();
resetHandler(config.mode);
@@ -419,6 +498,7 @@ export const createServer = (config: Options): express.Express => {
const recordingRoot = getRecordingRoot();
const recordingTempDir = path.join(recordingRoot, '.tmp');
const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir);
const recordingStorage = multer.diskStorage({
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
if (!fs.existsSync(recordingTempDir)) {
@@ -452,6 +532,194 @@ export const createServer = (config: Options): express.Express => {
}
});
app.post('/api/server-audio-recordings/start', async (req: express.Request, res: express.Response) => {
try {
const offerSdp = req.body.offerSdp || req.body.sdp;
const meetingId = sanitizePathSegment(req.body.meetingId, 'unknown');
const started = await serverAudioRecordings.start({
meetingId,
offerSdp,
iceServers: Array.isArray(req.body.iceServers) ? req.body.iceServers : undefined
});
res.json({
success: true,
recordingId: started.recordingId,
meetingId: started.meetingId,
answerSdp: started.answerSdp,
candidates: started.candidates
});
} catch (error) {
log(LogLevel.error, 'Failed to start server audio recording:', error);
res.status(400).json({
success: false,
message: error instanceof Error ? error.message : 'Failed to start server audio recording'
});
}
});
app.post('/api/server-audio-recordings/:recordingId/candidate', async (req: express.Request, res: express.Response) => {
try {
const recordingId = sanitizePathSegment(req.params.recordingId, '');
const candidate = req.body.candidate && typeof req.body.candidate === 'object'
? req.body.candidate
: {
candidate: req.body.candidate,
sdpMid: req.body.sdpMid,
sdpMLineIndex: req.body.sdpMLineIndex
};
const added = await serverAudioRecordings.addCandidate(recordingId, candidate);
if (!added) {
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
res.json({ success: true });
} catch (error) {
log(LogLevel.error, 'Failed to add server audio ICE candidate:', error);
res.status(400).json({
success: false,
message: error instanceof Error ? error.message : 'Failed to add server audio ICE candidate'
});
}
});
app.delete('/api/server-audio-recordings/:recordingId', async (req: express.Request, res: express.Response) => {
const recordingId = sanitizePathSegment(req.params.recordingId, '');
const cancelled = await serverAudioRecordings.cancel(recordingId);
if (!cancelled) {
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
res.json({ success: true });
});
app.post('/api/server-audio-recordings/:recordingId/stop', (req: express.Request, res: express.Response) => {
const recordingId = sanitizePathSegment(req.params.recordingId, '');
const stopPromise = serverAudioRecordings.stop(recordingId);
stopPromise.catch(() => undefined);
recordingUpload.single('video')(req, res, async (error: Error) => {
const request = req as any;
let stopped = null;
try {
stopped = await stopPromise;
if (!stopped) {
removeFileIfExists(request.file && request.file.path);
res.status(404).json({ success: false, message: 'Server audio recording not found' });
return;
}
if (error) {
removeFileIfExists(stopped.audioPath);
log(LogLevel.warn, 'Server audio merge upload rejected:', error.message);
const isSizeLimit = error.name === 'MulterError' && (error as any).code === 'LIMIT_FILE_SIZE';
res.status(400).json({
success: false,
message: isSizeLimit ? 'Recording file is too large' : error.message
});
return;
}
if (!stopped.hasAudio) {
removeFileIfExists(request.file && request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'No server audio was captured' });
return;
}
if (!request.file) {
res.json({
success: true,
recordingId: stopped.recordingId,
meetingId: stopped.meetingId,
audioOnly: true,
audioTrackCount: stopped.audioTrackCount
});
return;
}
const videoExt = safeRecordingExtension(request.file);
if (!videoExt) {
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'Unsupported recording file type' });
return;
}
const finalExt = getMergedRecordingExtension(videoExt);
const meetingId = sanitizePathSegment(request.body.meetingId || stopped.meetingId, 'unknown');
const originalFilename = path.basename(request.body.filename || request.file.originalname || `recording${finalExt}`);
const userId = sanitizeMetadataString(request.body.userId, 120);
const host = sanitizeRecordingPerson(request.body.host, 'host') || buildFallbackRecordingHost(userId);
const participants = sanitizeRecordingParticipants(request.body.participants);
const finalFilename = `${new Date().toISOString().replace(/[:.]/g, '-')}-${stopped.recordingId}${finalExt}`;
const meetingDir = path.join(recordingRoot, meetingId);
const finalPath = path.join(meetingDir, finalFilename);
if (!isPathInside(recordingRoot, finalPath)) {
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.status(400).json({ success: false, message: 'Invalid recording path' });
return;
}
if (!fs.existsSync(meetingDir)) {
fs.mkdirSync(meetingDir, { recursive: true });
}
await mergeVideoWithServerAudio(request.file.path, stopped.audioPath, finalPath, finalExt);
const stat = fs.statSync(finalPath);
const metadata = {
id: stopped.recordingId,
meetingId,
filename: finalFilename,
originalFilename,
mimetype: getRecordingMimeTypeFromExtension(finalExt),
size: stat.size,
userId,
host,
participants,
serverAudio: {
audioTrackCount: stopped.audioTrackCount,
startedAt: new Date(stopped.createdAt).toISOString(),
stoppedAt: new Date(stopped.stoppedAt).toISOString()
},
uploadedAt: new Date().toISOString()
};
fs.writeFileSync(path.join(meetingDir, `${finalFilename}.json`), JSON.stringify(metadata, null, 2));
removeFileIfExists(request.file.path);
removeFileIfExists(stopped.audioPath);
res.json({
success: true,
recordingId: stopped.recordingId,
meetingId,
filename: finalFilename,
originalFilename,
size: stat.size,
merged: true,
url: `/api/recordings/${encodeURIComponent(meetingId)}/${encodeURIComponent(finalFilename)}/download`
});
} catch (mergeError) {
removeFileIfExists(request.file && request.file.path);
if (stopped) {
removeFileIfExists(stopped.audioPath);
}
log(LogLevel.error, 'Failed to stop server audio recording:', mergeError);
res.status(500).json({
success: false,
message: mergeError instanceof Error ? mergeError.message : 'Failed to stop server audio recording'
});
}
});
});
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
try {
const recordings = listRecordings(recordingRoot);