import { spawn } from 'child_process'; import { v4 as uuid } from 'uuid'; import { ServerTrackRecordingFile, ServerTrackRecordingTarget, createComposedRecordingTarget, deleteServerTrackRecordingFiles, listServerTrackRecordingFiles, writeComposedRecordingMetadata } from './storage'; export type RecordingCompositionStatus = 'queued' | 'running' | 'completed' | 'failed'; export type RecordingCompositionJob = { id: string; recordingId: string; meetingId: string; status: RecordingCompositionStatus; layout: string; format: string; createdAt: string; updatedAt: string; startedAt?: string; completedAt?: string; failedAt?: string; error?: string; inputFiles: string[]; deletedInputFiles?: string[]; output?: { meetingId: string; filename: string; filePath: string; metadataPath: string; downloadUrl: string; streamUrl: string; }; }; type StartCompositionInput = { meetingId: string; recordingId: string; layout?: string; format?: string; }; type CompositionInputSets = { videoInputs: ServerTrackRecordingFile[]; audioInputs: ServerTrackRecordingFile[]; }; const jobs: Map = new Map(); function nowIso(): string { return new Date().toISOString(); } function normalizeOption(value: unknown, fallback: string): string { if (typeof value !== 'string') { return fallback; } const trimmed = value.trim(); return trimmed ? trimmed.slice(0, 40) : fallback; } function normalizeFormat(value: unknown): string { return normalizeOption(value, 'webm') === 'mp4' ? 'mp4' : 'webm'; } function getFfmpegPath(): string { return process.env.FFMPEG_PATH || 'ffmpeg'; } function sortInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] { return files.slice().sort((a, b) => { const participantCompare = a.participantId.localeCompare(b.participantId); if (participantCompare !== 0) { return participantCompare; } return Date.parse(a.uploadedAt) - Date.parse(b.uploadedAt); }); } function getInputSets(input: StartCompositionInput): CompositionInputSets { const files = listServerTrackRecordingFiles({ meetingId: input.meetingId, recordingId: input.recordingId }); return { videoInputs: sortInputs(files.filter((file) => file.trackKind === 'video')), audioInputs: sortInputs(files.filter((file) => file.trackKind === 'audio')) }; } function isHostInput(file: ServerTrackRecordingFile): boolean { if (file.metadata && file.metadata.role === 'host') { return true; } const firstParticipant = file.metadata && Array.isArray(file.metadata.participants) ? file.metadata.participants[0] : null; return Boolean(firstParticipant && firstParticipant.role === 'host'); } function orderVideoInputsForComposition(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] { const hostIndex = files.findIndex(isHostInput); if (hostIndex <= 0) { return files.slice(); } return [ files[hostIndex], ...files.slice(0, hostIndex), ...files.slice(hostIndex + 1) ]; } function getBottomTileWidth(index: number, inputCount: number, outputWidth: number): number { const sideCount = inputCount - 1; if (sideCount <= 1) { return outputWidth; } const rawWidth = Math.floor(outputWidth / sideCount); const tileWidth = rawWidth % 2 === 0 ? rawWidth : rawWidth - 1; return index === sideCount - 1 ? outputWidth - (tileWidth * index) : tileWidth; } function createHostBottomLayout(inputCount: number, outputWidth: number, hostHeight: number): string { const positions = ['0_0']; const sideCount = inputCount - 1; let x = 0; for (let sideIndex = 0; sideIndex < sideCount; sideIndex += 1) { positions.push(`${x}_${hostHeight}`); x += getBottomTileWidth(sideIndex, inputCount, outputWidth); } return positions.join('|'); } export function buildFfmpegCompositionArgs(input: { videoInputs: ServerTrackRecordingFile[]; audioInputs: ServerTrackRecordingFile[]; outputPath: string; format: string; }): string[] { const outputWidth = 1280; const outputHeight = 720; const hostHeight = 540; const bottomHeight = outputHeight - hostHeight; const videoInputs = orderVideoInputsForComposition(input.videoInputs); const args = ['-y']; const orderedInputs = videoInputs.concat(input.audioInputs); orderedInputs.forEach((file) => { args.push('-i', file.filePath); }); const filters: string[] = []; videoInputs.forEach((_file, index) => { const width = videoInputs.length === 1 ? outputWidth : index === 0 ? outputWidth : getBottomTileWidth(index - 1, videoInputs.length, outputWidth); const height = videoInputs.length === 1 ? outputHeight : index === 0 ? hostHeight : bottomHeight; filters.push(`[${index}:v]scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v${index}]`); }); if (videoInputs.length === 1) { filters.push('[v0]fps=30,format=yuv420p[vout]'); } else { const videoLabels = videoInputs.map((_file, index) => `[v${index}]`).join(''); filters.push(`${videoLabels}xstack=inputs=${videoInputs.length}:layout=${createHostBottomLayout(videoInputs.length, outputWidth, hostHeight)}:fill=black,fps=30,format=yuv420p[vout]`); } if (input.audioInputs.length === 1) { const audioInputIndex = videoInputs.length; filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0[aout]`); } else if (input.audioInputs.length > 1) { const audioLabels = input.audioInputs .map((_file, index) => `[${videoInputs.length + index}:a]`) .join(''); filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2[aout]`); } args.push('-filter_complex', filters.join(';'), '-map', '[vout]'); if (input.audioInputs.length > 0) { args.push('-map', '[aout]'); } if (input.format === 'mp4') { args.push('-c:v', 'libx264', '-preset', 'veryfast', '-pix_fmt', 'yuv420p'); if (input.audioInputs.length > 0) { args.push('-c:a', 'aac'); } } else { args.push('-c:v', 'libvpx-vp9', '-deadline', 'realtime', '-cpu-used', '4'); if (input.audioInputs.length > 0) { args.push('-c:a', 'libopus'); } } args.push('-shortest', input.outputPath); return args; } function runFfmpeg(args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(getFfmpegPath(), args, { windowsHide: true }); let stderr = ''; child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); child.on('error', (error: any) => { if (error && error.code === 'ENOENT') { reject(new Error('ffmpeg was not found. Install ffmpeg or set FFMPEG_PATH.')); return; } reject(error); }); child.on('close', (code) => { if (code === 0) { resolve(); return; } reject(new Error(stderr || `ffmpeg exited with code ${code}`)); }); }); } function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarget): RecordingCompositionJob['output'] { return { meetingId: target.meetingId, filename: target.filename, filePath: target.filePath, metadataPath: target.metadataPath, downloadUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/download`, streamUrl: `/api/recordings/${encodeURIComponent(target.meetingId)}/${encodeURIComponent(target.filename)}/stream` }; } async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise { const timestamp = nowIso(); job.status = 'running'; job.startedAt = timestamp; job.updatedAt = timestamp; try { const inputSets = getInputSets(job); if (inputSets.videoInputs.length === 0) { throw new Error('No server-side video track files are available for composition.'); } const target = createComposedRecordingTarget({ meetingId: job.meetingId, recordingId: job.recordingId, format: job.format }); const compositionInputs = inputSets.videoInputs.concat(inputSets.audioInputs); const args = buildFfmpegCompositionArgs({ ...inputSets, outputPath: target.filePath, format: job.format }); await runFfmpeg(args); writeComposedRecordingMetadata({ target, recordingId: job.recordingId, inputs: compositionInputs, layout: job.layout, format: job.format }); const deletedInputFiles = deleteServerTrackRecordingFiles(compositionInputs); const completedAt = nowIso(); job.status = 'completed'; job.completedAt = completedAt; job.updatedAt = completedAt; job.inputFiles = compositionInputs.map((file) => file.filename); job.deletedInputFiles = deletedInputFiles; job.output = toOutput(job, target); } catch (error) { const failedAt = nowIso(); job.status = 'failed'; job.failedAt = failedAt; job.updatedAt = failedAt; job.error = error instanceof Error ? error.message : String(error); } return job; } export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob { const timestamp = nowIso(); const inputSets = getInputSets(input); const job: RecordingCompositionJob = { id: uuid(), recordingId: normalizeOption(input.recordingId, ''), meetingId: normalizeOption(input.meetingId, ''), status: 'queued', layout: normalizeOption(input.layout, 'grid'), format: normalizeFormat(input.format), createdAt: timestamp, updatedAt: timestamp, inputFiles: inputSets.videoInputs.concat(inputSets.audioInputs).map((file) => file.filename) }; jobs.set(job.id, job); runRecordingCompositionJob(job); return job; } export function getRecordingCompositionJob(jobId: string): RecordingCompositionJob | null { return jobs.get(jobId) || null; } export function listRecordingCompositionJobs(meetingId?: string): RecordingCompositionJob[] { const allJobs = Array.from(jobs.values()); return meetingId ? allJobs.filter((job) => job.meetingId === meetingId) : allJobs; } export function resetRecordingCompositionJobs(): void { jobs.clear(); }