A two-person video podcast recorder: each participant records locally in the browser, chunks upload straight to S3, and the server stitches both sides into one side-by-side MP4 that is emailed to you.
- 1:1 video rooms ("Pod Cells"). Join with a room ID and a name. Two people connect peer to peer over WebRTC, with mic and camera toggles and a synchronized "end call".
- Local, synchronized recording. Either participant can start recording. The server picks a shared start time (about 5 s ahead) and a session ID, and both browsers start their
MediaRecorderat that moment. - Background chunk upload. Every 5 s the recorder produces a WebM chunk, which is uploaded directly from the browser to S3 using a pre-signed URL.
- Recordings page. Enter a session ID to:
- list and play/download every raw chunk per participant, or
- request a pre-rendered video: the server merges both recordings side by side and emails you the MP4 link (or a failure email).
- Automatic cleanup. A cron job deletes raw chunks and merged videos from S3 after 24 hours.
flowchart LR
A["Browser A<br/>React + MediaRecorder"]
B["Browser B<br/>React + MediaRecorder"]
S["Express + Socket.IO server"]
S3[("S3 video-recordings/")]
M[("S3 merged-videos/")]
E["User inbox"]
A <-->|"WebRTC media, P2P"| B
A <-->|"signaling + pre-signed URLs"| S
B <-->|"signaling + pre-signed URLs"| S
A -->|"PUT 5 s WebM chunks"| S3
B -->|"PUT 5 s WebM chunks"| S3
S -->|"download chunks, FFmpeg concat + hstack + amix"| M
S -->|"Nodemailer link"| E
Key engineering decisions
- Record locally, not from the live stream. The live call quality depends on the network, but the recording does not: each side records its own camera and mic at full quality. This is the core idea behind the app.
- Direct-to-S3 uploads with pre-signed URLs. The server never proxies video bytes during the call. Over Socket.IO it signs a
PutObjectURL (valid 10 minutes) forvideo-recordings/<sessionId>/<userName>/chunk-<index>.webm, and the browser PUTs the chunk itself. - Server-side clock for sync.
prepare-for-recordingcarries a start timestamp, and the server broadcastsstart-recording-atwith one session ID to both peers, so both recordings begin together and share a storage prefix. - Async render pipeline.
getCompleteLayoutVideoresponds immediately ("you will receive the link by email") and does the heavy work insetImmediate:- list the session's chunks in S3 and require exactly 2 participants
- download each participant's chunks
- concatenate them and re-encode to VP8/Opus WebM (
fluent-ffmpegwithffmpeg-static) - scale both to 640x720,
hstackthem, mix the two audio tracks withamix, and encode H.264/AAC MP4 - upload to
merged-videos/and email the link
- In-memory room state.
UserManagerandRoomManagerkeep rooms in aMapwith a hard cap of 2 users. The first user getssend-offeronce the second user joins.
| Layer | Tools |
|---|---|
| Client | React 19, TypeScript, Vite, Redux Toolkit, React Router 7, Tailwind CSS 4, socket.io-client, react-hot-toast |
| Real time | WebRTC (RTCPeerConnection, public STUN servers), Socket.IO 4 |
| Server | Node.js, Express 5, TypeScript |
| Media | MediaRecorder (browser), FFmpeg via fluent-ffmpeg + ffmpeg-static |
| Storage / mail | AWS S3 (@aws-sdk/client-s3, s3-request-presigner), Nodemailer, node-cron |
TalkCircle/
├── client/src/
│ ├── components/ Home, Room (WebRTC + recording), Recordings, About, NavBar
│ ├── utilities/ MediaRecorder.ts (chunked recorder), S3uploader.ts (pre-signed upload)
│ ├── services/ APIs.tsx (SERVER_URL + endpoints), APIcall.tsx (axios wrapper)
│ └── reducers/ Redux store (user name, room ID)
└── server/src/
├── index.ts Express app, Socket.IO, cleanup cron
├── managers/ UserManager (socket events), RoomManager (rooms, signaling relay)
├── controllers/ videoController.ts (list chunks, render, cleanup)
├── routes/ videoRoutes.ts
├── utilities/ S3 upload/sign, chunk download, FFmpeg concat + layout, mail
├── mailTemplates/ success and failure emails
└── config/S3Client.ts
Prerequisites: Node.js, an S3 bucket, and SMTP credentials supported by Nodemailer. FFmpeg is bundled through ffmpeg-static.
Server (listens on port 3000):
cd server
npm install
npm run dev # tsc -b, then node ./dist/index.jsCreate server/.env:
AWS_REGION=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
S3_BUCKET_NAME=
MAIL_HOST= # Nodemailer "service" name, e.g. gmail
MAIL_USER=
MAIL_PASS=
SERVER_BASE_URL=http://localhost:3000 # used by the cleanup cron to call itself
SECRET_KEY= # shared secret for the cleanup endpointThe bucket needs a CORS rule that allows PUT from the client origin (browsers upload directly). Chunk and video links are built as public https://<bucket>.s3.<region>.amazonaws.com/<key> URLs, so objects must be readable at those URLs for playback and for the render step.
Client (Vite, default http://localhost:5173):
cd client
npm install
npm run devPoint the client at your server by editing SERVER_URL in client/src/services/APIs.tsx (for example http://localhost:3000).
Other scripts: npm run build, npm run lint, npm run preview (client); npm run build, npm start (server).
REST (prefix /api, all POST with a JSON body)
| Endpoint | Body | Purpose |
|---|---|---|
/getVideoChunksFromSessionId |
{ sessionId } |
Returns chunk URLs grouped by participant |
/getCompleteLayoutVideo |
{ sessionId, emailId } |
Starts the merge in the background and emails the MP4 link |
/deleteOldVideoCronJob |
{ secretKey } |
Deletes S3 objects older than 24 h (called every 10 minutes by node-cron) |
Socket.IO events
| Client to server | Server to client |
|---|---|
join-room, offer, answer, add-ice-candidate |
set-remote-user-name, send-offer, offer, answer, add-ice-candidate |
prepare-for-recording, stop-recording |
start-recording-at, stop-recording, recording-error |
get-presigned-url (with ack callback) |
|
request-end-call |
confirm-end-call |
- Move rendering to a job queue. The FFmpeg pipeline runs inside the API process, and downloaded chunks in
chunksStorage/are never cleaned up. A worker with a queue (and temp-file cleanup) would make it robust. - Know when uploads are finished. Users are asked to wait about 5 minutes before ending a call. Tracking acknowledged chunks per session would remove the guesswork.
- Configuration. Read
SERVER_URLand the server port from environment variables, and restrict CORS (currently*) to the client origin. - Access control on recordings. Anyone with a session ID can list chunks or request a render. Private buckets with pre-signed GET links would be safer.
- Consistent retention. The Recordings page says raw chunks are deleted after 12 hours, but the cleanup job uses a 24-hour threshold for everything.
- More than two participants, TURN server support for strict NATs, and automated tests.