Compositing Multi-Party Recordings Server-Side

Compositing turns a set of independent, separately-timed participant streams into a single synchronized video file with a chosen layout. This guide is part of the Server-Side Recording & Composition guide, and it resolves one concrete problem: how to build the compositor and mixer stage so a four-to-twelve-person call produces a watchable, in-sync recording even as people join and leave mid-call. The decisions that matter are the layout policy (fixed grid vs active-speaker), the synchronization source (RTP timestamps, never arrival time), and how the pipeline survives roster changes without breaking the encoder’s timeline.

Context & Trade-offs

Two layout policies dominate. A grid assigns every active participant an equal tile — predictable, fair, and trivial to lay out, but tiles shrink as the room grows and a 12-person grid wastes most pixels on idle faces. An active-speaker layout promotes whoever is talking to a large tile with the rest as thumbnails or hidden — far better use of resolution, but it requires audio-energy detection and debouncing, and it flickers badly if you switch on every transient sound.

Dimension Grid Active-speaker
Layout cost trivial audio-energy + debounce
Pixel efficiency poor at scale (12+) high
Flicker risk none high without 1–2 s debounce
Best fit small meetings, fairness webinars, large rooms
Grid versus active-speaker pixel allocation Two identical output canvases. The grid splits the pixel budget into twelve equal tiles of about 320 by 180. The active-speaker layout spends most of the budget on one large tile with three small thumbnails down the right edge. Grid — 12 equal tiles every face ≈ 320×180, idle or not Active speaker — weighted budget follows who is talking promoted speaker ≈ 960×540, full layer thumbnails ride the low spatial layer Layout cost: trivial, no audio analysis Pixel efficiency: poor once past 12 tiles Flicker: none — tiles never move Layout cost: RMS energy + 1.5 s debounce Pixel efficiency: high, scales past 12 Flicker: severe if the debounce is skipped
The same 1280×720 budget split evenly across twelve tiles versus weighted toward the speaker.

Synchronization is the harder constraint. Every stream carries its own RTP timestamp in its own codec clock — 90 kHz for video, 48 kHz for Opus — and packets arrive reordered and jittered. If you align media by arrival time, network jitter leaks straight into lip-sync, and a participant on a worse connection drifts visibly behind their own audio. The fix is to normalize every input to one monotonic output clock by its RTP timestamp (using RTCP sender reports to map each stream’s RTP clock to a common wall-clock reference), then sample all streams against that single timeline. The sync budget is roughly 250 ms of A/V drift end-to-end; beyond that, lip-sync is perceptibly broken. The clock-mapping arithmetic itself — turning sender-report pairs into an offset you can sample against — is worked through step by step in Synchronising Audio and Video in Recordings.

Roster churn is the third constraint. Participants join and leave at arbitrary times, and the compositor must recompute layout on each change while keeping the encoder’s presentation timestamps strictly monotonic — a discontinuity in PTS produces a file that seeks wrong or refuses to play. A join mid-call also means a new stream whose RTP timestamp base is unrelated to everyone else’s; you cannot assume two senders started their clocks together, which is exactly why each stream must be mapped to the common reference via its own RTCP sender report before it ever reaches a tile. A leave is simpler but still requires care: drop the participant from the layout and stop sampling their queues, but never rewind or pause the shared output clock, or the encoder will emit a duplicate-PTS frame the muxer rejects.

There is also a resolution-versus-cost trade-off baked into the layout choice. A grid at 1280×720 splits a fixed pixel budget across all tiles, so each face in a 12-person call lands at roughly 320×180 regardless of the source quality — re-encoding 12 high-resolution decodes down to thumbnails wastes most of the decode work. Active-speaker layouts spend the budget where attention is, decoding the promoted tile at full resolution and the thumbnails cheaply, which is why large-room recordings almost always favor speaker layouts. When senders use simulcast, subscribe the compositor to the layer that matches each tile’s on-screen size: full resolution for the active speaker, a low spatial layer for thumbnails. That coupling to Simulcast-Aware Forwarding keeps the decoder from wasting cycles shrinking a 720p stream into a 180px thumbnail. Promoting a thumbnail to the big tile means re-subscribing to a higher layer mid-recording, so the same keyframe-timing rules described in Switching Layers Without Visible Glitches apply to the compositor’s inputs — a layer switch that lands without a keyframe bakes a block of corruption straight into the file.

Minimal Runnable Implementation

// ffmpeg/GStreamer-style compositor + mixer stage. Inputs are decoded YUV frames and
// PCM buffers, each tagged with an RTP timestamp; output is one synced A/V timeline.
const OUTPUT_FPS = 30;
const FRAME_MS = 1000 / OUTPUT_FPS;          // 33.3 ms per composite frame
const SAMPLE_RATE = 48000;                   // audio mix clock
const SPEAKER_DEBOUNCE_MS = 1500;            // hold active speaker 1.5 s before switching

