feat(media): add single-server SFU scaffolding and media mode config

This commit is contained in:
2026-02-07 17:30:00 +00:00
parent 63e7700340
commit aae91ac862
5 changed files with 179 additions and 0 deletions

43
Backend/media/config.ts Normal file
View File

@@ -0,0 +1,43 @@
export type MediaMode = 'legacy' | 'single_server_sfu';
const parseMediaMode = (value: string | undefined): MediaMode => {
const normalized = (value ?? 'legacy').trim().toLowerCase();
if (normalized === 'single_server_sfu') {
return 'single_server_sfu';
}
return 'legacy';
};
const parseCsv = (value: string | undefined): string[] => {
if (!value) return [];
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
};
const parsePositiveNumber = (value: string | undefined): number | null => {
if (!value) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return null;
}
return parsed;
};
export const mediaMode: MediaMode = parseMediaMode(process.env.MEDIA_MODE);
export const mediaConfig = {
mode: mediaMode,
turn: {
urls: parseCsv(process.env.TURN_URLS),
username: process.env.TURN_USERNAME ?? '',
credential: process.env.TURN_CREDENTIAL ?? '',
},
recordingsDir: process.env.MEDIA_RECORDINGS_DIR ?? 'media-recordings',
limits: {
maxPublishers: parsePositiveNumber(process.env.MEDIA_MAX_PUBLISHERS),
maxSubscribersPerRoom: parsePositiveNumber(process.env.MEDIA_MAX_SUBSCRIBERS_PER_ROOM),
},
};

71
Backend/media/sfu/noop.ts Normal file
View File

@@ -0,0 +1,71 @@
import { randomUUID } from 'crypto';
import { mediaConfig } from '../config';
import type {
SfuPublishTransportRequest,
SfuPublishTransportResult,
SfuService,
SfuSessionDescriptor,
SfuSessionStartInput,
SfuSubscribeTransportRequest,
SfuSubscribeTransportResult,
} from './types';
const toIceServers = (): Array<{ urls: string; username?: string; credential?: string }> => {
if (mediaConfig.turn.urls.length === 0) {
return [];
}
return mediaConfig.turn.urls.map((urls) => ({
urls,
...(mediaConfig.turn.username ? { username: mediaConfig.turn.username } : {}),
...(mediaConfig.turn.credential ? { credential: mediaConfig.turn.credential } : {}),
}));
};
export class NoopSfuService implements SfuService {
mode: 'single_server_sfu' = 'single_server_sfu';
private readonly sessions = new Map<string, SfuSessionDescriptor>();
async startSession(input: SfuSessionStartInput): Promise<SfuSessionDescriptor> {
const now = new Date().toISOString();
const existing = this.sessions.get(input.streamSessionId);
if (existing) return existing;
const descriptor: SfuSessionDescriptor = {
streamSessionId: input.streamSessionId,
ownerUserId: input.ownerUserId,
cameraDeviceId: input.cameraDeviceId,
requesterDeviceId: input.requesterDeviceId,
state: 'starting',
createdAt: now,
};
this.sessions.set(input.streamSessionId, descriptor);
return descriptor;
}
async endSession(streamSessionId: string): Promise<void> {
const existing = this.sessions.get(streamSessionId);
if (!existing) return;
this.sessions.set(streamSessionId, { ...existing, state: 'ended' });
}
async getSession(streamSessionId: string): Promise<SfuSessionDescriptor | null> {
return this.sessions.get(streamSessionId) ?? null;
}
async createPublishTransport(_input: SfuPublishTransportRequest): Promise<SfuPublishTransportResult> {
return {
transportId: `pub_${randomUUID()}`,
iceServers: toIceServers(),
};
}
async createSubscribeTransport(_input: SfuSubscribeTransportRequest): Promise<SfuSubscribeTransportResult> {
return {
transportId: `sub_${randomUUID()}`,
iceServers: toIceServers(),
};
}
}

View File

@@ -0,0 +1,14 @@
import { mediaMode } from '../config';
import { NoopSfuService } from './noop';
import type { SfuService } from './types';
const createSfuService = (): SfuService | null => {
if (mediaMode !== 'single_server_sfu') {
return null;
}
return new NoopSfuService();
};
export const sfuService = createSfuService();

View File

@@ -0,0 +1,47 @@
export type SfuSessionState = 'idle' | 'starting' | 'live' | 'ending' | 'ended';
export type SfuSessionDescriptor = {
streamSessionId: string;
ownerUserId: string;
cameraDeviceId: string;
requesterDeviceId: string;
state: SfuSessionState;
createdAt: string;
};
export type SfuSessionStartInput = {
streamSessionId: string;
ownerUserId: string;
cameraDeviceId: string;
requesterDeviceId: string;
};
export type SfuPublishTransportRequest = {
streamSessionId: string;
cameraDeviceId: string;
};
export type SfuPublishTransportResult = {
transportId: string;
iceServers: Array<{ urls: string; username?: string; credential?: string }>;
};
export type SfuSubscribeTransportRequest = {
streamSessionId: string;
viewerDeviceId: string;
};
export type SfuSubscribeTransportResult = {
transportId: string;
iceServers: Array<{ urls: string; username?: string; credential?: string }>;
};
export interface SfuService {
mode: 'single_server_sfu';
startSession(input: SfuSessionStartInput): Promise<SfuSessionDescriptor>;
endSession(streamSessionId: string): Promise<void>;
getSession(streamSessionId: string): Promise<SfuSessionDescriptor | null>;
createPublishTransport(input: SfuPublishTransportRequest): Promise<SfuPublishTransportResult>;
createSubscribeTransport(input: SfuSubscribeTransportRequest): Promise<SfuSubscribeTransportResult>;
}