Unity启动,录制视频没有文件产出

This commit is contained in:
2026-06-03 00:37:41 +08:00
parent 3e161ff995
commit f742499b33
4 changed files with 142 additions and 7 deletions

View File

@@ -406,6 +406,17 @@ function unwrapRecordingClientPayload(message: any): any {
return payload;
}
function getSdpMediaSections(sdp: string): string[] {
return sdp
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => /^m=(audio|video)\s/i.test(line));
}
function hasRecordableSdpMedia(sdp: string): boolean {
return getSdpMediaSections(sdp).length > 0;
}
function getActiveRecordingSessions(connectionId: string): RecordingSession[] {
return listRecordingSessions(connectionId).filter((session) => session.status === 'recording');
}
@@ -899,6 +910,23 @@ async function onRecordingOffer(ws: WebSocket, message: any): Promise<void> {
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' });
return;
}
if (!hasRecordableSdpMedia(sdp)) {
const participantId = getParticipantId(ws) || message.participantId || 'unknown';
log(LogLevel.warn, 'Rejected recording offer without audio/video media sections:', {
recordingId,
connectionId,
participantId,
mediaSections: getSdpMediaSections(sdp)
});
safeSendOnMessage(ws, connectionId, {
type: 'recording-status',
recordingId,
connectionId,
status: 'no-media-offer',
participantId
});
return;
}
const offer = registerRecordingPeerOffer({
recordingId,

View File

@@ -55,7 +55,7 @@ type CompositionInputSets = {
type VideoTimelineSegment = {
startMs: number;
endMs: number;
endMs: number | null;
activeInputs: ServerTrackRecordingFile[];
};
@@ -275,8 +275,10 @@ function getVideoTimelineSegments(
}
const activeInputs = sortActiveVideoInputs(files.filter((file) => {
const inputStartMs = getInputStartMs(file) || timelineOriginMs;
const inputEndMs = getInputEndMs(file) || timelineEndMs;
const fileStartMs = getInputStartMs(file);
const fileEndMs = getInputEndMs(file);
const inputStartMs = fileStartMs === null ? timelineOriginMs : fileStartMs;
const inputEndMs = fileEndMs === null ? timelineEndMs : fileEndMs;
return inputStartMs < endMs && inputEndMs > startMs;
}));
segments.push({ startMs, endMs, activeInputs });
@@ -285,6 +287,17 @@ function getVideoTimelineSegments(
return segments;
}
function getFallbackVideoTimelineSegment(
files: ServerTrackRecordingFile[],
timelineOriginMs: number | null
): VideoTimelineSegment {
return {
startMs: timelineOriginMs === null ? 0 : timelineOriginMs,
endMs: null,
activeInputs: sortActiveVideoInputs(files)
};
}
export function buildFfmpegCompositionArgs(input: {
videoInputs: ServerTrackRecordingFile[];
audioInputs: ServerTrackRecordingFile[];
@@ -299,7 +312,10 @@ export function buildFfmpegCompositionArgs(input: {
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 timelineVideoSegments = getVideoTimelineSegments(videoInputs, timelineOriginMs, timelineEndMs);
const videoSegments = timelineVideoSegments.length > 0
? timelineVideoSegments
: [getFallbackVideoTimelineSegment(videoInputs, timelineOriginMs)];
const args = ['-y'];
const orderedInputs = videoInputs.concat(input.audioInputs);
orderedInputs.forEach((file) => {
@@ -322,8 +338,11 @@ export function buildFfmpegCompositionArgs(input: {
});
videoSegments.forEach((segment, segmentIndex) => {
const segmentDurationSeconds = (segment.endMs - segment.startMs) / 1000;
const segmentDurationSeconds = segment.endMs === null ? null : (segment.endMs - segment.startMs) / 1000;
if (segment.activeInputs.length === 0) {
if (segmentDurationSeconds === null) {
return;
}
filters.push(`color=color=black:size=${outputWidth}x${outputHeight}:rate=30:duration=${formatSeconds(segmentDurationSeconds)},format=yuv420p[seg${segmentIndex}]`);
return;
}
@@ -333,7 +352,8 @@ export function buildFfmpegCompositionArgs(input: {
const inputLabel = videoInputUseCounts[inputIndex] > 1
? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}`
: `${inputIndex}:v`;
const inputStartMs = getInputStartMs(file) || segment.startMs;
const fileStartMs = getInputStartMs(file);
const inputStartMs = fileStartMs === null ? segment.startMs : fileStartMs;
const trimStartSeconds = Math.max(0, (segment.startMs - inputStartMs) / 1000);
const width = segment.activeInputs.length === 1
? outputWidth
@@ -341,7 +361,11 @@ export function buildFfmpegCompositionArgs(input: {
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}]`);
const trimOptions = [`start=${formatSeconds(trimStartSeconds)}`];
if (segmentDurationSeconds !== null) {
trimOptions.push(`duration=${formatSeconds(segmentDurationSeconds)}`);
}
filters.push(`[${inputLabel}]trim=${trimOptions.join(':')},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) {
@@ -435,6 +459,16 @@ function toOutput(job: RecordingCompositionJob, target: ServerTrackRecordingTarg
};
}
function getActiveCompositionJob(input: StartCompositionInput): RecordingCompositionJob | null {
const recordingId = normalizeOption(input.recordingId, '');
const meetingId = normalizeOption(input.meetingId, '');
return Array.from(jobs.values()).find((job) => {
return job.recordingId === recordingId
&& job.meetingId === meetingId
&& (job.status === 'queued' || job.status === 'running');
}) || null;
}
async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise<RecordingCompositionJob> {
const timestamp = nowIso();
job.status = 'running';
@@ -489,6 +523,11 @@ async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise
}
export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob {
const activeJob = getActiveCompositionJob(input);
if (activeJob) {
return activeJob;
}
const timestamp = nowIso();
const inputSets = getInputSets(input);
const job: RecordingCompositionJob = {

View File

@@ -93,6 +93,24 @@ describe('recording composer', () => {
expect(args).not.toContain('libopus');
});
test('falls back to one video segment when input end timestamps are missing', () => {
const args = buildFfmpegCompositionArgs({
videoInputs: [
file('host-video.webm', 'video', 'host', 'host', '2026-06-01T00:00:00.000Z', ''),
file('p1-video.webm', 'video', 'p1', 'participant', '2026-06-01T00:00:02.500Z', '')
],
audioInputs: [],
outputPath: 'recordings/room-1/output.webm',
format: 'webm'
});
const filter = args[args.indexOf('-filter_complex') + 1];
expect(filter).toContain('xstack=inputs=2');
expect(filter).toContain('trim=start=0,setpts=PTS-STARTPTS');
expect(filter).not.toContain('concat=n=0');
expect(args).not.toContain('-t');
});
test('pads late participant tracks to keep the room timeline aligned', () => {
const args = buildFfmpegCompositionArgs({
videoInputs: [

View File

@@ -292,3 +292,53 @@ describe('websocket signaling test in private mode', () => {
await wsHandler.remove(client);
});
});
describe('recording offer validation', () => {
let server: WS;
let client: WebSocket;
const connectionId = "recording-room";
beforeAll(async () => {
wsHandler.reset("private");
server = new WS("ws://localhost:1234", { jsonProtocol: true });
client = new WebSocket("ws://localhost:1234");
await server.connected;
await wsHandler.add(client);
await wsHandler.onConnect(client, connectionId);
await expect(server).toReceiveMessage({
type: "connect",
connectionId: connectionId,
polite: false,
role: "host",
participantId: anyParticipantId
});
});
afterAll(() => {
WS.clean();
});
test('rejects recording offers without audio or video media sections', async () => {
await wsHandler.onRecordingOffer(client, {
recordingId: 'recording-empty-offer',
connectionId,
sdp: [
'v=0',
'o=- 25268170 0 IN IP4 0.0.0.0',
's=-',
't=0 0',
'a=group:BUNDLE ',
'a=extmap-allow-mixed',
'a=msid-semantic:WMS *',
''
].join('\r\n')
});
await expect(server).toReceiveMessage(expect.objectContaining({
from: connectionId,
to: "",
type: "on-message",
data: expect.stringContaining('"status":"no-media-offer"')
}));
});
});