class Compositor {
  constructor(encoder) {
    this.encoder = encoder;
    this.participants = new Map();           // id -> { videoQueue, audioQueue, lastFrame, rmsEwma }
    this.outputPtsMs = 0;                    // single monotonic output clock — never rewinds
    this.activeSpeakerId = null;
    this.lastSwitchMs = 0;
  }

  // Roster change: recompute layout, but DO NOT touch outputPtsMs. New joiners get a
  // tile from the next frame; leavers are dropped. The timeline stays continuous.
  setRoster(ids) {
    for (const id of ids) if (!this.participants.has(id)) this.participants.set(id, newPeer());
    for (const id of [...this.participants.keys()]) if (!ids.includes(id)) this.participants.delete(id);
    this.layout = computeLayout([...this.participants.keys()], this.mode); // 'grid' | 'speaker'
  }

  // Called once per output frame on a fixed 33.3 ms timer.
  tick() {
    const pts = this.outputPtsMs;
    this.pickActiveSpeaker(pts);
    this.canvas.clear();

    for (const [id, p] of this.participants) {
      // latest decoded frame at-or-before this PTS; hold last frame across sender gaps
      const frame = p.videoQueue.latestAtOrBefore(pts) ?? p.lastFrame;
      if (!frame) continue;
      const rect = this.layout[id];          // tile for grid, or large/thumb for speaker
      this.canvas.drawScaled(frame, rect.x, rect.y, rect.w, rect.h);
      p.lastFrame = frame;
    }
    this.encoder.pushVideo(this.canvas.snapshot(), pts);
    this.mixAudio(pts);
    this.outputPtsMs += FRAME_MS;            // advance the one shared clock
  }

  pickActiveSpeaker(nowMs) {
    let loudest = null, max = -Infinity;
    for (const [id, p] of this.participants) {
      p.rmsEwma = 0.8 * p.rmsEwma + 0.2 * p.audioQueue.rmsAround(nowMs); // smoothed energy
      if (p.rmsEwma > max) { max = p.rmsEwma; loudest = id; }
    }
    // debounce: only switch the big tile after the floor has held long enough
    if (loudest && loudest !== this.activeSpeakerId && nowMs - this.lastSwitchMs > SPEAKER_DEBOUNCE_MS) {
      this.activeSpeakerId = loudest;
      this.lastSwitchMs = nowMs;
      this.layout = computeLayout([...this.participants.keys()], 'speaker', this.activeSpeakerId);
    }
  }

  mixAudio(pts) {
    const out = new Float32Array(SAMPLE_RATE * FRAME_MS / 1000); // one frame of samples
    for (const [, p] of this.participants) {
      const pcm = p.audioQueue.samplesAt(pts, out.length); // resampled to 48k, aligned by RTP ts
      if (pcm) for (let i = 0; i < out.length; i++) out[i] += pcm[i]; // sum sources
      // DTX gap → samplesAt returns null → contributes silence, timeline still advances
    }
    softLimit(out);                          // prevent clipping on simultaneous speech
    this.encoder.pushAudio(out, pts);
  }
}

Read left to right, the loop is a five-stage pipeline: every sender’s RTP arrives on its own codec clock, gets mapped onto one common reference through its RTCP sender reports, lands in per-stream video and audio queues, and is then sampled once per 33.3 ms tick by the compositor and the mixer — which share the single outputPtsMs that the encoder and muxer see.

Compositor pipeline driven by one output clock Three participant RTP inputs feed a clock-normalization stage keyed on RTCP sender reports, which fills separate 90 kHz video queues and 48 kHz audio queues. A 33.3 millisecond tick drives the tile compositor and the audio mixer, and both push frames tagged with the same monotonic presentation timestamp into the encoder and muxer. Compositor pipeline — five stages, one clock arrival time is used nowhere; every sample is placed by normalized RTP timestamp Peer A RTP 90 kHz / 48 kHz Peer B RTP own clock base Peer C RTP joined at 12.4 s Clock map RTCP sender report pairs RTP ts → shared wall clock stale SR here = drift downstream Video queues decoded YUV + held last frame Audio queues PCM at 48 kHz DTX gap = silence Compositor tick draw tiles @ 33.3 ms layout from roster Mixer sum sources then soft limit Encoder + muxer one PTS series for both tracks strictly monotonic MP4 / WebM out outputPtsMs advances exactly 33.3 ms per tick — for every stage, forever. setRoster() rebuilds the tile map only; it never rewinds, pauses or resets the clock.
Every stage reads the same monotonic output clock; roster changes touch layout, not timing.

