【m】修改为服务器录屏
This commit is contained in:
324
src/recording/composer.ts
Normal file
324
src/recording/composer.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
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<string, RecordingCompositionJob> = new Map<string, RecordingCompositionJob>();
|
||||
|
||||
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<void> {
|
||||
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<RecordingCompositionJob> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user