mirror of
https://github.com/livekit/livekit.git
synced 2026-07-09 21:02:16 +00:00
feat: acquire requested video layer directly at HIGH quality by default (#4595)
* feat: acquire requested video layer directly at HIGH quality by default Two changes that together remove the visible low->high quality ramp for a new subscriber (both publisher-first and subscriber-first join orders): 1. Default a subscriber's initial video quality to HIGH on bind instead of LOW for adaptive stream, so the subscribed max layer is the top layer. Adaptive stream clients can still scale down afterwards based on viewport. 2. On initial layer acquisition the forwarder/selector latch directly onto the allocator's target (the requested top layer) instead of opportunistically latching onto the first lower key frame that arrives. A short initial-acquisition grace aims the target at the requested layer; if it does not show up in time, the target falls back to the highest layer seen so acquisition never stalls. Always on - no configuration flag. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: gate start-at-desired-quality behind EnableStartAtDesiredQuality flag Put the "acquire requested video layer directly at HIGH quality" behavior behind a per-subscriber EnableStartAtDesiredQuality flag (default off, so the original low->high ramp-up is restored unless enabled). Plumbed from config.RTC.EnableStartAtDesiredQuality through ParticipantParams -> SubscribedTrack/DownTrack -> Forwarder -> simulcast selector, gating all three behavior changes: the HIGH default on bind, the forwarder's initial-acquisition grace, and the selector's direct-latch-onto-target. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * remove config. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -104,15 +104,16 @@ func (t *MediaTrackSubscriptions) AddSubscriber(sub types.LocalParticipant, wr *
|
||||
t.subscribedTracksMu.Unlock()
|
||||
|
||||
subTrack, err := NewSubscribedTrack(SubscribedTrackParams{
|
||||
ReceiverConfig: t.params.ReceiverConfig,
|
||||
SubscriberConfig: t.params.SubscriberConfig,
|
||||
Subscriber: sub,
|
||||
MediaTrack: t.params.MediaTrack,
|
||||
AdaptiveStream: sub.GetAdaptiveStream(),
|
||||
TelemetryListener: sub.GetTelemetryListener(),
|
||||
WrappedReceiver: wr,
|
||||
IsRelayed: t.params.IsRelayed,
|
||||
OnDownTrackCreated: t.onDownTrackCreated,
|
||||
ReceiverConfig: t.params.ReceiverConfig,
|
||||
SubscriberConfig: t.params.SubscriberConfig,
|
||||
Subscriber: sub,
|
||||
MediaTrack: t.params.MediaTrack,
|
||||
AdaptiveStream: sub.GetAdaptiveStream(),
|
||||
EnableStartAtDesiredQuality: sub.GetEnableStartAtDesiredQuality(),
|
||||
TelemetryListener: sub.GetTelemetryListener(),
|
||||
WrappedReceiver: wr,
|
||||
IsRelayed: t.params.IsRelayed,
|
||||
OnDownTrackCreated: t.onDownTrackCreated,
|
||||
OnDownTrackClosed: func(subscriberID livekit.ParticipantID) {
|
||||
t.subscribedTracksMu.Lock()
|
||||
delete(t.subscribedTracks, subscriberID)
|
||||
|
||||
@@ -225,6 +225,7 @@ type ParticipantParams struct {
|
||||
EnableRTPStreamRestartDetection bool
|
||||
ForceBackupCodecPolicySimulcast bool
|
||||
DisableTransceiverReuseForE2EE bool
|
||||
EnableStartAtDesiredQuality bool
|
||||
}
|
||||
|
||||
type ParticipantImpl struct {
|
||||
@@ -505,6 +506,10 @@ func (p *ParticipantImpl) GetAdaptiveStream() bool {
|
||||
return p.params.AdaptiveStream
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) GetEnableStartAtDesiredQuality() bool {
|
||||
return p.params.EnableStartAtDesiredQuality
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) GetPacer() pacer.Pacer {
|
||||
return p.TransportManager.GetSubscriberPacer()
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ type SubscribedTrackParams struct {
|
||||
Subscriber types.LocalParticipant
|
||||
MediaTrack types.MediaTrack
|
||||
AdaptiveStream bool
|
||||
EnableStartAtDesiredQuality bool
|
||||
TelemetryListener types.ParticipantTelemetryListener
|
||||
WrappedReceiver *WrappedReceiver
|
||||
IsRelayed bool
|
||||
@@ -154,6 +155,7 @@ func NewSubscribedTrack(params SubscribedTrackParams) (*SubscribedTrack, error)
|
||||
RTCPWriter: params.Subscriber.WriteSubscriberRTCP,
|
||||
DisableSenderReportPassThrough: params.Subscriber.GetDisableSenderReportPassThrough(),
|
||||
SupportsCodecChange: params.Subscriber.SupportsCodecChange(),
|
||||
EnableStartAtDesiredQuality: params.EnableStartAtDesiredQuality,
|
||||
Listener: s,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -212,7 +214,12 @@ func (t *SubscribedTrack) Bound(err error) {
|
||||
t.logger.Debugw("enabling subscriber track settings on bind", "settings", logger.Proto(t.settings))
|
||||
}
|
||||
} else {
|
||||
if t.params.AdaptiveStream {
|
||||
if t.params.EnableStartAtDesiredQuality {
|
||||
// default to HIGH quality so the subscriber acquires the top layer directly instead of
|
||||
// ramping up from a lower layer. adaptive stream clients can still scale down afterwards
|
||||
// based on viewport.
|
||||
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_HIGH}
|
||||
} else if t.params.AdaptiveStream {
|
||||
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_LOW}
|
||||
} else {
|
||||
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_HIGH}
|
||||
|
||||
@@ -403,6 +403,7 @@ type LocalParticipant interface {
|
||||
GetReporter() roomobs.ParticipantSessionReporter
|
||||
GetReporterResolver() roomobs.ParticipantReporterResolver
|
||||
GetAdaptiveStream() bool
|
||||
GetEnableStartAtDesiredQuality() bool
|
||||
ProtocolVersion() ProtocolVersion
|
||||
SupportsSyncStreamID() bool
|
||||
SupportsTransceiverReuse(mt MediaTrack) bool
|
||||
|
||||
@@ -325,6 +325,16 @@ type FakeLocalParticipant struct {
|
||||
getDisableSenderReportPassThroughReturnsOnCall map[int]struct {
|
||||
result1 bool
|
||||
}
|
||||
GetEnableStartAtDesiredQualityStub func() bool
|
||||
getEnableStartAtDesiredQualityMutex sync.RWMutex
|
||||
getEnableStartAtDesiredQualityArgsForCall []struct {
|
||||
}
|
||||
getEnableStartAtDesiredQualityReturns struct {
|
||||
result1 bool
|
||||
}
|
||||
getEnableStartAtDesiredQualityReturnsOnCall map[int]struct {
|
||||
result1 bool
|
||||
}
|
||||
GetEnabledPublishCodecsStub func() []*livekit.Codec
|
||||
getEnabledPublishCodecsMutex sync.RWMutex
|
||||
getEnabledPublishCodecsArgsForCall []struct {
|
||||
@@ -3079,6 +3089,59 @@ func (fake *FakeLocalParticipant) GetDisableSenderReportPassThroughReturnsOnCall
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQuality() bool {
|
||||
fake.getEnableStartAtDesiredQualityMutex.Lock()
|
||||
ret, specificReturn := fake.getEnableStartAtDesiredQualityReturnsOnCall[len(fake.getEnableStartAtDesiredQualityArgsForCall)]
|
||||
fake.getEnableStartAtDesiredQualityArgsForCall = append(fake.getEnableStartAtDesiredQualityArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.GetEnableStartAtDesiredQualityStub
|
||||
fakeReturns := fake.getEnableStartAtDesiredQualityReturns
|
||||
fake.recordInvocation("GetEnableStartAtDesiredQuality", []interface{}{})
|
||||
fake.getEnableStartAtDesiredQualityMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityCallCount() int {
|
||||
fake.getEnableStartAtDesiredQualityMutex.RLock()
|
||||
defer fake.getEnableStartAtDesiredQualityMutex.RUnlock()
|
||||
return len(fake.getEnableStartAtDesiredQualityArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityCalls(stub func() bool) {
|
||||
fake.getEnableStartAtDesiredQualityMutex.Lock()
|
||||
defer fake.getEnableStartAtDesiredQualityMutex.Unlock()
|
||||
fake.GetEnableStartAtDesiredQualityStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityReturns(result1 bool) {
|
||||
fake.getEnableStartAtDesiredQualityMutex.Lock()
|
||||
defer fake.getEnableStartAtDesiredQualityMutex.Unlock()
|
||||
fake.GetEnableStartAtDesiredQualityStub = nil
|
||||
fake.getEnableStartAtDesiredQualityReturns = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityReturnsOnCall(i int, result1 bool) {
|
||||
fake.getEnableStartAtDesiredQualityMutex.Lock()
|
||||
defer fake.getEnableStartAtDesiredQualityMutex.Unlock()
|
||||
fake.GetEnableStartAtDesiredQualityStub = nil
|
||||
if fake.getEnableStartAtDesiredQualityReturnsOnCall == nil {
|
||||
fake.getEnableStartAtDesiredQualityReturnsOnCall = make(map[int]struct {
|
||||
result1 bool
|
||||
})
|
||||
}
|
||||
fake.getEnableStartAtDesiredQualityReturnsOnCall[i] = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeLocalParticipant) GetEnabledPublishCodecs() []*livekit.Codec {
|
||||
fake.getEnabledPublishCodecsMutex.Lock()
|
||||
ret, specificReturn := fake.getEnabledPublishCodecsReturnsOnCall[len(fake.getEnabledPublishCodecsArgsForCall)]
|
||||
|
||||
@@ -312,6 +312,7 @@ type DownTrackParams struct {
|
||||
DisableSenderReportPassThrough bool
|
||||
SupportsCodecChange bool
|
||||
StripPacketTrailer bool
|
||||
EnableStartAtDesiredQuality bool
|
||||
Listener DownTrackListener
|
||||
}
|
||||
|
||||
@@ -463,6 +464,7 @@ func NewDownTrack(params DownTrackParams) (*DownTrack, error) {
|
||||
d.params.Logger,
|
||||
false, // skipReferenceTS
|
||||
false, // disableOpportunisticAllocation
|
||||
d.params.EnableStartAtDesiredQuality,
|
||||
d.rtpStats,
|
||||
)
|
||||
|
||||
@@ -1026,6 +1028,15 @@ func (d *DownTrack) keyFrameRequester() {
|
||||
d.Receiver().SendPLI(layer, false)
|
||||
d.rtpStats.UpdateLayerLockPliAndTime(1)
|
||||
}
|
||||
|
||||
// if the initial-acquisition grace expired without latching the requested layer, force a
|
||||
// re-allocation so the target falls back to the highest layer actually seen (rather than
|
||||
// stalling while waiting for a requested layer that never showed up)
|
||||
if d.forwarder.MaybeExpireAcquireGrace() {
|
||||
if sal := d.getStreamAllocatorListener(); sal != nil {
|
||||
sal.OnAvailableLayersChanged(d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+63
-2
@@ -52,6 +52,15 @@ const (
|
||||
ResumeBehindHighThresholdSeconds = float64(2.0) // 2 seconds
|
||||
LayerSwitchBehindThresholdSeconds = float64(0.05) // 50ms
|
||||
SwitchAheadThresholdSeconds = float64(0.025) // 25ms
|
||||
|
||||
// While a subscriber is acquiring its first layer and the requested (max) layer has not been
|
||||
// seen on the wire yet, aim straight for the requested layer for this long instead of latching
|
||||
// onto a lower layer that is detected first. Avoids a visible low -> high quality ramp,
|
||||
// notably when the subscriber joined before the publisher started (so layers are detected, and
|
||||
// `maxSeen` climbs, gradually). If the requested layer does not show up within this window the
|
||||
// grace expires and forwarding falls back to the highest layer actually seen.
|
||||
// See Forwarder.opportunisticAlloc / withinAcquireGraceLocked / MaybeExpireAcquireGrace.
|
||||
initialLayerAcquisitionGrace = time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -222,6 +231,7 @@ type Forwarder struct {
|
||||
logger logger.Logger
|
||||
skipReferenceTS bool
|
||||
disableOpportunisticAllocation bool
|
||||
enableStartAtDesiredQuality bool
|
||||
rtpStats *rtpstats.RTPStatsSender
|
||||
|
||||
muted bool
|
||||
@@ -230,6 +240,7 @@ type Forwarder struct {
|
||||
|
||||
started bool
|
||||
preStartTime time.Time
|
||||
acquireDeadline int64 // mono nanos; initial-acquisition grace deadline, 0 = inactive
|
||||
extFirstTS uint64
|
||||
lastSSRC uint32
|
||||
lastReferencePayloadType int8
|
||||
@@ -256,6 +267,7 @@ func NewForwarder(
|
||||
logger logger.Logger,
|
||||
skipReferenceTS bool,
|
||||
disableOpportunisticAllocation bool,
|
||||
enableStartAtDesiredQuality bool,
|
||||
rtpStats *rtpstats.RTPStatsSender,
|
||||
) *Forwarder {
|
||||
f := &Forwarder{
|
||||
@@ -264,6 +276,7 @@ func NewForwarder(
|
||||
logger: logger,
|
||||
skipReferenceTS: skipReferenceTS,
|
||||
disableOpportunisticAllocation: disableOpportunisticAllocation,
|
||||
enableStartAtDesiredQuality: enableStartAtDesiredQuality,
|
||||
rtpStats: rtpStats,
|
||||
referenceLayerSpatial: buffer.InvalidLayerSpatial,
|
||||
lastAllocation: VideoAllocationDefault,
|
||||
@@ -276,6 +289,7 @@ func NewForwarder(
|
||||
if f.kind == webrtc.RTPCodecTypeVideo {
|
||||
f.vls.SetMaxTemporal(buffer.DefaultMaxLayerTemporal)
|
||||
}
|
||||
f.vls.SetEnableStartAtDesiredQuality(enableStartAtDesiredQuality)
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -289,10 +303,36 @@ func (f *Forwarder) SetMaxPublishedLayer(maxPublishedLayer int32) bool {
|
||||
}
|
||||
|
||||
f.vls.SetMaxSeenSpatial(maxPublishedLayer)
|
||||
if f.enableStartAtDesiredQuality && !f.vls.GetCurrent().IsValid() {
|
||||
// A (higher) layer just became available while nothing is being forwarded yet.
|
||||
// (Re)start the initial-acquisition grace so the target aims for the requested layer
|
||||
// instead of ramping up gradually as more layers are detected. See opportunisticAlloc.
|
||||
f.acquireDeadline = mono.UnixNano() + initialLayerAcquisitionGrace.Nanoseconds()
|
||||
}
|
||||
f.logger.Debugw("setting max published layer", "layer", maxPublishedLayer)
|
||||
return true
|
||||
}
|
||||
|
||||
// withinAcquireGraceLocked reports whether the initial-acquisition grace window is still open.
|
||||
func (f *Forwarder) withinAcquireGraceLocked() bool {
|
||||
return f.acquireDeadline != 0 && mono.UnixNano() < f.acquireDeadline
|
||||
}
|
||||
|
||||
// MaybeExpireAcquireGrace returns true once when the initial-acquisition grace has expired while
|
||||
// the forwarder is still not streaming any layer. The caller should trigger a re-allocation so the
|
||||
// target falls back from the requested layer to the highest layer actually seen, avoiding a stall
|
||||
// if the requested layer never shows up. The deadline is cleared so it fires at most once.
|
||||
func (f *Forwarder) MaybeExpireAcquireGrace() bool {
|
||||
f.lock.Lock()
|
||||
defer f.lock.Unlock()
|
||||
|
||||
if f.acquireDeadline == 0 || mono.UnixNano() < f.acquireDeadline {
|
||||
return false
|
||||
}
|
||||
f.acquireDeadline = 0
|
||||
return !f.vls.GetCurrent().IsValid()
|
||||
}
|
||||
|
||||
func (f *Forwarder) SetMaxTemporalLayerSeen(maxTemporalLayerSeen int32) bool {
|
||||
f.lock.Lock()
|
||||
defer f.lock.Unlock()
|
||||
@@ -832,6 +872,17 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow
|
||||
return maxTemporal
|
||||
}
|
||||
|
||||
// inAcquireGrace reports that we are acquiring the first layer, the requested layer has not
|
||||
// been seen on the wire yet, and the grace window is still open. While true, aim straight for
|
||||
// the requested layer instead of the highest seen so far, so acquisition does not ramp up
|
||||
// gradually as layers are detected (`maxSeen` climbs). See initialLayerAcquisitionGrace.
|
||||
inAcquireGrace := func(maxSpatial int32) bool {
|
||||
return !currentLayer.IsValid() && maxSeenLayer.Spatial < maxSpatial && f.withinAcquireGraceLocked()
|
||||
}
|
||||
|
||||
// set when opportunisticAlloc aimed the target at the requested layer due to the acquisition
|
||||
// grace (as opposed to overshoot), so the key frame request can be pointed at it too
|
||||
acquireGraceApplied := false
|
||||
opportunisticAlloc := func() {
|
||||
// opportunistically latch on to anything
|
||||
maxSpatial := maxLayer.Spatial
|
||||
@@ -839,8 +890,14 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow
|
||||
maxSpatial = maxSeenLayer.Spatial
|
||||
}
|
||||
|
||||
targetSpatial := min(maxSeenLayer.Spatial, maxSpatial)
|
||||
if inAcquireGrace(maxSpatial) {
|
||||
targetSpatial = maxSpatial
|
||||
acquireGraceApplied = true
|
||||
}
|
||||
|
||||
alloc.TargetLayer = buffer.VideoLayer{
|
||||
Spatial: min(maxSeenLayer.Spatial, maxSpatial),
|
||||
Spatial: targetSpatial,
|
||||
Temporal: getMaxTemporal(),
|
||||
}
|
||||
}
|
||||
@@ -935,7 +992,11 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow
|
||||
} else {
|
||||
// opportunistically latch on to anything
|
||||
opportunisticAlloc()
|
||||
if requestLayerSpatial == buffer.InvalidLayerSpatial {
|
||||
if acquireGraceApplied {
|
||||
// in the acquisition grace, request a key frame for the requested layer we
|
||||
// are waiting for (above what has been seen so far)
|
||||
alloc.RequestLayerSpatial = alloc.TargetLayer.Spatial
|
||||
} else if requestLayerSpatial == buffer.InvalidLayerSpatial {
|
||||
alloc.RequestLayerSpatial = maxLayerSpatialLimit
|
||||
} else {
|
||||
alloc.RequestLayerSpatial = requestLayerSpatial
|
||||
|
||||
@@ -39,6 +39,7 @@ func newForwarder(codec webrtc.RTPCodecCapability, kind webrtc.RTPCodecType) *Fo
|
||||
logger.GetLogger(),
|
||||
true, // skipReferenceTS
|
||||
true, // disableOpportunisticAllocation
|
||||
true, // enableStartAtDesiredQuality
|
||||
nil,
|
||||
)
|
||||
f.DetermineCodec(codec, nil, livekit.VideoLayer_MODE_UNUSED)
|
||||
@@ -2145,3 +2146,44 @@ func TestForwarderGetPaddingVP8(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, marshalledVP8, buf)
|
||||
}
|
||||
|
||||
func TestForwarderInitialAcquisitionGrace(t *testing.T) {
|
||||
f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo)
|
||||
|
||||
// subscriber requested the top spatial layer
|
||||
f.SetMaxSpatialLayer(buffer.DefaultMaxLayerSpatial)
|
||||
f.SetMaxTemporalLayer(buffer.DefaultMaxLayerTemporal)
|
||||
f.SetMaxTemporalLayerSeen(buffer.DefaultMaxLayerTemporal)
|
||||
|
||||
bitrates := Bitrates{
|
||||
{2, 3, 0, 0},
|
||||
{4, 0, 0, 5},
|
||||
{0, 7, 0, 0},
|
||||
}
|
||||
|
||||
// subscriber-first: only layer 1 has been seen so far and nothing is being forwarded yet
|
||||
// (current invalid). this arms the initial-acquisition grace.
|
||||
require.True(t, f.SetMaxPublishedLayer(1))
|
||||
f.lock.RLock()
|
||||
require.True(t, f.withinAcquireGraceLocked())
|
||||
f.lock.RUnlock()
|
||||
|
||||
// during the grace, the target aims straight at the requested layer (2) and requests a key
|
||||
// frame for it, even though only layer 1 has been seen - so acquisition does not ramp up
|
||||
// gradually as higher layers are detected
|
||||
alloc := f.AllocateOptimal([]int32{0, 1}, bitrates, false, false)
|
||||
require.Equal(t, int32(2), alloc.TargetLayer.Spatial)
|
||||
require.Equal(t, int32(2), alloc.RequestLayerSpatial)
|
||||
|
||||
// force the grace to expire while still not streaming: must signal that a re-allocation is
|
||||
// needed so the target can fall back
|
||||
f.acquireDeadline = 1 // a deadline far in the past
|
||||
require.True(t, f.MaybeExpireAcquireGrace())
|
||||
require.False(t, f.MaybeExpireAcquireGrace()) // only fires once
|
||||
|
||||
// after the grace, the target falls back to the highest layer actually seen (1) instead of
|
||||
// stalling while waiting for a requested layer that never showed up
|
||||
alloc = f.AllocateOptimal([]int32{0, 1}, bitrates, false, false)
|
||||
require.Equal(t, int32(1), alloc.TargetLayer.Spatial)
|
||||
require.Equal(t, int32(1), alloc.RequestLayerSpatial)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ type Base struct {
|
||||
|
||||
currentLayer buffer.VideoLayer
|
||||
previousLayer buffer.VideoLayer
|
||||
|
||||
// when set, on initial acquisition latch directly onto the target (requested) layer instead of
|
||||
// opportunistically latching onto the first lower-layer key frame that arrives (see Simulcast.Select)
|
||||
enableStartAtDesiredQuality bool
|
||||
}
|
||||
|
||||
func NewBase(logger logger.Logger) *Base {
|
||||
@@ -66,6 +70,10 @@ func (b *Base) SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerS
|
||||
b.tls = tls
|
||||
}
|
||||
|
||||
func (b *Base) SetEnableStartAtDesiredQuality(enable bool) {
|
||||
b.enableStartAtDesiredQuality = enable
|
||||
}
|
||||
|
||||
func (b *Base) SetMax(maxLayer buffer.VideoLayer) {
|
||||
b.maxLayer = maxLayer
|
||||
}
|
||||
|
||||
@@ -94,14 +94,32 @@ func (s *Simulcast) Select(extPkt *buffer.ExtPacket, layer int32) (result VideoL
|
||||
found := false
|
||||
reason := ""
|
||||
if extPkt.IsKeyFrame {
|
||||
if layer > s.currentLayer.Spatial && layer <= s.targetLayer.Spatial {
|
||||
reason = "upgrading layer"
|
||||
found = true
|
||||
}
|
||||
if s.enableStartAtDesiredQuality && !isActive {
|
||||
// Initial acquisition: latch directly onto the target layer instead of
|
||||
// opportunistically latching onto the first key frame of any lower layer that
|
||||
// happens to arrive first. This avoids a visible low-quality -> high-quality ramp
|
||||
// (e.g. briefly decoding layer 0 before settling on a requested layer 2) for a
|
||||
// subscriber that requested the higher layer.
|
||||
//
|
||||
// The target is chosen by the allocator: during the initial-acquisition grace it
|
||||
// points at the requested layer (so we wait for it); if that layer never shows
|
||||
// up the grace expires and the allocator drops the target to the highest layer
|
||||
// actually seen, so we always end up latching onto a layer that is flowing.
|
||||
if layer == s.targetLayer.Spatial {
|
||||
reason = "acquiring target layer"
|
||||
found = true
|
||||
}
|
||||
} else {
|
||||
// default: opportunistically latch on to / step towards the target layer
|
||||
if layer > s.currentLayer.Spatial && layer <= s.targetLayer.Spatial {
|
||||
reason = "upgrading layer"
|
||||
found = true
|
||||
}
|
||||
|
||||
if layer < s.currentLayer.Spatial && layer >= s.targetLayer.Spatial {
|
||||
reason = "downgrading layer"
|
||||
found = true
|
||||
if layer < s.currentLayer.Spatial && layer >= s.targetLayer.Spatial {
|
||||
reason = "downgrading layer"
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
if found {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package videolayerselector
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pion/rtp"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
func keyFrameOnLayer(spatial, temporal int32) *buffer.ExtPacket {
|
||||
return &buffer.ExtPacket{
|
||||
VideoLayer: buffer.VideoLayer{Spatial: spatial, Temporal: temporal},
|
||||
Packet: &rtp.Packet{},
|
||||
IsKeyFrame: true,
|
||||
}
|
||||
}
|
||||
|
||||
// On initial acquisition the selector must latch directly onto the target layer and ignore
|
||||
// lower-layer key frames that arrive first, so a subscriber requesting the top layer does not
|
||||
// briefly decode a lower layer (a visible quality ramp).
|
||||
func TestSimulcastSelectAcquiresTargetLayerDirectly(t *testing.T) {
|
||||
s := NewSimulcast(logger.GetLogger())
|
||||
s.SetEnableStartAtDesiredQuality(true)
|
||||
s.SetMax(buffer.VideoLayer{Spatial: 2, Temporal: 2})
|
||||
s.SetMaxSeen(buffer.VideoLayer{Spatial: 2, Temporal: 2})
|
||||
s.SetTarget(buffer.VideoLayer{Spatial: 2, Temporal: 2})
|
||||
s.SetRequestSpatial(2)
|
||||
s.SetCurrent(buffer.InvalidLayer)
|
||||
|
||||
// lower-layer key frames arriving first must NOT be latched
|
||||
require.False(t, s.Select(keyFrameOnLayer(0, 2), 0).IsSelected)
|
||||
require.False(t, s.GetCurrent().IsValid())
|
||||
require.False(t, s.Select(keyFrameOnLayer(1, 2), 1).IsSelected)
|
||||
require.False(t, s.GetCurrent().IsValid())
|
||||
|
||||
// the target layer key frame latches directly
|
||||
require.True(t, s.Select(keyFrameOnLayer(2, 2), 2).IsSelected)
|
||||
require.Equal(t, int32(2), s.GetCurrent().Spatial)
|
||||
}
|
||||
|
||||
// Acquisition follows whatever target the allocator set: when the target is lowered (e.g. the
|
||||
// acquisition grace expired and the allocator fell back to the highest layer seen), the selector
|
||||
// latches that lower layer directly. This is the fallback path that prevents a stall when the
|
||||
// originally requested layer never shows up.
|
||||
func TestSimulcastSelectAcquiresLoweredTarget(t *testing.T) {
|
||||
s := NewSimulcast(logger.GetLogger())
|
||||
s.SetEnableStartAtDesiredQuality(true)
|
||||
s.SetMax(buffer.VideoLayer{Spatial: 2, Temporal: 2})
|
||||
s.SetMaxSeen(buffer.VideoLayer{Spatial: 1, Temporal: 2})
|
||||
// allocator dropped the target to the highest layer actually seen
|
||||
s.SetTarget(buffer.VideoLayer{Spatial: 1, Temporal: 2})
|
||||
s.SetRequestSpatial(1)
|
||||
s.SetCurrent(buffer.InvalidLayer)
|
||||
|
||||
require.False(t, s.Select(keyFrameOnLayer(0, 2), 0).IsSelected)
|
||||
require.False(t, s.GetCurrent().IsValid())
|
||||
|
||||
require.True(t, s.Select(keyFrameOnLayer(1, 2), 1).IsSelected)
|
||||
require.Equal(t, int32(1), s.GetCurrent().Spatial)
|
||||
}
|
||||
@@ -37,6 +37,7 @@ type VideoLayerSelector interface {
|
||||
IsOvershootOkay() bool
|
||||
|
||||
SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerSelector)
|
||||
SetEnableStartAtDesiredQuality(enable bool)
|
||||
|
||||
SetMax(maxLayer buffer.VideoLayer)
|
||||
SetMaxSpatial(layer int32)
|
||||
|
||||
Reference in New Issue
Block a user