Livestreaming - Technical Deep Dive
This page describes the streaming flow and technical details used by the Spritz app (see lib/livepeer.ts, GoLiveModal, LiveStreamPlayer, and stream API routes in the codebase).
Architecture
Spritz uses Livepeer for livestreaming infrastructure, combining WebRTC for ingestion and HLS for playback.
Streaming Flow
1. Stream Creation
// Create stream on Livepeer (recording off by default)
const livepeerStream = await createLivepeerStream(streamName, record = false);
// Response includes:
{
id: "stream-id",
streamKey: "stream-key-for-webrtc",
playbackId: "playback-id-for-hls",
rtmpIngestUrl: "rtmp://rtmp.livepeer.com/live/{streamKey}",
record: false // Set true only when user enables recording (beta)
}
Recording is disabled by default. When creating a stream, pass record: true only if the broadcaster has explicitly enabled recording in the Go Live modal. Recording is a beta-only feature: the recording toggle in the UI is shown only to users with beta access. This reduces storage and processing costs and keeps recording opt-in.
2. WebRTC Ingestion (WHIP)
The broadcaster uses WebRTC-HTTP Ingestion Protocol (WHIP) to send video. Livepeer uses GeoDNS: a HEAD request to https://livepeer.studio/webrtc/{streamKey} returns a Location header with the regional WHIP endpoint (e.g. https://lax-prod-catalyst-2.lp-playback.studio/webrtc/{streamKey}). Use that URL for SDP POST and for ICE servers (STUN/TURN on the same host). Recommended: use getIngest(streamKey) from @livepeer/react/external or the Broadcast component from @livepeer/react/broadcast.
import { getIngest } from "@livepeer/react/external";
const ingestUrl = getIngest(streamKey); // or resolve regional URL via HEAD redirect
// Regional ICE servers (host from redirect URL)
const host = new URL(ingestUrl).host;
const pc = new RTCPeerConnection({
iceServers: [
{ urls: `stun:${host}` },
{ urls: `turn:${host}`, username: "livepeer", credential: "livepeer" },
],
});
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1080, height: 1920, frameRate: 30 },
audio: true,
});
// Add as sendonly transceivers (WHIP spec)
stream.getVideoTracks()[0] && pc.addTransceiver(stream.getVideoTracks()[0], { direction: "sendonly" });
stream.getAudioTracks()[0] && pc.addTransceiver(stream.getAudioTracks()[0], { direction: "sendonly" });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Wait for ICE gathering (e.g. up to 5s), then POST offer SDP to ingestUrl
const response = await fetch(ingestUrl, {
method: "POST",
headers: { "Content-Type": "application/sdp" },
body: pc.localDescription?.sdp,
});
const answer = await response.text();
await pc.setRemoteDescription({ type: "answer", sdp: answer });
3. Livepeer Processing
Livepeer:
- Receives WebRTC stream (WHIP)
- Transcodes to H.264 with configured or default profiles (240p, 360p, 480p, 720p)
- Generates HLS manifest and optional WebRTC WHEP playback
- Records stream for VOD when
record: true(optionalrecordingSpecwith encoder: H.264, HEVC, VP8, VP9)
4. Playback (HLS or WebRTC WHEP)
Viewers can use Playback Info (GET https://livepeer.studio/api/playback/{playbackId}) to get HLS and WebRTC WHEP URLs, then use the Livepeer Player (prefers WebRTC, fallback HLS) or hls.js for HLS only:
// Direct HLS URL
const playbackUrl = `https://livepeercdn.studio/hls/${playbackId}/index.m3u8`;
// Or fetch playback info (server-side) and use getSrc(playbackInfo) with Player.Root
const playbackInfo = await livepeer.playback.get(playbackId);
// Using hls.js
import Hls from "hls.js";
const hls = new Hls();
hls.loadSource(playbackUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play());
Stream States
State Machine
idle → live → ended
↓ ↓
└──────┘ (can transition back to idle if stream fails)
State Transitions
- idle → live: When broadcaster starts WebRTC connection
- live → ended: When broadcaster stops or connection fails
- idle → ended: Auto-cleanup of stale streams (>1 hour old)
Grace Period
New streams have a 60-second grace period before verification:
const GRACE_PERIOD_MS = 60000;
const startedAt = new Date(stream.started_at).getTime();
const isNewStream = (Date.now() - startedAt) < GRACE_PERIOD_MS;
// New streams shown even if not yet active
if (isNewStream) {
return stream; // Show as live
}
// Older streams verified with Livepeer
const livepeerStream = await getLivepeerStream(stream.stream_id);
if (livepeerStream?.isActive) {
return stream; // Actually live
}
Recording
Recording (Beta, Opt-In)
Streams are not recorded by default. Recording runs only when the stream is created with record: true (e.g. when the broadcaster enables the recording toggle in the Go Live modal; the toggle is shown only to users with beta access). Assets (recordings) are fetched and saved only for streams that had recording enabled.
// Spritz app: createLivepeerStream(name, record = false)
const livepeerStream = await createLivepeerStream(streamName, record === true);
// When record is true: profiles [720p 2Mbps, 480p 1Mbps, 360p 500kbps] are used for recording
Asset Retrieval
After stream ends, assets are processed:
// Get stream assets (recordings)
const assets = await getLivepeerStreamAssets(streamId);
// Asset structure
{
id: "asset-id",
playbackId: "playback-id",
playbackUrl: "https://livepeercdn.studio/hls/{playbackId}/index.m3u8",
downloadUrl: "https://livepeer.studio/api/asset/{id}/download",
status: {
phase: "waiting" | "processing" | "ready" | "failed",
progress?: number
},
videoSpec: {
duration: 1234, // seconds
format: "mp4"
},
size: 12345678 // bytes
}
Viewer Tracking
Real-time Viewer Count
// Increment viewer count
POST /api/streams/:id/viewers
{
"userAddress": "0x..."
}
// Decrement on leave
DELETE /api/streams/:id/viewers
{
"userAddress": "0x..."
}
Implementation
// Track viewers in database
await db
.from("shout_stream_viewers")
.upsert({
stream_id: streamId,
user_address: userAddress,
joined_at: new Date().toISOString()
});
// Count active viewers
const { count } = await db
.from("shout_stream_viewers")
.select("*", { count: "exact", head: true })
.eq("stream_id", streamId)
.gte("joined_at", fiveMinutesAgo);
Stream Chat
Streams can have associated chat:
// Get stream chat
GET /api/streams/:id/chat
// Send chat message
POST /api/streams/:id/chat
{
"userAddress": "0x...",
"message": "Hello!"
}
Technical Specifications
Video Encoding (Livepeer)
- Codec: H.264 (AVC); profile: H264Baseline, H264Main, H264High, or H264ConstrainedHigh
- Transcode profiles: width/height ≥ 128, bitrate ≥ 400; optional quality (0–44), gop, fpsDen (default 1)
- Default profiles (if not specified): 240p (250 kbps), 360p (800 kbps), 480p (1.6 Mbps), 720p (3 Mbps)
- Recording encoders: H.264, HEVC, VP8, VP9 (via recordingSpec)
Audio Encoding
- Codec: AAC (transcoder default)
- Sample Rate: 48 kHz typical
- Bitrate: Set by transcoder
Network Requirements
- Upload: Minimum 2 Mbps for 720p; 5 Mbps recommended for stable broadcast
- Download: Minimum 1 Mbps for viewing
- Latency: ~3–5 s (HLS); sub-second with WebRTC WHEP playback
Transcoding Profiles (Spritz defaults)
- 720p: 1280×720 @ 3 Mbps, quality 23, gop "2", H264Baseline
- 480p: 854×480 @ 1.6 Mbps
- 360p: 640×360 @ 800 kbps
HLS player (or Livepeer Player with Playback Info) selects best quality based on connection.
Error Handling
Connection Failures
// WebRTC connection monitoring
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed') {
// Attempt reconnection
reconnectStream();
}
};
// HLS error handling
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
switch(data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
hls.startLoad(); // Retry
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hls.recoverMediaError(); // Recover
break;
}
}
});
Stream Cleanup
Stale streams are automatically cleaned up:
// Auto-end stale idle streams (>1 hour old)
const createdAt = new Date(stream.created_at).getTime();
const oneHourAgo = Date.now() - 60 * 60 * 1000;
if (stream.status === "idle" && createdAt < oneHourAgo) {
await db
.from("shout_streams")
.update({
status: "ended",
ended_at: new Date().toISOString()
})
.eq("id", stream.id);
}
API Reference
Create Stream
POST /api/streams
{
"userAddress": "0x...",
"title": "My Stream",
"description": "Optional description"
}
// Response
{
"stream": {
"id": "uuid",
"stream_id": "livepeer-stream-id",
"stream_key": "stream-key",
"playback_id": "playback-id",
"playback_url": "https://livepeercdn.studio/hls/{id}/index.m3u8",
"rtmp_url": "rtmp://rtmp.livepeer.com/live/{key}",
"status": "idle"
}
}
Get Streams
GET /api/streams?userAddress=0x...&live=true&limit=20
// Response
{
"streams": [
{
"id": "uuid",
"user_address": "0x...",
"title": "Stream Title",
"status": "live",
"viewer_count": 5,
"playback_url": "...",
"started_at": "2024-01-01T00:00:00Z"
}
]
}
Stream Assets
GET /api/streams/:id/assets
// Response
{
"assets": [
{
"id": "asset-id",
"playback_id": "playback-id",
"playback_url": "...",
"status": {
"phase": "ready",
"progress": 100
},
"duration_seconds": 1234,
"size_bytes": 12345678
}
]
}
Performance Optimization
CDN Caching
- HLS manifests cached for 2 seconds
- Video segments cached for 1 hour
- Thumbnails cached for 24 hours
Adaptive Bitrate
HLS automatically adjusts quality:
- High bandwidth → 720p
- Medium bandwidth → 480p
- Low bandwidth → 360p
Connection Pooling
- Reuse WebRTC connections when possible
- Batch viewer count updates
- Cache stream metadata
Security
Access Control
- Streams are public by default
- Can be restricted to friends (future feature)
- Viewer addresses tracked for analytics
Content Moderation
- Stream titles/descriptions can be moderated
- Chat messages can be filtered
- Report functionality (future feature)