Skip to content

feat: expose scalabilityMode in VideoPublishOptions - #1122

Open
davibittencourtome wants to merge 1 commit into
livekit:mainfrom
davibittencourtome:feat/video-publish-scalability-mode
Open

davibittencourtome wants to merge 1 commit into
livekit:mainfrom
davibittencourtome:feat/video-publish-scalability-mode

Conversation

@davibittencourtome

@davibittencourtome davibittencourtome commented Sep 12, 2026

Copy link
Copy Markdown

Problem

Utils.computeVideoEncodings hardcodes the scalability mode whenever the preferred codec is SVC:

if let videoCodec, videoCodec.isSVC {
    return [RTC.createRtpEncodingParameters(encoding: encoding, scalabilityMode: isScreenShare ? .L1T3 : .L3T3_KEY)]
}

ScalabilityMode is public and RTC.createRtpEncodingParameters already takes it, but no public option feeds it, so a camera publish is always L3T3_KEY. livekit-client exposes the same knob as VideoPublishOptions.scalabilityMode, so an app can be configured on the web and not on iOS.

This is not theoretical for us. Against livekit-server 1.9.12 we see the SFU turn the dependency descriptor off for tracks published with L3T3_KEY and never turn it back on; every subscriber of that publisher then freezes, while the publisher's own preview looks fine and its outbound stats stay healthy. Our web clients work around it by publishing L1T3 (one spatial layer, temporal scalability kept). The iOS apps could not, and the only remaining lever was falling back to VP8, which costs noticeably more bitrate for the same quality.

Fix

Add VideoPublishOptions.scalabilityMode (defaults to nil, which keeps the current behavior exactly) and honor it in the SVC branch:

let scalabilityMode = publishOptions.scalabilityMode ?? (isScreenShare ? .L1T3 : .L3T3_KEY)
return [RTC.createRtpEncodingParameters(encoding: encoding, scalabilityMode: scalabilityMode)]

The option is threaded through the overrideVideoCodec path as well, so it still applies when the backup codec or a republish recomputes the encodings — otherwise the mode would silently revert on the very paths where the freeze shows up.

ScalabilityMode gains Sendable. VideoPublishOptions is a Sendable-conforming final class, so a stored property of a non-Sendable enum does not compile; the enum is a plain Int-backed value, so this is not a behavior change.

The property is included in isEqual and hash, matching the other publish options.

Testing

New VideoEncodingsScalabilityModeTests (swift-testing):

  • svcDefaultsToL3T3KeyForCamera / svcDefaultsToL1T3ForScreenShare — unchanged defaults when the option is nil.
  • explicitScalabilityModeWinsForSvcCameraL1T3 reaches the encoding parameters.
  • explicitScalabilityModeSurvivesCodecOverride — the option still applies on the overrideVideoCodec path.
  • scalabilityModeIsIgnoredForNonSvcCodec — VP8 still produces simulcast layers, none carrying a scalability mode.
  • optionsEqualityIncludesScalabilityMode — equality and hash account for the new property.

Red/green checked: reverting only the Utils+VideoEncodings.swift change makes the two explicit-mode tests fail and leaves the four others passing. Run with swift test --filter VideoEncodingsScalabilityModeTests on macOS.

I did not find an existing issue for this; happy to open one if you prefer to track it separately.

devin-ai-integration[bot]

This comment was marked as resolved.

@davibittencourtome
davibittencourtome force-pushed the feat/video-publish-scalability-mode branch from 308d540 to 15c1b0a Compare September 12, 2026 17:41
devin-ai-integration[bot]

This comment was marked as resolved.

@davibittencourtome
davibittencourtome force-pushed the feat/video-publish-scalability-mode branch 2 times, most recently from 6aaea2c to 1ccb46d Compare September 12, 2026 18:12
computeVideoEncodings hardcodes L3T3_KEY for camera whenever the
preferred codec is SVC, so an application has no way to choose the
mode. livekit-client exposes exactly this knob as
VideoPublishOptions.scalabilityMode.

It matters against SFUs that turn off the dependency descriptor for a
track published with L3T3_KEY: every subscriber of that publisher then
freezes until the track is republished, and the publisher sees nothing
wrong on its side. Publishing a single spatial layer (L1T3) avoids the
whole situation, and is already what the JS client allows.

Add VideoPublishOptions.scalabilityMode — nil keeps the current
behavior — and honor it in the SVC branch of computeVideoEncodings,
including the overrideVideoCodec path used by backup codecs and
republish.

Screen share keeps forcing L1T3 regardless of the option: WebRTC does
not publish SVC screen share with multiple spatial layers, so letting a
shared VideoPublishOptions override it would emit no frames at all.

The initializer that existed before this property is preserved as a
convenience init with its original Objective-C selector, forwarding
scalabilityMode: nil, so binaries compiled against the previous
framework keep resolving it — same pattern as AudioCaptureOptions.

ScalabilityMode gains Sendable, required by the property on a
Sendable-conforming class.
@davibittencourtome
davibittencourtome force-pushed the feat/video-publish-scalability-mode branch from 1ccb46d to 55f7d06 Compare September 12, 2026 18:20
/// configuration `livekit-client` (JS) exposes as `scalabilityMode: 'L1T3'`. Motivation:
/// with `L3T3_KEY` some SFU versions turn the dependency descriptor off for the track
/// and subscribers freeze; a single spatial layer avoids it.
public let scalabilityMode: ScalabilityMode?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScalabilityMode? (an Optional of an @objc enum) is not Objective-C-representable, so @objcMembers silently drops both this property and the new designated initializer from the generated header — verified with -emit-objc-header: @interface VideoPublishOptions has no scalabilityMode and exactly one init, the legacy 10-arg one — which also means the convenience init below buys nothing, and modelling this as a non-optional enum with an .auto case (the pattern the sibling degradationPreference already uses) would fix both at once.


/// Scalability mode used when publishing the **camera** with an SVC codec (VP9/AV1).
/// `nil` keeps the default, `L3T3_KEY`. Ignored for non-SVC codecs (VP8/H264), which use
/// simulcast instead, and ignored for screen share, which always uses `L1T3` because

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This invariant can't hold on the backup-codec path: LocalParticipant.publish(additionalVideoCodec:) (LocalParticipant.swift:520) calls computeVideoEncodings without isScreenShare:, so a screen-share track with a preferredBackupCodec gets its backup stream computed as camera — camera encoding, camera simulcast layers and camera presets, not just the scalability mode — unlike line 648, which correctly passes isScreenShare: track.source == .screenShareVideo.

// there — a multi-spatial mode publishes no frames at all. An explicit
// `publishOptions.scalabilityMode` only applies to camera encodings (parity with
// livekit-client `scalabilityMode`).
let scalabilityMode: ScalabilityMode = isScreenShare ? .L1T3 : (publishOptions.scalabilityMode ?? .L3T3_KEY)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since videoCodec is overrideVideoCodec ?? publishOptions.preferredCodec (line 30), an app that sets scalabilityMode but leaves preferredCodec at its nil default never reaches this branch even when SDP negotiates VP9 — the option is a complete no-op, and the only log here is .debug and prints the resolved mode rather than the fact that the caller's value was dropped, so a .warning when publishOptions.scalabilityMode is non-nil and discarded would save someone a long debugging session.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants