Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 9 additions & 17 deletions apps/web/src/app/api/stream/egress/start/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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' })
);
});
});
32 changes: 16 additions & 16 deletions apps/web/src/app/api/stream/egress/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading