enProvider();
await this.room.connect(this.config.wsEndpoint, token);
this.room.on(RoomEvent.ConnectionStateChanged, (state) => {
console.info(`[stage] connection state: ${state}`);
});
}
async publishMedia(): Promise<LocalTrackPublication[]> {
const tracks = await navigator.mediaDevices.getUserMedia({
audio: true,
video: this.config.videoConstraints,
});
const publications: LocalTrackPublication[] = [];
for (const track of tracks.getTracks()) {
const pub = await this.room.localParticipant.publishTrack(
new Track(track),
{ simulcast: true }
);
publications.push(pub);
}
this.isLive = true;
return publications;
}
async terminate(): Promise<void> {
if (this.isLive) await this.room.disconnect();
this.isLive = false;
}
}
**Architecture Rationale:** Simulcast and dynacast are enabled by default to allow the SFU to route optimal bitrates to different subscribers without re-encoding. The `StageManager` abstracts connection lifecycle and track publishing, keeping the UI layer decoupled from media engine state.
### Step 2: Credential Issuance
Access tokens must be issued server-side with scoped permissions. The token grants room join, publish, and subscribe rights while binding to a specific identity.
```typescript
// src/auth/TokenIssuer.ts
import { AccessToken } from 'livekit-server-sdk';
interface TokenPayload {
participantId: string;
targetRoom: string;
permissions: { canPublish: boolean; canSubscribe: boolean };
}
export class TokenIssuer {
constructor(
private apiKey: string,
private apiSecret: string
) {}
generate(payload: TokenPayload): string {
const token = new AccessToken(this.apiKey, this.apiSecret, {
identity: payload.participantId,
});
token.addGrant({
roomJoin: true,
room: payload.targetRoom,
canPublish: payload.permissions.canPublish,
canSubscribe: payload.permissions.canSubscribe,
});
return token.toJwt();
}
}
Step 3: RTMP Egress Composition
The SFU composes the active stage layout and relays it as a single RTMP stream. This bridge is critical: it converts real-time UDP media into a TCP-based, timestamped stream that packaging tools can consume.
#!/bin/bash
# scripts/trigger-egress.sh
LIVEKIT_ENDPOINT="${LIVEKIT_WS_URL}"
AUTH_TOKEN="${LIVEKIT_ADMIN_TOKEN}"
TARGET_RTMP="rtmp://ingest-gateway.internal/live/stream-primary"
ROOM_ID="production-stage-01"
curl -s -X POST "${LIVEKIT_ENDPOINT}/twirp/livekit.Egress/StartRoomCompositeEgress" \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{
\"room_name\": \"${ROOM_ID}\",
\"layout\": \"speaker\",
\"audio_only\": false,
\"stream_outputs\": [
{ \"protocol\": \"RTMP\", \"urls\": [\"${TARGET_RTMP}\"] }
]
}"
Architecture Rationale: RTMP remains the most stable bridge between real-time media servers and HTTP packagers. While WebRTC-WHEP is emerging, RTMP egress provides predictable timestamp continuity and widespread toolchain support. The speaker layout ensures the active participant is prioritized in the composed output.
Step 4: CMAF Packaging to LL-HLS
LL-HLS requires fragmented MP4 (CMAF) containers, partial segment delivery, and explicit manifest directives. FFmpeg 6.0+ natively supports the specification when configured correctly.
#!/bin/bash
# scripts/package-lowlatency.sh
INPUT_RTMP="rtmp://ingest-gateway.internal/live/stream-primary"
OUTPUT_DIR="/var/www/edge-cache/live"
MANIFEST_NAME="stream.m3u8"
ffmpeg -y -i "${INPUT_RTMP}" \
-c:v libx264 -preset veryfast -tune zerolatency \
-g 60 -keyint_min 60 -sc_threshold 0 \
-c:a aac -ar 48000 -b:a 128k \
-hls_time 2 \
-hls_playlist_type event \
-hls_segment_type fmp4 \
-hls_fmp4_init_filename "init.mp4" \
-hls_segment_filename "${OUTPUT_DIR}/seg_%05d.m4s" \
-hls_flags independent_segments+program_date_time+append_list \
-hls_list_size 6 \
-master_pl_name "master.m3u8" \
-strftime 1 \
-method PUT \
-http_persistent 1 \
-ldash 1 \
-window_size 6 \
-extra_window_size 3 \
-streaming 1 \
-seg_duration 2 \
-frag_duration 0.2 \
"${OUTPUT_DIR}/${MANIFEST_NAME}"
Architecture Rationale:
-frag_duration 0.2 creates 200ms CMAF fragments, satisfying the LL-HLS spec for partial segment delivery.
-streaming 1 forces immediate fragment flushing instead of waiting for segment closure, which is the primary latency reducer.
-g 60 at 30fps ensures a 2-second GOP, aligning with segment boundaries and preventing keyframe misalignment during live sync.
-ldash 1 enables DASH-compatible CMAF packaging, which LL-HLS leverages for partial segment indexing.
Step 5: Low-Latency Playback
The player must be explicitly configured to avoid aggressive buffering. Default HLS.js settings assume traditional HLS behavior, which negates low-latency packaging.
// src/player/LowLatencyRenderer.ts
import Hls from 'hls.js';
interface PlayerOptions {
manifestUrl: string;
container: HTMLVideoElement;
syncThreshold: number;
maxLatency: number;
}
export class LowLatencyRenderer {
private engine: Hls;
private isFatal = false;
constructor(private options: PlayerOptions) {
this.engine = new Hls({
lowLatencyMode: true,
backBufferLength: 4,
maxLiveSyncPlaybackRate: 1.5,
liveSyncDuration: options.syncThreshold,
liveMaxLatencyDuration: options.maxLatency,
});
}
initialize(): void {
this.engine.loadSource(this.options.manifestUrl);
this.engine.attachMedia(this.options.container);
this.engine.on(Hls.Events.MEDIA_ATTACHED, () => {
this.options.container.play().catch(console.warn);
});
this.engine.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
console.error(`[player] fatal error: ${data.type} / ${data.details}`);
this.isFatal = true;
this.engine.startLoad();
}
});
}
destroy(): void {
if (!this.isFatal) this.engine.destroy();
}
}
Architecture Rationale: liveSyncDuration and liveMaxLatencyDuration define the player's tolerance window. Setting them to 1.5s and 3.5s respectively prevents the buffer from drifting too far behind live while avoiding constant rebuffering on minor network fluctuations. Safari bypasses HLS.js and relies on native LL-HLS support, which respects the EXT-X-SERVER-CONTROL manifest tag automatically generated by FFmpeg.
Pitfall Guide
1. CDN Ignores Chunked Transfer Encoding
Explanation: LL-HLS relies on the server pushing partial segments as they're generated. Legacy CDNs or misconfigured edge proxies buffer the entire segment before serving it, effectively downgrading LL-HLS to standard HLS latency.
Fix: Verify edge behavior with curl -v <manifest-url> 2>&1 | grep -i chunked. Configure the CDN to pass Transfer-Encoding: chunked and disable segment-level buffering. Use Cache-Control: no-cache on manifests and max-age=1 on fragments.
2. GOP Misalignment with Fragment Boundaries
Explanation: If the keyframe interval doesn't align with segment duration, the player cannot safely switch between partial segments, causing decode errors or black frames during live sync.
Fix: Set -g and -keyint_min to match the segment duration in frames (e.g., 60 frames at 30fps for a 2-second segment). Disable scene change detection with -sc_threshold 0 to prevent unexpected keyframe insertion.
3. Over-Tightening Player Sync Thresholds
Explanation: Setting liveSyncDuration below 1.0s forces the player to constantly chase the live edge. Minor network jitter triggers aggressive buffer discards and rebuffering loops.
Fix: Maintain a 1.5sβ2.0s sync window. Allow the player to drift slightly behind live rather than fighting for the absolute edge. Use maxLiveSyncPlaybackRate: 1.5 to gradually catch up instead of abrupt jumps.
4. TURN Server Placement Blind Spots
Explanation: Corporate firewalls and restrictive NATs force WebRTC through TURN relays. If the TURN server is geographically distant from the SFU, it adds 50β150ms of relay latency per hop.
Fix: Deploy TURN nodes in the same availability zone as the SFU. Use iceServers with multiple regional TURN endpoints and prioritize relay candidates only when srflx fails. Monitor RTCPeerConnection.getStats() for candidate pair latency.
5. SFU Egress Buffer Accumulation
Explanation: RTMP egress engines maintain a 1β2 second internal buffer to smooth timestamp irregularities. This buffer compounds with LL-HLS packaging latency, pushing audience latency to 3β4 seconds.
Fix: Accept the protocol boundary. The hybrid model is designed for sub-second stage latency and ~2s audience latency. Do not attempt to force audience-side sub-second delivery; it requires WebRTC-WHEP or HESP, which lack mature CDN support.
6. Manifest Cache Stampede
Explanation: When thousands of players request the updated .m3u8 simultaneously, origin servers experience CPU spikes and 503 errors, breaking live sync for all viewers.
Fix: Implement CDN-level manifest caching with stale-while-revalidate. Use #EXT-X-PRELOAD-HINT to allow players to fetch fragments before the manifest updates. Set hls_list_size to 6β8 to limit manifest growth.
7. Audio/Video Timestamp Drift in Composition
Explanation: RTMP egress composites audio and video streams independently. If source clocks drift, the packaged output develops A/V sync issues that compound over time.
Fix: Enable -program_date_time in FFmpeg to embed wall-clock timestamps. Use -fflags +genpts to regenerate presentation timestamps if the source stream lacks consistent DTS/PTS alignment. Monitor sync drift with ffprobe -show_frames.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| <500 concurrent viewers, high interactivity | Pure WebRTC | SFU capacity is sufficient; avoids packaging overhead | Low (single pipeline) |
| 500β5,000 viewers, mixed interaction | Hybrid (WebRTC + LL-HLS) | Balances stage latency with audience scalability | Moderate (split pipeline) |
| >10,000 viewers, passive consumption | Pure LL-HLS | WebRTC cannot scale; CDN handles distribution efficiently | Low (edge caching) |
| Live auctions, real-time bidding | Hybrid with WebRTC for bidders | Sub-second feedback is mandatory for transaction integrity | High (dedicated relay nodes) |
| Global broadcast, multi-region | Hybrid + regional packagers | Reduces cross-region latency and origin load | High (multi-region infra) |
Configuration Template
# FFmpeg LL-HLS Packaging Profile
# Usage: bash package-llhls.sh <rtmp-input> <output-dir>
INPUT_STREAM="${1:-rtmp://localhost/live/stream}"
OUTPUT_PATH="${2:-/var/www/hls/live}"
SEGMENT_DURATION=2
FRAGMENT_DURATION=0.2
GOP_SIZE=60
FPS=30
ffmpeg -y -i "${INPUT_STREAM}" \
-c:v libx264 -preset veryfast -tune zerolatency \
-g ${GOP_SIZE} -keyint_min ${GOP_SIZE} -sc_threshold 0 -r ${FPS} \
-c:a aac -ar 48000 -b:a 128k \
-hls_time ${SEGMENT_DURATION} \
-hls_playlist_type event \
-hls_segment_type fmp4 \
-hls_fmp4_init_filename "init.mp4" \
-hls_segment_filename "${OUTPUT_PATH}/seg_%05d.m4s" \
-hls_flags independent_segments+program_date_time+append_list \
-hls_list_size 6 \
-master_pl_name "master.m3u8" \
-strftime 1 \
-method PUT \
-http_persistent 1 \
-ldash 1 \
-window_size 6 \
-extra_window_size 3 \
-streaming 1 \
-seg_duration ${SEGMENT_DURATION} \
-frag_duration ${FRAGMENT_DURATION} \
"${OUTPUT_PATH}/stream.m3u8"
// HLS.js Low-Latency Player Configuration
const playerConfig = {
lowLatencyMode: true,
backBufferLength: 4,
maxBufferLength: 6,
maxMaxBufferLength: 10,
liveSyncDuration: 1.5,
liveMaxLatencyDuration: 3.5,
maxLiveSyncPlaybackRate: 1.5,
enableWorker: true,
progressive: false,
lowLatencyMode: true,
testBandwidth: true,
};
Quick Start Guide
- Initialize the SFU Session: Deploy a LiveKit instance or equivalent SFU. Generate a join token with publish/subscribe grants and connect the presenter's browser client with simulcast enabled.
- Trigger RTMP Egress: Call the SFU's egress API to compose the active stage and relay it to an RTMP ingest endpoint. Verify the stream arrives with consistent timestamps.
- Launch FFmpeg Packaging: Execute the LL-HLS packaging script pointing to the RTMP ingest. Confirm the output directory contains
init.mp4, .m4s fragments, and a manifest with EXT-X-SERVER-CONTROL and PART-INF tags.
- Serve via Edge CDN: Point a CDN distribution to the packaging output directory. Configure cache rules to allow chunked transfer for manifests and short TTLs for fragments.
- Attach Player: Initialize the HLS.js renderer with low-latency flags. Validate end-to-end latency using a clock overlay or timestamp comparison between source and playback.