From 876f4012e98dcb30dab8016418f34eae8309f7cd Mon Sep 17 00:00:00 2001 From: max Date: Tue, 5 May 2026 18:15:53 -0700 Subject: [PATCH] PublishTrack: populate SimulcastCodecs with primary codec for video tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, PublishTrack only set AddTrackRequest.SimulcastCodecs when an explicit backup codec was supplied via WithBackupCodec. With no backup codec, the request omitted SimulcastCodecs entirely, so the server's TrackPublishedResponse echoed back an empty TrackInfo.MimeType (and empty TrackInfo.Codecs[0].MimeType — see comment at publication.go:78-105). trackPublicationBase.MimeType() then returned "" for any single-stream PublishTrack video track, which made setPublishingCodecsQuality (publication.go:485-528) fail the primary-codec check (strings.HasSuffix("", subscribedCodec.Codec) is always false for any non-empty codec). Every subscribed codec fell into the backup branch and emitted "subscriber requested backup codec but no track found", and the primary track's per-quality muted state was never toggled. For default codecs like H.264 the SFU still forwarded RTP from defaults, so the bug was invisible. For non-default codecs like H.265 the publisher's track stayed unconfigured, the SFU never started forwarding, and subscribers were stuck on track.muted=true with zero bytes received even though SDP negotiation completed cleanly. The fix adds an else-if to PublishTrack that populates SimulcastCodecs[0] with the primary codec MimeType + track CID for video tracks with a known primary codec MIME, mirroring what PublishSimulcastTrack already does at localparticipant.go:300. A new integration test, TestPublishTrackSingleStreamCodecMime, covers H.264, H.265, and VP8 single-stream PublishTrack and asserts that: 1. trackPub.MimeType() returns the codec MIME after publish 2. A subscriber actually receives the track (regression for H.265 stuck-muted) Reported in livekit/livekit-cli#837, where lk room join --publish h265://host:port produced the symptom; the bug is in the SDK rather than the CLI. --- integration_test.go | 81 +++++++++++++++++++++++++++++++++++++++++++++ localparticipant.go | 13 ++++++++ 2 files changed, 94 insertions(+) diff --git a/integration_test.go b/integration_test.go index fe48e5b7..8cdb2072 100644 --- a/integration_test.go +++ b/integration_test.go @@ -563,3 +563,84 @@ func TestSimulcastCodec(t *testing.T) { } } + +// TestPublishTrackSingleStreamCodecMime verifies that a single-stream +// (no backup codec) PublishTrack populates AddTrackRequest.SimulcastCodecs +// with the primary codec's MimeType, so trackPublicationBase.MimeType() +// returns a non-empty value and setPublishingCodecsQuality classifies the +// subscribed codec as primary instead of falling into the backup branch. +// +// Regression test for: single-stream H.265 publishes negotiated SDP cleanly +// but the subscriber's track stayed muted with zero RTP forwarded, because +// the publisher SDK was emitting "subscriber requested backup codec but no +// track found" for every subscribed codec. +func TestPublishTrackSingleStreamCodecMime(t *testing.T) { + cases := []struct { + name string + codec webrtc.RTPCodecCapability + }{ + { + name: "h264", + codec: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + }, + }, + { + name: "h265", + codec: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH265, + ClockRate: 90000, + }, + }, + { + name: "vp8", + codec: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeVP8, + ClockRate: 90000, + }, + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + pub, err := createAgent(t.Name(), nil, "publisher") + require.NoError(t, err) + defer pub.Disconnect() + + trackPub := pubNullTrack(t, pub, "single_codec", c.codec) + t.Log("published single-stream track:", trackPub.SID(), "codec:", trackPub.MimeType()) + + // Wait briefly for the server's TrackPublishedResponse to populate info. + require.Eventually(t, func() bool { + return trackPub.MimeType() != "" + }, 5*time.Second, 50*time.Millisecond, "MimeType should be populated from server response") + + require.Equal(t, c.codec.MimeType, trackPub.MimeType(), + "single-stream %s publish should report its codec MimeType (regression for H.265 single-stream)", c.name) + + subscribed := make(chan struct{}, 1) + sub, err := createAgent(t.Name(), &RoomCallback{ + ParticipantCallback: ParticipantCallback{ + OnTrackSubscribed: func(track *webrtc.TrackRemote, publication *RemoteTrackPublication, rp *RemoteParticipant) { + t.Log("subscribed:", track.Codec().MimeType, publication.SID()) + require.Equal(t, publication.SID(), trackPub.SID()) + require.Equal(t, c.codec.MimeType, track.Codec().MimeType) + select { + case subscribed <- struct{}{}: + default: + } + }, + }, + }, "subscriber") + require.NoError(t, err) + defer sub.Disconnect() + + select { + case <-subscribed: + case <-time.After(10 * time.Second): + t.Fatalf("subscriber never received %s track — SFU likely never started forwarding RTP", c.name) + } + }) + } +} diff --git a/localparticipant.go b/localparticipant.go index d634990e..9b1dfa36 100644 --- a/localparticipant.go +++ b/localparticipant.go @@ -173,6 +173,19 @@ func (p *LocalParticipant) PublishTrack(track webrtc.TrackLocal, opts *TrackPubl } else { p.log.Warnw("backup codec publication with encryption is not supported, ignoring backup codec", nil) } + } else if kind == TrackKindVideo && primaryCodec.MimeType != "" { + // TrackPublishedResponse does not include a top-level TrackInfo.MimeType, + // but the server echoes AddTrackRequest.SimulcastCodecs into + // TrackInfo.Codecs. Populate the primary codec here so MimeType() + // (publication.go:78-105) returns a non-empty value, which lets + // setPublishingCodecsQuality (publication.go:485-528) correctly + // classify subscribed video codecs as primary rather than backup. + req.SimulcastCodecs = []*livekit.SimulcastCodec{ + { + Codec: primaryCodec.MimeType, + Cid: track.ID(), + }, + } } if err := p.engine.SendAddTrack(req); err != nil { return nil, err