diff --git a/apps/web/src/app/api/stream/egress/start/route.test.ts b/apps/web/src/app/api/stream/egress/start/route.test.ts index da81452..b34590f 100644 --- a/apps/web/src/app/api/stream/egress/start/route.test.ts +++ b/apps/web/src/app/api/stream/egress/start/route.test.ts @@ -61,9 +61,7 @@ beforeEach(() => { process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-key'; mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); mockSingle.mockResolvedValue({ data: hostSession, error: null }); - mockStartRoomCompositeEgress - .mockResolvedValueOnce({ egressId: 'eg-yt' }) - .mockResolvedValueOnce({ egressId: 'eg-tw' }); + mockStartRoomCompositeEgress.mockResolvedValue({ egressId: 'eg-combined' }); }); describe('POST /api/stream/egress/start', () => { @@ -91,32 +89,26 @@ describe('POST /api/stream/egress/start', () => { expect(res.status).toBe(403); }); - it('starts one isolated egress per destination', async () => { + it('starts a single composite fanning out to all destinations', async () => { const urls = ['rtmp://a.rtmp.youtube.com/live2/yt-key', 'rtmp://live.twitch.tv/app/tw-key']; const res = await POST(createRequest({ sessionId, rtmpUrls: urls })); expect(res.status).toBe(200); const json = (await res.json()) as { data: { egressId: string; egressIds: string[] } }; - expect(json.data.egressIds).toEqual(['eg-yt', 'eg-tw']); - expect(json.data.egressId).toBe('eg-yt'); + expect(json.data.egressIds).toEqual(['eg-combined']); + expect(json.data.egressId).toBe('eg-combined'); - // One egress per URL — each StreamOutput carries a single destination. - expect(mockStartRoomCompositeEgress).toHaveBeenCalledTimes(2); - expect(mockStartRoomCompositeEgress).toHaveBeenNthCalledWith( - 1, + // One composite (one Chrome render + one H264 encode) carrying every URL in + // a single StreamOutput — half the CPU of one-egress-per-destination. + expect(mockStartRoomCompositeEgress).toHaveBeenCalledTimes(1); + expect(mockStartRoomCompositeEgress).toHaveBeenCalledWith( `session-${sessionId}`, - expect.objectContaining({ stream: expect.objectContaining({ urls: [urls[0]] }) }), + expect.objectContaining({ stream: expect.objectContaining({ urls }) }), expect.objectContaining({ layout: 'speaker', // 1s keyframe interval so YouTube Live leaves "Preparing" reliably encodingOptions: expect.objectContaining({ keyFrameInterval: 1, height: 1080 }), }) ); - expect(mockStartRoomCompositeEgress).toHaveBeenNthCalledWith( - 2, - `session-${sessionId}`, - expect.objectContaining({ stream: expect.objectContaining({ urls: [urls[1]] }) }), - expect.objectContaining({ layout: 'speaker' }) - ); }); }); diff --git a/apps/web/src/app/api/stream/egress/start/route.ts b/apps/web/src/app/api/stream/egress/start/route.ts index 69eba7c..0c3ba4c 100644 --- a/apps/web/src/app/api/stream/egress/start/route.ts +++ b/apps/web/src/app/api/stream/egress/start/route.ts @@ -92,24 +92,24 @@ export async function POST(request: Request) { keyFrameInterval: 1, }); - // One egress per destination instead of a single combined pipeline. With a - // shared RoomComposite fanning out to YouTube + Twitch in one StreamOutput, - // a stall on one sink (e.g. YouTube stuck on "Preparing") can back-pressure - // the shared flvmux/encoder and degrade or stall the others. Independent - // egresses isolate each platform and give each its own egressId/status. - const results = await Promise.all( - rtmpUrls.map((url) => - egress.startRoomCompositeEgress( - roomName, - { stream: new StreamOutput({ protocol: StreamProtocol.RTMP, urls: [url] }) }, - { layout: 'speaker', encodingOptions } - ) - ) + // A SINGLE RoomComposite that fans out to every destination in one + // StreamOutput. One composite = one headless Chrome rendering + one H264 + // encode, regardless of how many platforms; each extra RTMP URL is just + // another cheap mux/push. Running one isolated egress *per destination* + // (the previous approach) duplicated the whole render+encode pipeline, so + // streaming to two platforms needed ~2x the CPU and the 4-vCPU SFU droplet + // thrashed — the second composite (e.g. YouTube) starved and stuck on + // "Preparing", and the CPU spike broke the host's publisher DTLS handshake + // ("could not establish pc connection"). keyFrameInterval:1 (above) already + // mitigates the YouTube-stall back-pressure that motivated the split. + const info = await egress.startRoomCompositeEgress( + roomName, + { stream: new StreamOutput({ protocol: StreamProtocol.RTMP, urls: rtmpUrls }) }, + { layout: 'speaker', encodingOptions } ); - const egressIds = results.map((info) => info.egressId); - // egressId (first) kept for backward compatibility with older clients. - return successResponse({ egressIds, egressId: egressIds[0] }); + // egressIds[] kept for clients that track the full set; egressId for older ones. + return successResponse({ egressIds: [info.egressId], egressId: info.egressId }); } catch (error) { return handleApiError(error); }