现在合成的有问题,时间轴错乱,在房间中有多个用户情况下

This commit is contained in:
2026-06-02 23:59:55 +08:00
parent 206a3ac91d
commit 3e161ff995
4 changed files with 298 additions and 22 deletions

View File

@@ -53,6 +53,12 @@ type CompositionInputSets = {
audioInputs: ServerTrackRecordingFile[];
};
type VideoTimelineSegment = {
startMs: number;
endMs: number;
activeInputs: ServerTrackRecordingFile[];
};
const jobs: Map<string, RecordingCompositionJob> = new Map<string, RecordingCompositionJob>();
function nowIso(): string {
@@ -120,6 +126,87 @@ function orderVideoInputsForComposition(files: ServerTrackRecordingFile[]): Serv
];
}
function parseInputTimestamp(value: unknown): number | null {
if (typeof value !== 'string' || !value.trim()) {
return null;
}
const timestamp = Date.parse(value);
return isFinite(timestamp) ? timestamp : null;
}
function getInputStartMs(file: ServerTrackRecordingFile): number | null {
const metadata = file.metadata || {};
return parseInputTimestamp(metadata.recordingStartedAt)
|| parseInputTimestamp(file.recordingStartedAt)
|| parseInputTimestamp(metadata.startedAt)
|| parseInputTimestamp(file.uploadedAt)
|| parseInputTimestamp(metadata.uploadedAt);
}
function getInputEndMs(file: ServerTrackRecordingFile): number | null {
const metadata = file.metadata || {};
return parseInputTimestamp(metadata.recordingEndedAt)
|| parseInputTimestamp(file.recordingEndedAt)
|| parseInputTimestamp(metadata.endedAt)
|| parseInputTimestamp(metadata.updatedAt);
}
function getTimelineOriginMs(files: ServerTrackRecordingFile[]): number | null {
const starts = files
.map(getInputStartMs)
.filter((timestamp) => timestamp !== null) as number[];
if (starts.length === 0) {
return null;
}
return Math.min(...starts);
}
function getTimelineDurationSeconds(files: ServerTrackRecordingFile[], timelineOriginMs: number | null): number | null {
if (timelineOriginMs === null) {
return null;
}
const ends = files
.map(getInputEndMs)
.filter((timestamp) => timestamp !== null) as number[];
if (ends.length === 0) {
return null;
}
const durationSeconds = (Math.max(...ends) - timelineOriginMs) / 1000;
return durationSeconds > 0 ? durationSeconds : null;
}
function getTimelineEndMs(files: ServerTrackRecordingFile[]): number | null {
const ends = files
.map(getInputEndMs)
.filter((timestamp) => timestamp !== null) as number[];
if (ends.length === 0) {
return null;
}
return Math.max(...ends);
}
function getInputOffsetSeconds(file: ServerTrackRecordingFile, timelineOriginMs: number | null): number {
const startMs = getInputStartMs(file);
if (startMs === null || timelineOriginMs === null) {
return 0;
}
return Math.max(0, (startMs - timelineOriginMs) / 1000);
}
function formatSeconds(value: number): string {
if (value <= 0.001) {
return '0';
}
return value.toFixed(3).replace(/\.?0+$/, '');
}
function getBottomTileWidth(index: number, inputCount: number, outputWidth: number): number {
const sideCount = inputCount - 1;
if (sideCount <= 1) {
@@ -142,6 +229,62 @@ function createHostBottomLayout(inputCount: number, outputWidth: number, hostHei
return positions.join('|');
}
function sortActiveVideoInputs(files: ServerTrackRecordingFile[]): ServerTrackRecordingFile[] {
return orderVideoInputsForComposition(files).sort((a, b) => {
const aIsHost = isHostInput(a);
const bIsHost = isHostInput(b);
if (aIsHost !== bIsHost) {
return aIsHost ? -1 : 1;
}
return a.participantId.localeCompare(b.participantId);
});
}
function getVideoTimelineSegments(
files: ServerTrackRecordingFile[],
timelineOriginMs: number | null,
timelineEndMs: number | null
): VideoTimelineSegment[] {
if (timelineOriginMs === null || timelineEndMs === null || timelineEndMs <= timelineOriginMs) {
return [];
}
const pointsByMs: { [timestamp: string]: boolean } = {};
pointsByMs[String(timelineOriginMs)] = true;
pointsByMs[String(timelineEndMs)] = true;
files.forEach((file) => {
const startMs = getInputStartMs(file);
const endMs = getInputEndMs(file);
if (startMs !== null && startMs > timelineOriginMs && startMs < timelineEndMs) {
pointsByMs[String(startMs)] = true;
}
if (endMs !== null && endMs > timelineOriginMs && endMs < timelineEndMs) {
pointsByMs[String(endMs)] = true;
}
});
const points = Object.keys(pointsByMs)
.map((value) => Number(value))
.sort((a, b) => a - b);
const segments: VideoTimelineSegment[] = [];
for (let index = 0; index < points.length - 1; index += 1) {
const startMs = points[index];
const endMs = points[index + 1];
if (endMs <= startMs) {
continue;
}
const activeInputs = sortActiveVideoInputs(files.filter((file) => {
const inputStartMs = getInputStartMs(file) || timelineOriginMs;
const inputEndMs = getInputEndMs(file) || timelineEndMs;
return inputStartMs < endMs && inputEndMs > startMs;
}));
segments.push({ startMs, endMs, activeInputs });
}
return segments;
}
export function buildFfmpegCompositionArgs(input: {
videoInputs: ServerTrackRecordingFile[];
audioInputs: ServerTrackRecordingFile[];
@@ -153,6 +296,10 @@ export function buildFfmpegCompositionArgs(input: {
const hostHeight = 540;
const bottomHeight = outputHeight - hostHeight;
const videoInputs = orderVideoInputsForComposition(input.videoInputs);
const timelineOriginMs = getTimelineOriginMs(videoInputs.concat(input.audioInputs));
const timelineEndMs = getTimelineEndMs(videoInputs.concat(input.audioInputs));
const timelineDurationSeconds = getTimelineDurationSeconds(videoInputs.concat(input.audioInputs), timelineOriginMs);
const videoSegments = getVideoTimelineSegments(videoInputs, timelineOriginMs, timelineEndMs);
const args = ['-y'];
const orderedInputs = videoInputs.concat(input.audioInputs);
orderedInputs.forEach((file) => {
@@ -160,30 +307,72 @@ export function buildFfmpegCompositionArgs(input: {
});
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}]`);
const videoInputUseCounts = videoInputs.map((file) => videoSegments.filter((segment) => segment.activeInputs.indexOf(file) >= 0).length);
const videoInputUsePositions = videoInputs.map(() => 0);
videoInputUseCounts.forEach((useCount, inputIndex) => {
if (useCount <= 1) {
return;
}
const splitLabels = [];
for (let splitIndex = 0; splitIndex < useCount; splitIndex += 1) {
splitLabels.push(`[vin${inputIndex}_${splitIndex}]`);
}
filters.push(`[${inputIndex}:v]split=${useCount}${splitLabels.join('')}`);
});
if (videoInputs.length === 1) {
filters.push('[v0]fps=30,format=yuv420p[vout]');
videoSegments.forEach((segment, segmentIndex) => {
const segmentDurationSeconds = (segment.endMs - segment.startMs) / 1000;
if (segment.activeInputs.length === 0) {
filters.push(`color=color=black:size=${outputWidth}x${outputHeight}:rate=30:duration=${formatSeconds(segmentDurationSeconds)},format=yuv420p[seg${segmentIndex}]`);
return;
}
segment.activeInputs.forEach((file, activeIndex) => {
const inputIndex = videoInputs.indexOf(file);
const inputLabel = videoInputUseCounts[inputIndex] > 1
? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}`
: `${inputIndex}:v`;
const inputStartMs = getInputStartMs(file) || segment.startMs;
const trimStartSeconds = Math.max(0, (segment.startMs - inputStartMs) / 1000);
const width = segment.activeInputs.length === 1
? outputWidth
: activeIndex === 0 ? outputWidth : getBottomTileWidth(activeIndex - 1, segment.activeInputs.length, outputWidth);
const height = segment.activeInputs.length === 1
? outputHeight
: activeIndex === 0 ? hostHeight : bottomHeight;
filters.push(`[${inputLabel}]trim=start=${formatSeconds(trimStartSeconds)}:duration=${formatSeconds(segmentDurationSeconds)},setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[seg${segmentIndex}v${activeIndex}]`);
});
if (segment.activeInputs.length === 1) {
filters.push(`[seg${segmentIndex}v0]fps=30,format=yuv420p[seg${segmentIndex}]`);
return;
}
const segmentVideoLabels = segment.activeInputs.map((_file, activeIndex) => `[seg${segmentIndex}v${activeIndex}]`).join('');
filters.push(`${segmentVideoLabels}xstack=inputs=${segment.activeInputs.length}:layout=${createHostBottomLayout(segment.activeInputs.length, outputWidth, hostHeight)}:fill=black,fps=30,format=yuv420p[seg${segmentIndex}]`);
});
if (videoSegments.length === 1) {
filters.push('[seg0]null[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]`);
const videoLabels = videoSegments.map((_segment, index) => `[seg${index}]`).join('');
filters.push(`${videoLabels}concat=n=${videoSegments.length}:v=1:a=0[vout]`);
}
if (input.audioInputs.length === 1) {
const audioInputIndex = videoInputs.length;
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0[aout]`);
const offsetMs = Math.round(getInputOffsetSeconds(input.audioInputs[0], timelineOriginMs) * 1000);
const offsetFilter = offsetMs > 1 ? `,adelay=${offsetMs}:all=1` : '';
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0${offsetFilter}[aout]`);
} else if (input.audioInputs.length > 1) {
const audioLabels = input.audioInputs
.map((_file, index) => `[${videoInputs.length + index}:a]`)
.join('');
const audioLabels = input.audioInputs.map((file, index) => {
const audioInputIndex = videoInputs.length + index;
const offsetMs = Math.round(getInputOffsetSeconds(file, timelineOriginMs) * 1000);
const offsetFilter = offsetMs > 1 ? `,adelay=${offsetMs}:all=1` : '';
filters.push(`[${audioInputIndex}:a]aresample=async=1:first_pts=0${offsetFilter}[a${index}]`);
return `[a${index}]`;
}).join('');
filters.push(`${audioLabels}amix=inputs=${input.audioInputs.length}:duration=longest:dropout_transition=2[aout]`);
}
@@ -204,7 +393,10 @@ export function buildFfmpegCompositionArgs(input: {
}
}
args.push('-shortest', input.outputPath);
if (timelineDurationSeconds !== null) {
args.push('-t', formatSeconds(timelineDurationSeconds));
}
args.push(input.outputPath);
return args;
}