本地音视频合并测试
This commit is contained in:
1335
package-lock.json
generated
1335
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@
|
|||||||
"swagger-jsdoc": "^6.2.1",
|
"swagger-jsdoc": "^6.2.1",
|
||||||
"swagger-ui-express": "^4.5.0",
|
"swagger-ui-express": "^4.5.0",
|
||||||
"uuid": "^9.0.0",
|
"uuid": "^9.0.0",
|
||||||
|
"werift": "^0.23.0",
|
||||||
"ws": "^8.8.1"
|
"ws": "^8.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
197
src/class/serveraudiorecorder.ts
Normal file
197
src/class/serveraudiorecorder.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { v4 as uuid } from 'uuid';
|
||||||
|
import { RTCPeerConnection } from 'werift';
|
||||||
|
import { MediaRecorder } from 'werift/nonstandard';
|
||||||
|
import { log, LogLevel } from '../log';
|
||||||
|
|
||||||
|
type ServerAudioRecordingSession = {
|
||||||
|
recordingId: string;
|
||||||
|
meetingId: string;
|
||||||
|
peerConnection: RTCPeerConnection;
|
||||||
|
audioPath: string;
|
||||||
|
createdAt: number;
|
||||||
|
recorder?: MediaRecorder;
|
||||||
|
audioTrackCount: number;
|
||||||
|
localCandidates: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StartServerAudioRecordingOptions = {
|
||||||
|
meetingId?: string;
|
||||||
|
offerSdp: string;
|
||||||
|
iceServers?: any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StartServerAudioRecordingResult = {
|
||||||
|
recordingId: string;
|
||||||
|
meetingId: string;
|
||||||
|
answerSdp: string;
|
||||||
|
candidates: any[];
|
||||||
|
audioPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoppedServerAudioRecording = {
|
||||||
|
recordingId: string;
|
||||||
|
meetingId: string;
|
||||||
|
audioPath: string;
|
||||||
|
hasAudio: boolean;
|
||||||
|
audioTrackCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
stoppedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function waitForIceGatheringComplete(peerConnection: RTCPeerConnection, timeoutMs: number): Promise<void> {
|
||||||
|
if (peerConnection.iceGatheringState === 'complete') {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const subscription = peerConnection.iceGatheringStateChange.subscribe((state) => {
|
||||||
|
if (state === 'complete') {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const timer = setTimeout(finish, timeoutMs);
|
||||||
|
|
||||||
|
function finish(): void {
|
||||||
|
if (done) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
subscription.unSubscribe();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJsonCandidate(candidate: any): any {
|
||||||
|
if (!candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof candidate.toJSON === 'function' ? candidate.toJSON() : candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServerAudioRecorderManager {
|
||||||
|
private sessions: Map<string, ServerAudioRecordingSession> = new Map<string, ServerAudioRecordingSession>();
|
||||||
|
|
||||||
|
constructor(private tempDir: string) {}
|
||||||
|
|
||||||
|
async start(options: StartServerAudioRecordingOptions): Promise<StartServerAudioRecordingResult> {
|
||||||
|
if (!options.offerSdp || typeof options.offerSdp !== 'string') {
|
||||||
|
throw new Error('offerSdp is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(this.tempDir)) {
|
||||||
|
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordingId = uuid();
|
||||||
|
const meetingId = options.meetingId || 'unknown';
|
||||||
|
const audioPath = path.join(this.tempDir, `${recordingId}.server-audio.webm`);
|
||||||
|
const peerConnection = new RTCPeerConnection({
|
||||||
|
iceServers: Array.isArray(options.iceServers) ? options.iceServers : []
|
||||||
|
});
|
||||||
|
const session: ServerAudioRecordingSession = {
|
||||||
|
recordingId,
|
||||||
|
meetingId,
|
||||||
|
peerConnection,
|
||||||
|
audioPath,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
audioTrackCount: 0,
|
||||||
|
localCandidates: []
|
||||||
|
};
|
||||||
|
|
||||||
|
peerConnection.onIceCandidate.subscribe((candidate) => {
|
||||||
|
if (candidate) {
|
||||||
|
session.localCandidates.push(toJsonCandidate(candidate));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
peerConnection.onTrack.subscribe((track) => {
|
||||||
|
if (track.kind !== 'audio') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.audioTrackCount += 1;
|
||||||
|
if (session.recorder) {
|
||||||
|
log(LogLevel.warn, `Ignoring extra server audio track for recording ${recordingId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session.recorder = new MediaRecorder({
|
||||||
|
path: audioPath,
|
||||||
|
tracks: [track]
|
||||||
|
});
|
||||||
|
session.recorder.onError.subscribe((error) => {
|
||||||
|
log(LogLevel.error, `Server audio recorder error for ${recordingId}:`, error);
|
||||||
|
});
|
||||||
|
log(LogLevel.log, `Server audio track received for recording ${recordingId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
await peerConnection.setRemoteDescription({ type: 'offer', sdp: options.offerSdp });
|
||||||
|
const answer = await peerConnection.createAnswer();
|
||||||
|
await peerConnection.setLocalDescription(answer);
|
||||||
|
await waitForIceGatheringComplete(peerConnection, 3000);
|
||||||
|
|
||||||
|
this.sessions.set(recordingId, session);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recordingId,
|
||||||
|
meetingId,
|
||||||
|
answerSdp: (peerConnection.localDescription && peerConnection.localDescription.sdp) || answer.sdp,
|
||||||
|
candidates: session.localCandidates,
|
||||||
|
audioPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCandidate(recordingId: string, candidate: any): Promise<boolean> {
|
||||||
|
const session = this.sessions.get(recordingId);
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.peerConnection.addIceCandidate(candidate || {});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(recordingId: string): Promise<StoppedServerAudioRecording | null> {
|
||||||
|
const session = this.sessions.get(recordingId);
|
||||||
|
if (!session) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.delete(recordingId);
|
||||||
|
|
||||||
|
if (session.recorder) {
|
||||||
|
await session.recorder.stop();
|
||||||
|
}
|
||||||
|
await session.peerConnection.close();
|
||||||
|
|
||||||
|
const hasAudio = fs.existsSync(session.audioPath) && fs.statSync(session.audioPath).size > 0;
|
||||||
|
return {
|
||||||
|
recordingId: session.recordingId,
|
||||||
|
meetingId: session.meetingId,
|
||||||
|
audioPath: session.audioPath,
|
||||||
|
hasAudio,
|
||||||
|
audioTrackCount: session.audioTrackCount,
|
||||||
|
createdAt: session.createdAt,
|
||||||
|
stoppedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(recordingId: string): Promise<boolean> {
|
||||||
|
const stopped = await this.stop(recordingId);
|
||||||
|
if (!stopped) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fs.existsSync(stopped.audioPath)) {
|
||||||
|
fs.unlinkSync(stopped.audioPath);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
82
src/server-audio-recording-api.md
Normal file
82
src/server-audio-recording-api.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Server Audio Recording API
|
||||||
|
|
||||||
|
This API lets Unity keep local video recording while the server records app audio as an extra WebRTC peer. When Unity stops local recording, upload the local video to the stop endpoint and the server merges it with the recorded audio.
|
||||||
|
|
||||||
|
## 1. Start
|
||||||
|
|
||||||
|
`POST /api/server-audio-recordings/start`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meetingId": "room-001",
|
||||||
|
"offerSdp": "v=0...",
|
||||||
|
"iceServers": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"recordingId": "uuid",
|
||||||
|
"meetingId": "room-001",
|
||||||
|
"answerSdp": "v=0...",
|
||||||
|
"candidates": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unity should create a peer connection with an audio track only, send its offer SDP here, then set the returned answer SDP as the remote description.
|
||||||
|
|
||||||
|
## 2. Trickle ICE
|
||||||
|
|
||||||
|
`POST /api/server-audio-recordings/{recordingId}/candidate`
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"candidate": "candidate:...",
|
||||||
|
"sdpMid": "0",
|
||||||
|
"sdpMLineIndex": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Stop And Merge
|
||||||
|
|
||||||
|
`POST /api/server-audio-recordings/{recordingId}/stop`
|
||||||
|
|
||||||
|
Content type: `multipart/form-data`
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `video`: the local video file recorded by Unity.
|
||||||
|
- `meetingId`: optional, overrides the start meeting id.
|
||||||
|
- `filename`: optional display filename.
|
||||||
|
- `userId`: optional host user id.
|
||||||
|
- `host`: optional JSON host metadata.
|
||||||
|
- `participants`: optional JSON participant metadata array.
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"recordingId": "uuid",
|
||||||
|
"meetingId": "room-001",
|
||||||
|
"filename": "2026-06-02T13-00-00-000Z-uuid.mp4",
|
||||||
|
"merged": true,
|
||||||
|
"url": "/api/recordings/room-001/2026-06-02T13-00-00-000Z-uuid.mp4/download"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server keeps the local video track, replaces audio with the server-recorded app audio, and stores the merged file in the existing `recordings` directory.
|
||||||
|
|
||||||
|
## 4. Cancel
|
||||||
|
|
||||||
|
`DELETE /api/server-audio-recordings/{recordingId}`
|
||||||
|
|
||||||
|
Use this if local recording is aborted and no merged output should be saved.
|
||||||
|
|
||||||
270
src/server.ts
270
src/server.ts
@@ -2,12 +2,14 @@ import * as express from 'express';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as morgan from 'morgan';
|
import * as morgan from 'morgan';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import signaling from './signaling';
|
import signaling from './signaling';
|
||||||
import { log, LogLevel } from './log';
|
import { log, LogLevel } from './log';
|
||||||
import Options from './class/options';
|
import Options from './class/options';
|
||||||
import { reset as resetHandler } from './class/httphandler';
|
import { reset as resetHandler } from './class/httphandler';
|
||||||
import { initSwagger } from './swagger';
|
import { initSwagger } from './swagger';
|
||||||
|
import { ServerAudioRecorderManager } from './class/serveraudiorecorder';
|
||||||
|
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
@@ -139,7 +141,15 @@ function sanitizeMetadataString(value: any, maxLength = 200): string {
|
|||||||
return '';
|
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 {
|
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 => {
|
export const createServer = (config: Options): express.Express => {
|
||||||
const app: express.Express = express();
|
const app: express.Express = express();
|
||||||
resetHandler(config.mode);
|
resetHandler(config.mode);
|
||||||
@@ -419,6 +498,7 @@ export const createServer = (config: Options): express.Express => {
|
|||||||
|
|
||||||
const recordingRoot = getRecordingRoot();
|
const recordingRoot = getRecordingRoot();
|
||||||
const recordingTempDir = path.join(recordingRoot, '.tmp');
|
const recordingTempDir = path.join(recordingRoot, '.tmp');
|
||||||
|
const serverAudioRecordings = new ServerAudioRecorderManager(recordingTempDir);
|
||||||
const recordingStorage = multer.diskStorage({
|
const recordingStorage = multer.diskStorage({
|
||||||
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
|
destination: (_req: any, _file: any, cb: (error: Error | null, destination: string) => void) => {
|
||||||
if (!fs.existsSync(recordingTempDir)) {
|
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) => {
|
app.get('/api/recordings', (_req: express.Request, res: express.Response) => {
|
||||||
try {
|
try {
|
||||||
const recordings = listRecordings(recordingRoot);
|
const recordings = listRecordings(recordingRoot);
|
||||||
|
|||||||
12
src/types/werift-nonstandard.d.ts
vendored
Normal file
12
src/types/werift-nonstandard.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
declare module 'werift/nonstandard' {
|
||||||
|
export class MediaRecorder {
|
||||||
|
onError: {
|
||||||
|
subscribe: (execute: (error: Error) => void) => { unSubscribe: () => void };
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor(props: any);
|
||||||
|
addTrack(track: any): Promise<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,10 +3,12 @@
|
|||||||
"exclude": ["node_modules", "**/*.spec.ts"],
|
"exclude": ["node_modules", "**/*.spec.ts"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
"target": "es5",
|
"target": "es5",
|
||||||
"lib": ["dom","es5"],
|
"lib": ["dom","es5"],
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"outDir":"build",
|
"outDir":"build",
|
||||||
"rootDir":"src"
|
"rootDir":"src",
|
||||||
|
"skipLibCheck": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user