本地音视频合并测试
This commit is contained in:
270
src/server.ts
270
src/server.ts
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user