@@ -592,11 +592,7 @@ internal constructor(
592592 requestConfig = {
593593 width = track.dimensions.width
594594 height = track.dimensions.height
595- source = options.source?.toProto() ? : if (track.options.isScreencast) {
596- LivekitModels .TrackSource .SCREEN_SHARE
597- } else {
598- LivekitModels .TrackSource .CAMERA
599- }
595+ source = resolveVideoTrackSource(track, options).toProto()
600596 addAllLayers(videoLayers)
601597
602598 addSimulcastCodecs(
@@ -772,9 +768,7 @@ internal constructor(
772768 transceiver.sortVideoCodecPreferences(finalOptions.videoCodec, capabilitiesGetter)
773769 (track as LocalVideoTrack ).codec = finalOptions.videoCodec
774770
775- val rtpParameters = transceiver.sender.parameters
776- rtpParameters.degradationPreference = finalOptions.degradationPreference
777- transceiver.sender.parameters = rtpParameters
771+ transceiver.applyDegradationPreference(finalOptions.degradationPreference, trackSource)
778772 }
779773
780774 // PublisherTransportObserver.onRenegotiationNeeded() gets triggered automatically
@@ -1318,6 +1312,15 @@ internal constructor(
13181312 transceiver.sortVideoCodecPreferences(newOptions.videoCodec, capabilitiesGetter)
13191313 simulcastTrack.sender = transceiver.sender
13201314
1315+ // The backup codec has its own sender, so it needs the same degradation
1316+ // preference as the primary applied explicitly. Resolve the source the same
1317+ // way the primary publish did, rather than reading it back off the
1318+ // publication, so the two encoders can't disagree.
1319+ transceiver.applyDegradationPreference(
1320+ newOptions.degradationPreference,
1321+ resolveVideoTrackSource(track, newOptions),
1322+ )
1323+
13211324 engine.negotiatePublisher()
13221325 }
13231326 val publishJob = async {
@@ -1552,10 +1555,21 @@ abstract class BaseVideoTrackPublishOptions {
15521555 abstract val backupCodec: BackupVideoCodec ?
15531556
15541557 /* *
1555- * When bandwidth is constrained, this preference indicates which is preferred
1556- * between degrading resolution vs. framerate .
1558+ * Controls how the encoder trades off between resolution and framerate
1559+ * when bandwidth is constrained .
15571560 *
1558- * null value indicates default value (maintain framerate).
1561+ * - MAINTAIN_FRAMERATE: Prioritizes framerate, reduces resolution if needed
1562+ * - MAINTAIN_RESOLUTION: Prioritizes resolution, drops frames if needed
1563+ * - BALANCED: Balances between both
1564+ *
1565+ * If not set (null), the SDK uses defaults based on track source:
1566+ * - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication)
1567+ * - Screen share: MAINTAIN_RESOLUTION (clarity is critical for text/UI)
1568+ * - Other/unknown: BALANCED
1569+ *
1570+ * Note that a preference is always applied to video senders, so leaving this null
1571+ * selects the source-based default above rather than deferring to WebRTC's own
1572+ * implicit choice.
15591573 */
15601574 abstract val degradationPreference: RtpParameters .DegradationPreference ?
15611575
@@ -1757,6 +1771,63 @@ internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean {
17571771private val backupCodecs = listOf (VideoCodec .VP8 .codecName, VideoCodec .H264 .codecName)
17581772private fun isBackupCodec (codecName : String ) = backupCodecs.contains(codecName)
17591773
1774+ /* *
1775+ * Resolves the [Track.Source] a video track is published under: the explicitly requested
1776+ * source if any, otherwise inferred from whether the track is backed by a screencast source.
1777+ */
1778+ private fun resolveVideoTrackSource (track : LocalVideoTrack , options : VideoTrackPublishOptions ): Track .Source {
1779+ return options.source ? : if (track.options.isScreencast) {
1780+ Track .Source .SCREEN_SHARE
1781+ } else {
1782+ Track .Source .CAMERA
1783+ }
1784+ }
1785+
1786+ /* *
1787+ * Returns the appropriate degradation preference for a video track based on its source.
1788+ *
1789+ * - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication)
1790+ * - Screen share: MAINTAIN_RESOLUTION (clarity is critical for reading text/UI)
1791+ * - Other/unknown: BALANCED
1792+ *
1793+ * Any other source means the application declined to declare a motion-vs-detail intent,
1794+ * so this falls back to BALANCED, the preference the WebRTC spec mandates as the default.
1795+ * This deliberately does not defer to libwebrtc's implicit derivation, which keys off the
1796+ * native source's is_screencast flag: custom feeds report is_screencast = false regardless
1797+ * of content (see VideoFrameCapturer/BitmapFrameCapturer), so deferring would resolve to
1798+ * MAINTAIN_FRAMERATE for every custom feed rather than recovering any real intent.
1799+ *
1800+ * This is the intended behavior across LiveKit client SDKs; client-sdk-js
1801+ * (`getDefaultDegradationPreference` in publishUtils.ts) and the Rust SDK
1802+ * (`get_default_degradation_preference` in room/options.rs) use the same mapping.
1803+ */
1804+ private fun getDefaultDegradationPreference (source : Track .Source ): RtpParameters .DegradationPreference {
1805+ return when (source) {
1806+ Track .Source .CAMERA -> RtpParameters .DegradationPreference .MAINTAIN_FRAMERATE
1807+ Track .Source .SCREEN_SHARE -> RtpParameters .DegradationPreference .MAINTAIN_RESOLUTION
1808+ else -> RtpParameters .DegradationPreference .BALANCED
1809+ }
1810+ }
1811+
1812+ /* *
1813+ * Applies [preference] to this transceiver's sender, falling back to the
1814+ * source-based default from [getDefaultDegradationPreference].
1815+ *
1816+ * Degradation preference is a property of the sender, not of the track, so every
1817+ * sender feeding from a track needs it applied separately. In particular the backup
1818+ * codec gets its own transceiver over the same rtc track, and would otherwise let
1819+ * libwebrtc resolve a preference implicitly from the native source's is_screencast
1820+ * flag, diverging from the primary encoder.
1821+ */
1822+ private fun RtpTransceiver.applyDegradationPreference (
1823+ preference : RtpParameters .DegradationPreference ? ,
1824+ source : Track .Source ,
1825+ ) {
1826+ val rtpParameters = sender.parameters
1827+ rtpParameters.degradationPreference = preference ? : getDefaultDegradationPreference(source)
1828+ sender.parameters = rtpParameters
1829+ }
1830+
17601831/* *
17611832 * A handler that processes an RPC request and returns a string
17621833 * that will be sent back to the requester. The payload must
0 commit comments