Reproduction Steps & Debugging Log Patterns

  1. Start a 3-participant recording with a grid layout; confirm three equal tiles render and outputPtsMs advances by exactly 33.3 ms per tick.
  2. Have a fourth participant join mid-call; verify setRoster recomputes a 2×2 layout and the encoder PTS does not jump or reset.
  3. Switch to active-speaker mode and have two people alternate talking; watch the debounce hold each speaker for ~1.5 s before promoting the next.
  4. Drop a participant abruptly (kill their connection); confirm the layout collapses to the remaining set and audio keeps mixing without a stall.
  5. Run ffprobe on the finalized file and compare audio vs video duration at start, middle, and end.

Expected healthy log:

// roster=[a,b,c] layout=grid-3 pts=0ms
// roster=[a,b,c,d] layout=grid-4 pts=12440ms   // joined mid-call, PTS continuous
// activeSpeaker: a -> b  (held 1520ms)          // debounce respected
// roster=[a,c,d] layout=grid-3 pts=48900ms      // b left, no PTS jump
// ffprobe: video=600.10s audio=600.02s drift=80ms  // under the 250ms budget

A broken sync run shows the drift line climbing over time — drift=90ms at the start growing to drift=420ms by the end — which means an input was aligned on arrival time, or the audio timeline advanced on packet arrival and compressed during DTX silence. A layout that flickers between speakers every few frames means the debounce window is too short or RMS is not being smoothed. If the file refuses to seek or a player reports a negative timestamp after a mid-call join, the roster handler reset outputPtsMs instead of leaving it untouched — log the PTS immediately before and after every setRoster call and confirm it only ever increases.

Drift over a ten-minute recording, healthy versus arrival-time aligned A drift axis from zero to five hundred milliseconds over a six hundred second recording. The healthy trace stays flat near eighty milliseconds. The arrival-time-aligned trace climbs steadily from ninety to four hundred and twenty milliseconds, crossing the two hundred and fifty millisecond budget line partway through. Callouts mark a mid-call join, a leave, and the final ffprobe check. A/V drift across one 600 s recording flat is healthy; a monotonically climbing trace means an input was aligned on arrival time 500 ms 250 ms A/V sync budget 0 ms healthy — holds ~80 ms arrival-time aligned — 420 ms 0 s 120 s 240 s 360 s 480 s 600 s join d @ pts 12 440 ms layout → grid-4, PTS continuous leave b @ pts 48 900 ms layout → grid-3, mixer never stalls ffprobe: 600.10 s v / 600.02 s a drift 80 ms — inside budget
The failing run separates from the healthy one gradually — sample drift every second, not just at the end.

To isolate which input drifted, log per-participant the offset between each stream’s normalized RTP timestamp and the current outputPtsMs once per second. A healthy stream holds a small, stable offset; the one that desyncs shows its offset walking away monotonically, which points straight at a sender whose RTCP sender reports were missing or stale when its clock was mapped. Emit that offset as a per-participant gauge rather than a log line and it alarms the same way the counters in Exporting SFU Metrics to Prometheus and Grafana do, so you catch a walking offset during the call instead of after the file is finalized. For audio specifically, count the silence samples the mixer padded for DTX gaps versus the real samples decoded — if padding dominates for a participant who was clearly speaking, their Opus DTX gaps are being mishandled and the timeline is compressing; the client-side counterpart of that measurement is covered in Measuring Audio Latency and Jitter Buffer Delay.

Common Implementation Mistakes

FAQ

How do I keep lip-sync across participants with different network quality?

Normalize every stream to one output clock using its RTP timestamps and RTCP sender reports, never arrival time. A slow participant’s jitter is then absorbed by their own jitter buffer instead of leaking into the shared timeline, and all tiles stay within the ~250 ms A/V budget regardless of individual connection quality.

Should the compositor run on the SFU or a separate node?

Separate. Live decode-plus-encode is CPU-heavy and must not steal cycles from packet forwarding on the Selective Forwarding Unit Design; an overloaded SFU drops media for live participants. The compositor consumes the same forwarded streams a subscriber would, so it can run anywhere the streams can be delivered.

Can I do this with ffmpeg or GStreamer instead of hand-writing the loop?

Yes — both express this as a filter graph (xstack/overlay for the grid, amix for audio in ffmpeg; compositor and audiomixer in GStreamer). The same rules apply: feed inputs tagged with normalized timestamps, keep one output clock, and rebuild the graph on roster changes while holding PTS continuous. Device-side capture hygiene from Audio/Video Track Management still governs the quality of what enters the mix.

Related: return to Server-Side Recording & Composition, and cross-reference Selective Forwarding Unit Design and Simulcast-Aware Forwarding for how the source streams are forwarded to the recorder.