Unity启动,录制视频没有文件产出
This commit is contained in:
@@ -406,6 +406,17 @@ function unwrapRecordingClientPayload(message: any): any {
|
|||||||
return payload;
|
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[] {
|
function getActiveRecordingSessions(connectionId: string): RecordingSession[] {
|
||||||
return listRecordingSessions(connectionId).filter((session) => session.status === 'recording');
|
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' });
|
safeSendOnMessage(ws, connectionId, { type: 'recording-status', recordingId, connectionId, status: 'invalid-offer' });
|
||||||
return;
|
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({
|
const offer = registerRecordingPeerOffer({
|
||||||
recordingId,
|
recordingId,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ type CompositionInputSets = {
|
|||||||
|
|
||||||
type VideoTimelineSegment = {
|
type VideoTimelineSegment = {
|
||||||
startMs: number;
|
startMs: number;
|
||||||
endMs: number;
|
endMs: number | null;
|
||||||
activeInputs: ServerTrackRecordingFile[];
|
activeInputs: ServerTrackRecordingFile[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -275,8 +275,10 @@ function getVideoTimelineSegments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeInputs = sortActiveVideoInputs(files.filter((file) => {
|
const activeInputs = sortActiveVideoInputs(files.filter((file) => {
|
||||||
const inputStartMs = getInputStartMs(file) || timelineOriginMs;
|
const fileStartMs = getInputStartMs(file);
|
||||||
const inputEndMs = getInputEndMs(file) || timelineEndMs;
|
const fileEndMs = getInputEndMs(file);
|
||||||
|
const inputStartMs = fileStartMs === null ? timelineOriginMs : fileStartMs;
|
||||||
|
const inputEndMs = fileEndMs === null ? timelineEndMs : fileEndMs;
|
||||||
return inputStartMs < endMs && inputEndMs > startMs;
|
return inputStartMs < endMs && inputEndMs > startMs;
|
||||||
}));
|
}));
|
||||||
segments.push({ startMs, endMs, activeInputs });
|
segments.push({ startMs, endMs, activeInputs });
|
||||||
@@ -285,6 +287,17 @@ function getVideoTimelineSegments(
|
|||||||
return segments;
|
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: {
|
export function buildFfmpegCompositionArgs(input: {
|
||||||
videoInputs: ServerTrackRecordingFile[];
|
videoInputs: ServerTrackRecordingFile[];
|
||||||
audioInputs: ServerTrackRecordingFile[];
|
audioInputs: ServerTrackRecordingFile[];
|
||||||
@@ -299,7 +312,10 @@ export function buildFfmpegCompositionArgs(input: {
|
|||||||
const timelineOriginMs = getTimelineOriginMs(videoInputs.concat(input.audioInputs));
|
const timelineOriginMs = getTimelineOriginMs(videoInputs.concat(input.audioInputs));
|
||||||
const timelineEndMs = getTimelineEndMs(videoInputs.concat(input.audioInputs));
|
const timelineEndMs = getTimelineEndMs(videoInputs.concat(input.audioInputs));
|
||||||
const timelineDurationSeconds = getTimelineDurationSeconds(videoInputs.concat(input.audioInputs), timelineOriginMs);
|
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 args = ['-y'];
|
||||||
const orderedInputs = videoInputs.concat(input.audioInputs);
|
const orderedInputs = videoInputs.concat(input.audioInputs);
|
||||||
orderedInputs.forEach((file) => {
|
orderedInputs.forEach((file) => {
|
||||||
@@ -322,8 +338,11 @@ export function buildFfmpegCompositionArgs(input: {
|
|||||||
});
|
});
|
||||||
|
|
||||||
videoSegments.forEach((segment, segmentIndex) => {
|
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 (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}]`);
|
filters.push(`color=color=black:size=${outputWidth}x${outputHeight}:rate=30:duration=${formatSeconds(segmentDurationSeconds)},format=yuv420p[seg${segmentIndex}]`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -333,7 +352,8 @@ export function buildFfmpegCompositionArgs(input: {
|
|||||||
const inputLabel = videoInputUseCounts[inputIndex] > 1
|
const inputLabel = videoInputUseCounts[inputIndex] > 1
|
||||||
? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}`
|
? `vin${inputIndex}_${videoInputUsePositions[inputIndex]++}`
|
||||||
: `${inputIndex}:v`;
|
: `${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 trimStartSeconds = Math.max(0, (segment.startMs - inputStartMs) / 1000);
|
||||||
const width = segment.activeInputs.length === 1
|
const width = segment.activeInputs.length === 1
|
||||||
? outputWidth
|
? outputWidth
|
||||||
@@ -341,7 +361,11 @@ export function buildFfmpegCompositionArgs(input: {
|
|||||||
const height = segment.activeInputs.length === 1
|
const height = segment.activeInputs.length === 1
|
||||||
? outputHeight
|
? outputHeight
|
||||||
: activeIndex === 0 ? hostHeight : bottomHeight;
|
: 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) {
|
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> {
|
async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise<RecordingCompositionJob> {
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
job.status = 'running';
|
job.status = 'running';
|
||||||
@@ -489,6 +523,11 @@ async function runRecordingCompositionJob(job: RecordingCompositionJob): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob {
|
export function startRecordingCompositionJob(input: StartCompositionInput): RecordingCompositionJob {
|
||||||
|
const activeJob = getActiveCompositionJob(input);
|
||||||
|
if (activeJob) {
|
||||||
|
return activeJob;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
const inputSets = getInputSets(input);
|
const inputSets = getInputSets(input);
|
||||||
const job: RecordingCompositionJob = {
|
const job: RecordingCompositionJob = {
|
||||||
|
|||||||
@@ -93,6 +93,24 @@ describe('recording composer', () => {
|
|||||||
expect(args).not.toContain('libopus');
|
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', () => {
|
test('pads late participant tracks to keep the room timeline aligned', () => {
|
||||||
const args = buildFfmpegCompositionArgs({
|
const args = buildFfmpegCompositionArgs({
|
||||||
videoInputs: [
|
videoInputs: [
|
||||||
|
|||||||
@@ -292,3 +292,53 @@ describe('websocket signaling test in private mode', () => {
|
|||||||
await wsHandler.remove(client);
|
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"')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user