diff --git a/Apps/VoidDisplay/Resources/Localizable.xcstrings b/Apps/VoidDisplay/Resources/Localizable.xcstrings index 127aa76..4ba4ead 100644 --- a/Apps/VoidDisplay/Resources/Localizable.xcstrings +++ b/Apps/VoidDisplay/Resources/Localizable.xcstrings @@ -323,13 +323,13 @@ } } }, - "Automatic balances H.264 compatibility, clarity, and encoder load." : { + "Automatic and Smooth send AV1 at source resolution on LAN. Actual frame rate follows encoder capability." : { "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "自动模式会平衡 H.264 兼容性、清晰度与编码负载。" + "value" : "自动和流畅会在局域网按源分辨率发送 AV1。实际帧率取决于编码能力。" } } } @@ -2916,16 +2916,6 @@ } } }, - "This Mac's WebRTC stack did not expose H.264 video encoding." : { - "localizations" : { - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "本机 WebRTC 链路未暴露 H.264 视频编码能力。" - } - } - } - }, "Technical Details" : { "extractionState" : "stale", "localizations" : { @@ -3058,6 +3048,17 @@ } } }, + "This Mac's WebRTC stack did not expose AV1 video encoding." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本机 WebRTC 链路未暴露 AV1 视频编码能力。" + } + } + } + }, "This resolution mode already exists." : { "extractionState" : "stale", "localizations" : { diff --git a/Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj b/Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj index 3c9c240..7577047 100644 --- a/Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj +++ b/Apps/VoidDisplay/VoidDisplay.xcodeproj/project.pbxproj @@ -264,6 +264,7 @@ "$(ROOT_DIR)/Tools/VoidDisplayRelay/go.mod", "$(ROOT_DIR)/Tools/VoidDisplayRelay/go.sum", "$(ROOT_DIR)/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main.go", + "$(ROOT_DIR)/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main_test.go", "$(ROOT_DIR)/Tools/VoidDisplayRelay/internal/relay/server.go", "$(ROOT_DIR)/Tools/VoidDisplayRelay/internal/relay/server_test.go", ); diff --git a/Sources/VoidDisplayCapture/Services/CapturePreviewDiagnosticsSession.swift b/Sources/VoidDisplayCapture/Services/CapturePreviewDiagnosticsSession.swift index de3edb8..c3cbcc8 100644 --- a/Sources/VoidDisplayCapture/Services/CapturePreviewDiagnosticsSession.swift +++ b/Sources/VoidDisplayCapture/Services/CapturePreviewDiagnosticsSession.swift @@ -64,6 +64,9 @@ package final class UITestCapturePreviewSession: @unchecked Sendable, DisplayCap private final class DiagnosticsShareFrameConsumer: DisplayShareFrameConsumer { nonisolated var hasDemand: Bool { false } + package nonisolated func updateSourceVideoSpec(_ spec: SourceVideoSpec) { + _ = spec + } package nonisolated func updatePerformanceMode(_ mode: CapturePerformanceMode) { _ = mode } diff --git a/Sources/VoidDisplayCapture/Services/DisplayCaptureRegistry.swift b/Sources/VoidDisplayCapture/Services/DisplayCaptureRegistry.swift index 54c0d9e..b0bc007 100644 --- a/Sources/VoidDisplayCapture/Services/DisplayCaptureRegistry.swift +++ b/Sources/VoidDisplayCapture/Services/DisplayCaptureRegistry.swift @@ -105,6 +105,10 @@ package final class DisplayPreviewSubscription: Sendable { private final class NoopDisplayShareFrameConsumer: DisplayShareFrameConsumer { nonisolated var hasDemand: Bool { false } + package nonisolated func updateSourceVideoSpec(_ spec: SourceVideoSpec) { + _ = spec + } + package nonisolated func updatePerformanceMode(_ mode: CapturePerformanceMode) { _ = mode } diff --git a/Sources/VoidDisplayCapture/Services/DisplayCaptureSession.swift b/Sources/VoidDisplayCapture/Services/DisplayCaptureSession.swift index bbc0272..45aa46f 100644 --- a/Sources/VoidDisplayCapture/Services/DisplayCaptureSession.swift +++ b/Sources/VoidDisplayCapture/Services/DisplayCaptureSession.swift @@ -311,6 +311,7 @@ package final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSe nonisolated private let metrics: DisplayCaptureMetricsStore nonisolated private let streamConfigurationCoordinator: DisplayCaptureStreamConfigurationCoordinator nonisolated private let demandDriver: DisplayCaptureDemandDriver + nonisolated private let sourceVideoSpec: SourceVideoSpec nonisolated init( display: SCDisplay, @@ -338,6 +339,8 @@ package final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSe let filter = try await Self.makeContentFilter(display: display) self.stream = SCStream(filter: filter, configuration: config, delegate: output) self.shareFrameConsumer = makeShareFrameConsumer() + self.sourceVideoSpec = captureSizeContext.sourceVideoSpec + self.shareFrameConsumer.updateSourceVideoSpec(captureSizeContext.sourceVideoSpec) let metrics = DisplayCaptureMetricsStore() self.metrics = metrics let streamConfigurationCoordinator = DisplayCaptureStreamConfigurationCoordinator( @@ -403,6 +406,7 @@ package final class DisplayCaptureSession: @unchecked Sendable, DisplayCaptureSe } package nonisolated func setDemand(_ demand: DisplayCaptureDemandSnapshot) async throws { + shareFrameConsumer.updateSourceVideoSpec(sourceVideoSpec) shareFrameConsumer.updatePerformanceMode(demand.performanceMode) try await demandDriver.setDemand(demand) } @@ -466,8 +470,12 @@ package extension DisplayCaptureSession { } nonisolated static func clampedPreviewFramesPerSecond(for refreshRate: Double) -> Int { + sourceFramesPerSecond(for: refreshRate) + } + + nonisolated static func sourceFramesPerSecond(for refreshRate: Double) -> Int { let normalizedRefreshRate = refreshRate > 0 ? refreshRate : 60.0 - return max(1, Int(min(normalizedRefreshRate, 60.0).rounded())) + return max(1, Int(normalizedRefreshRate.rounded())) } nonisolated private static func makeStreamConfigurationState( @@ -482,10 +490,11 @@ package extension DisplayCaptureSession { for: initialProfile, performanceMode: initialPerformanceMode ) - let previewFramesPerSecond = clampedPreviewFramesPerSecond(for: displayMode?.refreshRate ?? 60.0) + let previewFramesPerSecond = sourceFramesPerSecond(for: displayMode?.refreshRate ?? 60.0) let initialFrameRateTier = DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( for: initialProfile, - performanceMode: initialPerformanceMode + performanceMode: initialPerformanceMode, + sourceFramesPerSecond: captureSizeContext.sourceVideoSpec.framesPerSecond ) let state = DisplayCaptureStreamConfigurationState( @@ -501,7 +510,7 @@ package extension DisplayCaptureSession { shareCursorOverrideCount: 0 ) AppLog.capture.notice( - "Capture config display=\(display.displayID, privacy: .public) size=\(captureSize.width)x\(captureSize.height, privacy: .public) logical=\(captureSizeContext.logicalSize.width)x\(captureSizeContext.logicalSize.height, privacy: .public) physical=\(captureSizeContext.physicalSize.width)x\(captureSizeContext.physicalSize.height, privacy: .public)" + "Capture config display=\(display.displayID, privacy: .public) size=\(captureSize.width)x\(captureSize.height, privacy: .public) logical=\(captureSizeContext.logicalSize.width)x\(captureSizeContext.logicalSize.height, privacy: .public) physical=\(captureSizeContext.physicalSize.width)x\(captureSizeContext.physicalSize.height, privacy: .public) sourceFps=\(captureSizeContext.sourceVideoSpec.framesPerSecond, privacy: .public)" ) return state } @@ -515,6 +524,7 @@ package extension DisplayCaptureSession { let modeLogicalWidth = displayMode.map { $0.width } ?? modePixelWidth let modeLogicalHeight = displayMode.map { $0.height } ?? modePixelHeight let backingScale = screenBackingScaleFactor(for: display.displayID) + let sourceFramesPerSecond = sourceFramesPerSecond(for: displayMode?.refreshRate ?? 60.0) let scaledLogicalWidth = max(1, Int((CGFloat(modeLogicalWidth) * backingScale).rounded())) let scaledLogicalHeight = max(1, Int((CGFloat(modeLogicalHeight) * backingScale).rounded())) @@ -527,7 +537,8 @@ package extension DisplayCaptureSession { physicalSize: DisplayCaptureDimensions( width: max(modePixelWidth, scaledLogicalWidth), height: max(modePixelHeight, scaledLogicalHeight) - ) + ), + sourceFramesPerSecond: sourceFramesPerSecond ) } diff --git a/Sources/VoidDisplayCapture/Services/DisplayCaptureTypes.swift b/Sources/VoidDisplayCapture/Services/DisplayCaptureTypes.swift index c612b9b..c53ac5e 100644 --- a/Sources/VoidDisplayCapture/Services/DisplayCaptureTypes.swift +++ b/Sources/VoidDisplayCapture/Services/DisplayCaptureTypes.swift @@ -5,13 +5,15 @@ import CoreGraphics import CoreMedia import Foundation import ScreenCaptureKit -package nonisolated enum DisplayCaptureFrameRateTier: Int, CaseIterable, Sendable, Equatable { - case fps30 = 30 - case fps45 = 45 - case fps60 = 60 +package nonisolated struct DisplayCaptureFrameRateTier: Sendable, Equatable, Hashable { + package static let fps30 = DisplayCaptureFrameRateTier(framesPerSecond: 30) + package static let fps45 = DisplayCaptureFrameRateTier(framesPerSecond: 45) + package static let fps60 = DisplayCaptureFrameRateTier(framesPerSecond: 60) - package var framesPerSecond: Int { - rawValue + package let framesPerSecond: Int + + package init(framesPerSecond: Int) { + self.framesPerSecond = max(1, framesPerSecond) } } @@ -20,18 +22,37 @@ package typealias DisplayCaptureDimensions = CapturePixelDimensions package nonisolated struct DisplayCaptureSizeContext: Sendable, Equatable { package static let defaultShared = DisplayCaptureSizeContext( logicalSize: .defaultShared, - physicalSize: .defaultShared + physicalSize: .defaultShared, + sourceVideoSpec: .defaultShared ) package let logicalSize: DisplayCaptureDimensions package let physicalSize: DisplayCaptureDimensions + package let sourceVideoSpec: SourceVideoSpec + + package init( + logicalSize: DisplayCaptureDimensions, + physicalSize: DisplayCaptureDimensions, + sourceFramesPerSecond: Int = 60 + ) { + self.init( + logicalSize: logicalSize, + physicalSize: physicalSize, + sourceVideoSpec: SourceVideoSpec( + dimensions: physicalSize, + framesPerSecond: sourceFramesPerSecond + ) + ) + } package init( logicalSize: DisplayCaptureDimensions, - physicalSize: DisplayCaptureDimensions + physicalSize: DisplayCaptureDimensions, + sourceVideoSpec: SourceVideoSpec ) { self.logicalSize = logicalSize self.physicalSize = physicalSize + self.sourceVideoSpec = sourceVideoSpec } package func captureSize( @@ -273,19 +294,21 @@ package nonisolated enum DisplayCaptureConfigurationDecision: Sendable, Equatabl package nonisolated enum DisplayCaptureConfigurationStateMachine { nonisolated static func defaultFrameRateTier( for profile: DisplayCaptureProfile, - performanceMode: CapturePerformanceMode + performanceMode: CapturePerformanceMode, + sourceFramesPerSecond: Int = 60 ) -> DisplayCaptureFrameRateTier { - switch performanceMode { + let sourceFramesPerSecond = max(1, sourceFramesPerSecond) + return switch performanceMode { case .automatic: - .fps60 + DisplayCaptureFrameRateTier(framesPerSecond: sourceFramesPerSecond) case .smooth: - .fps60 + DisplayCaptureFrameRateTier(framesPerSecond: sourceFramesPerSecond) case .powerEfficient: switch profile { case .previewOnly: - .fps45 + DisplayCaptureFrameRateTier(framesPerSecond: min(sourceFramesPerSecond, 45)) case .shareOnly, .mixed: - .fps30 + DisplayCaptureFrameRateTier(framesPerSecond: min(sourceFramesPerSecond, 30)) } } } @@ -301,7 +324,8 @@ package nonisolated enum DisplayCaptureConfigurationStateMachine { profile: profile, frameRateTier: defaultFrameRateTier( for: profile, - performanceMode: demand.performanceMode + performanceMode: demand.performanceMode, + sourceFramesPerSecond: captureSizeContext.sourceVideoSpec.framesPerSecond ), captureSize: captureSizeContext.captureSize( for: profile, diff --git a/Sources/VoidDisplayFoundation/RuntimeSupport/SharedCapturePerformanceBudget.swift b/Sources/VoidDisplayFoundation/RuntimeSupport/SharedCapturePerformanceBudget.swift index da45bc9..c825e4c 100644 --- a/Sources/VoidDisplayFoundation/RuntimeSupport/SharedCapturePerformanceBudget.swift +++ b/Sources/VoidDisplayFoundation/RuntimeSupport/SharedCapturePerformanceBudget.swift @@ -58,8 +58,27 @@ package nonisolated struct CapturePixelDimensions: Sendable, Equatable { } } +package nonisolated struct SourceVideoSpec: Sendable, Equatable { + package let dimensions: CapturePixelDimensions + package let framesPerSecond: Int + + package init(width: Int, height: Int, framesPerSecond: Int) { + self.dimensions = CapturePixelDimensions(width: width, height: height) + self.framesPerSecond = max(1, framesPerSecond) + } + + package init(dimensions: CapturePixelDimensions, framesPerSecond: Int) { + self.dimensions = dimensions + self.framesPerSecond = max(1, framesPerSecond) + } + + package static let defaultShared = SourceVideoSpec( + dimensions: .defaultShared, + framesPerSecond: 60 + ) +} + package nonisolated struct SharedCapturePerformanceBudget: Sendable, Equatable { - package static let automaticPixelBudgetPerSecond: Int64 = 221_184_000 package static let powerEfficientPixelBudgetPerSecond: Int64 = 62_208_000 package let framesPerSecond: Int @@ -78,7 +97,7 @@ package nonisolated struct SharedCapturePerformanceBudget: Sendable, Equatable { case .automatic: self.init( framesPerSecond: 60, - pixelBudgetPerSecond: Self.automaticPixelBudgetPerSecond + pixelBudgetPerSecond: nil ) case .smooth: self.init( diff --git a/Sources/VoidDisplayFoundation/Screen/DisplaySharingBoundary.swift b/Sources/VoidDisplayFoundation/Screen/DisplaySharingBoundary.swift index 8ac1492..15a4eda 100644 --- a/Sources/VoidDisplayFoundation/Screen/DisplaySharingBoundary.swift +++ b/Sources/VoidDisplayFoundation/Screen/DisplaySharingBoundary.swift @@ -5,6 +5,7 @@ import Synchronization package protocol DisplayShareFrameConsumer: AnyObject, Sendable { nonisolated var hasDemand: Bool { get } + nonisolated func updateSourceVideoSpec(_ spec: SourceVideoSpec) nonisolated func updatePerformanceMode(_ mode: CapturePerformanceMode) nonisolated func stopSharing() nonisolated func submitFrame(pixelBuffer: CVPixelBuffer, ptsUs: UInt64) diff --git a/Sources/VoidDisplaySharing/Resources/displayPage.html b/Sources/VoidDisplaySharing/Resources/displayPage.html index 3a71ca9..bf27469 100644 --- a/Sources/VoidDisplaySharing/Resources/displayPage.html +++ b/Sources/VoidDisplaySharing/Resources/displayPage.html @@ -70,6 +70,10 @@ font-size: 13px; background: rgba(255, 255, 255, 0.08); color: var(--muted); + max-width: min(62vw, 560px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .controls { display: flex; @@ -215,8 +219,17 @@

Connecting…

let reconnectIndex = 0; let reconnectTimer = null; var terminalStop = false; - let state = "idle"; + var state = "idle"; let originalScaleEnabled = false; + var negotiatedVideoCodec = null; + let expectedSourceVideoSpec = null; + const browserStatsLogInterval = 2000; + const browserStatsState = { + timer: null, + lastBytesReceived: null, + lastFramesDecoded: null, + lastTimestamp: null + }; const reconnectDelays = [250, 500, 1000, 2000, 4000]; const messages = { en: { @@ -233,6 +246,9 @@

Connecting…

statusError: "Error", statusConnectionError: "Connection error", statusReconnecting: (delay) => `Reconnecting in ${delay}ms`, + statusLiveWithStats: (codec, width, height, fps) => `Live ${codec} ${width}×${height} ${fps}fps`, + statusLiveBelowSource: (codec, width, height, fps, sourceWidth, sourceHeight, sourceFps) => + `Live ${codec} ${width}×${height} ${fps}fps, source ${sourceWidth}×${sourceHeight} ${sourceFps}fps`, scaleFit: "Fit", scaleOriginal: "1:1", fullscreenEnter: "Fullscreen", @@ -251,10 +267,12 @@

Connecting…

overlayWebSocketRequiredBody: "This browser cannot open signaling transport.", overlayWebRTCRequiredTitle: "WebRTC required", overlayWebRTCRequiredBody: "This browser does not support RTCPeerConnection.", - overlayH264RequiredTitle: "H.264 required", - overlayH264RequiredBody: "This browser or device did not expose H.264 for WebRTC playback.", - overlayH264AnswerRequiredBody: "The WebRTC answer did not negotiate H.264 video.", - overlayH264StatsRequiredBody: "The browser did not confirm an H.264 decoder in WebRTC stats.", + overlayCodecRequiredTitle: "AV1 required", + overlayCodecRequiredBody: "This browser or device did not expose AV1 for WebRTC playback.", + overlayCodecAnswerRequiredBody: "The WebRTC answer did not negotiate AV1 video.", + overlayCodecStatsRequiredBody: "The browser did not confirm the selected AV1 decoder in WebRTC stats.", + overlayCodecPendingTitle: "Preparing video codec", + overlayCodecPendingBody: "The source stream is still preparing AV1. Retrying automatically.", overlayNegotiationFailedTitle: "Negotiation failed", overlayNegotiationFailedFallback: "Failed to create WebRTC offer.", overlaySharingStoppedTitle: "Sharing stopped", @@ -277,6 +295,9 @@

Connecting…

statusError: "发生错误", statusConnectionError: "连接出错", statusReconnecting: (delay) => `${delay}ms 后重连`, + statusLiveWithStats: (codec, width, height, fps) => `直播中 ${codec} ${width}×${height} ${fps}fps`, + statusLiveBelowSource: (codec, width, height, fps, sourceWidth, sourceHeight, sourceFps) => + `直播中 ${codec} ${width}×${height} ${fps}fps,源 ${sourceWidth}×${sourceHeight} ${sourceFps}fps`, scaleFit: "适应", scaleOriginal: "1:1", fullscreenEnter: "全屏", @@ -295,10 +316,12 @@

Connecting…

overlayWebSocketRequiredBody: "当前浏览器无法建立信令传输通道。", overlayWebRTCRequiredTitle: "需要 WebRTC", overlayWebRTCRequiredBody: "当前浏览器不支持 RTCPeerConnection。", - overlayH264RequiredTitle: "需要 H.264", - overlayH264RequiredBody: "当前浏览器或设备未暴露 WebRTC H.264 播放能力。", - overlayH264AnswerRequiredBody: "WebRTC answer 未协商到 H.264 视频。", - overlayH264StatsRequiredBody: "浏览器 WebRTC stats 未确认 H.264 解码器。", + overlayCodecRequiredTitle: "需要 AV1", + overlayCodecRequiredBody: "当前浏览器或设备未暴露 WebRTC AV1 播放能力。", + overlayCodecAnswerRequiredBody: "WebRTC answer 未协商到 AV1 视频。", + overlayCodecStatsRequiredBody: "浏览器 WebRTC stats 未确认选中的 AV1 解码器。", + overlayCodecPendingTitle: "正在准备视频编码", + overlayCodecPendingBody: "源端仍在准备 AV1,正在自动重试。", overlayNegotiationFailedTitle: "协商失败", overlayNegotiationFailedFallback: "创建 WebRTC offer 失败。", overlaySharingStoppedTitle: "共享已停止", @@ -351,8 +374,8 @@

Connecting…

} })(); const localOfferIceTimeoutMs = 2000; - const h264StatsConfirmationTimeoutMs = 10000; - const h264StatsPollIntervalMs = 100; + const codecStatsConfirmationTimeoutMs = 10000; + const codecStatsPollIntervalMs = 100; function applyStaticCopy() { document.title = t("pageTitle"); @@ -418,6 +441,7 @@

Connecting…

syncFullscreenButtonLabel(); function closePeer() { + stopBrowserStatsLoop(); if (peer) { peer.ontrack = null; peer.onicecandidate = null; @@ -451,7 +475,7 @@

Connecting…

} } - function scheduleReconnect() { + function scheduleReconnect(overlayTitle = t("overlayReconnectTitle"), overlayBody = t("overlayReconnectBody")) { if (terminalStop || state === "stopping" || state === "closed") { return; } @@ -461,7 +485,7 @@

Connecting…

const delay = reconnectDelays[Math.min(reconnectIndex, reconnectDelays.length - 1)]; reconnectIndex += 1; setStatus(t("statusReconnecting", delay)); - setOverlay(t("overlayReconnectTitle"), t("overlayReconnectBody"), true); + setOverlay(overlayTitle, overlayBody, true); transition("handshaking"); reconnectTimer = window.setTimeout(() => { reconnectTimer = null; @@ -469,6 +493,47 @@

Connecting…

}, delay); } + function schedulePeerRetry(overlayTitle, overlayBody) { + if (terminalStop || state === "stopping" || state === "closed") { + return; + } + if (reconnectTimer) { + return; + } + const delay = reconnectDelays[Math.min(reconnectIndex, reconnectDelays.length - 1)]; + reconnectIndex += 1; + setStatus(t("statusReconnecting", delay)); + setOverlay(overlayTitle, overlayBody, true); + transition("signalingReady"); + reconnectTimer = window.setTimeout(async () => { + reconnectTimer = null; + if (terminalStop || state === "stopping" || state === "closed") { + return; + } + if (!socket || socket.readyState !== WebSocket.OPEN) { + scheduleReconnect(overlayTitle, overlayBody); + return; + } + try { + await startPeerConnection(); + setOverlay(t("overlayNegotiatingTitle"), t("overlayNegotiatingBody"), true); + } catch (error) { + if (isCodecRequirementError(error)) { + failCodecRequirement(error); + return; + } + setStatus(t("statusNegotiationFailed")); + setOverlay( + t("overlayNegotiationFailedTitle"), + error?.message || t("overlayNegotiationFailedFallback"), + true + ); + closeSocketAndClearReference(); + scheduleReconnect(); + } + }, delay); + } + async function sendSignal(payload) { if (!socket || socket.readyState !== WebSocket.OPEN) return; socket.send(JSON.stringify(payload)); @@ -502,35 +567,126 @@

Connecting…

throw new Error("Local WebRTC offer is missing ICE credentials."); } - function isH264Codec(codec) { - return String(codec?.mimeType || "").toLowerCase() === "video/h264"; + function normalizedVideoCodecName(codec) { + return String(codec?.mimeType || "").toLowerCase(); + } + + function isProbedReceiverVideoCodec(codec) { + return normalizedVideoCodecName(codec) === "video/av1"; } - function h264ReceiverCodecPreferences() { + function receiverCapabilityEntry(codec) { + const payloadType = codecPayloadType(codec); + const fmtpLine = String(codec?.sdpFmtpLine || ""); + return normalizedVideoCodecName(codec) + + "(pt=" + (payloadType === null ? "none" : String(payloadType)) + + ",fmtp=" + (fmtpLine.length === 0 ? "none" : fmtpLine) + ")"; + } + + function receiverCapabilityProbeGroup(allCodecs, mimeTypes) { + const entries = allCodecs + .filter((codec) => mimeTypes.includes(normalizedVideoCodecName(codec))) + .map(receiverCapabilityEntry); + return entries.length === 0 ? "missing" : entries.join(","); + } + + function receiverVideoCodecCapabilitySummary() { if (!window.RTCRtpReceiver || typeof RTCRtpReceiver.getCapabilities !== "function") { - return null; + return "unavailable"; } const capabilities = RTCRtpReceiver.getCapabilities("video"); - const codecs = Array.isArray(capabilities?.codecs) - ? capabilities.codecs.filter(isH264Codec) - : []; - if (codecs.length === 0) { - throw new Error(t("overlayH264RequiredBody")); + const allCodecs = Array.isArray(capabilities?.codecs) ? capabilities.codecs : []; + const probedCodecCount = allCodecs.filter(isProbedReceiverVideoCodec).length; + return [ + "AV1=" + receiverCapabilityProbeGroup(allCodecs, ["video/av1"]), + "unsupportedVideoCodecCount=" + String(allCodecs.length - probedCodecCount) + ].join("; "); + } + + function isSupportedPlaybackCodec(codec) { + return normalizedVideoCodecName(codec) === "video/av1"; + } + + function isRetransmissionCodec(codec) { + return normalizedVideoCodecName(codec) === "video/rtx"; + } + + function codecPayloadType(codec) { + const value = Number(codec?.payloadType ?? codec?.preferredPayloadType ?? NaN); + return Number.isFinite(value) && value >= 0 ? Math.round(value) : null; + } + + function rtxAptPayloadType(codec) { + const parameterApt = Number(codec?.parameters?.apt ?? NaN); + if (Number.isFinite(parameterApt) && parameterApt >= 0) { + return Math.round(parameterApt); + } + const fmtpLine = String(codec?.sdpFmtpLine || ""); + const match = /(?:^|;)\s*apt=(\d+)\s*(?:;|$)/u.exec(fmtpLine); + return match ? Number(match[1]) : null; + } + + function matchingRtxCodecs(allCodecs, primaryCodecs) { + const primaryPayloadTypes = new Set(primaryCodecs + .map(codecPayloadType) + .filter((payloadType) => payloadType !== null)); + if (primaryPayloadTypes.size === 0) { + return []; } - return codecs; + return allCodecs.filter((codec) => { + if (!isRetransmissionCodec(codec)) return false; + const apt = rtxAptPayloadType(codec); + return apt !== null && primaryPayloadTypes.has(apt); + }); + } + + function codecRequirementError(message) { + const error = new Error(message); + error.codecRequirement = true; + return error; + } + + function isCodecRequirementError(error) { + return Boolean(error?.codecRequirement); + } + + function receiverCodecPreferences() { + if (!window.RTCRtpReceiver || typeof RTCRtpReceiver.getCapabilities !== "function") { + throw codecRequirementError(t("overlayCodecRequiredBody")); + } + const capabilities = RTCRtpReceiver.getCapabilities("video"); + const allCodecs = Array.isArray(capabilities?.codecs) ? capabilities.codecs : []; + const supportedCodecs = allCodecs.filter(isSupportedPlaybackCodec); + if (supportedCodecs.length === 0) { + throw codecRequirementError(t("overlayCodecRequiredBody")); + } + return supportedCodecs.concat(matchingRtxCodecs(allCodecs, supportedCodecs)); } function videoCodecNamesFromSDP(sdp) { const lines = String(sdp || "").split(/\r?\n/u); - const payloadTypes = []; - const namesByPayloadType = new Map(); + let payloadTypes = []; + let namesByPayloadType = new Map(); let inVideo = false; + const codecNames = []; + + function flushVideoMedia() { + if (!inVideo) return; + for (const payloadType of payloadTypes) { + const codecName = namesByPayloadType.get(payloadType); + if (codecName) { + codecNames.push(codecName); + } + } + } for (const line of lines) { if (line.startsWith("m=")) { + flushVideoMedia(); inVideo = line.startsWith("m=video "); + payloadTypes = []; + namesByPayloadType = new Map(); if (inVideo) { - payloadTypes.length = 0; const parts = line.trim().split(/\s+/u); payloadTypes.push(...parts.slice(3)); } @@ -542,27 +698,28 @@

Connecting…

namesByPayloadType.set(match[1], match[2].toLowerCase()); } } + flushVideoMedia(); - return payloadTypes - .map((payloadType) => namesByPayloadType.get(payloadType)) - .filter(Boolean); + return codecNames; } - function assertH264AnswerSDP(sdp) { + function selectedCodecFromAnswerSDP(sdp) { const codecNames = videoCodecNamesFromSDP(sdp); - const hasH264 = codecNames.some((name) => name === "h264"); - const hasUnexpectedVideoCodec = codecNames.some((name) => name !== "h264" && name !== "rtx"); - if (!hasH264 || hasUnexpectedVideoCodec) { - throw new Error(t("overlayH264AnswerRequiredBody")); + const primaryCodecs = codecNames.filter((name) => name !== "rtx"); + const supportedPrimaryCodecs = [...new Set(primaryCodecs.filter((name) => name === "av1"))]; + const hasUnexpectedVideoCodec = primaryCodecs.some((name) => name !== "av1"); + if (supportedPrimaryCodecs.length !== 1 || hasUnexpectedVideoCodec) { + throw new Error(t("overlayCodecAnswerRequiredBody")); } + return supportedPrimaryCodecs[0]; } - function failH264Requirement(error) { + function failCodecRequirement(error) { terminalStop = true; setStatus(t("statusNegotiationFailed")); setOverlay( - t("overlayH264RequiredTitle"), - error?.message || t("overlayH264RequiredBody"), + t("overlayCodecRequiredTitle"), + error?.message || t("overlayCodecRequiredBody"), true ); clearReconnectTimer(); @@ -571,13 +728,18 @@

Connecting…

transition("closed"); } - async function verifySelectedH264Codec() { + function isCodecErrorReason(reason) { + return reason === "unsupported_video_codec_offered" || reason === "supported_video_codec_missing"; + } + + async function verifySelectedCodec(expectedCodec) { if (!peer || typeof peer.getStats !== "function") { - throw new Error(t("overlayH264StatsRequiredBody")); + throw new Error(t("overlayCodecStatsRequiredBody")); } const startedAt = performance.now(); - while (performance.now() - startedAt < h264StatsConfirmationTimeoutMs) { + const expectedMimeType = `video/${expectedCodec}`; + while (performance.now() - startedAt < codecStatsConfirmationTimeoutMs) { const stats = await peer.getStats(); const reports = new Map(); stats.forEach((report) => reports.set(report.id, report)); @@ -592,28 +754,182 @@

Connecting…

} const codec = reports.get(report.codecId); if (!codec?.mimeType) continue; - if (String(codec.mimeType).toLowerCase() === "video/h264") { + const codecMimeType = String(codec.mimeType).toLowerCase(); + if (codecMimeType === expectedMimeType) { + logBrowserVideoStats(report, codec, null); return; } - throw new Error(t("overlayH264StatsRequiredBody")); + if (codecMimeType === "video/rtx") { + continue; + } + throw new Error(t("overlayCodecStatsRequiredBody")); + } + await new Promise((resolve) => window.setTimeout(resolve, codecStatsPollIntervalMs)); + } + throw new Error(t("overlayCodecStatsRequiredBody")); + } + + function browserStatsCodecName(codec) { + const mimeType = String(codec?.mimeType || "").toLowerCase(); + if (mimeType === "video/av1") return "AV1"; + return mimeType || "unknown"; + } + + function sourceSpecFromSignal(value) { + const width = Number(value?.width || 0); + const height = Number(value?.height || 0); + const framesPerSecond = Number(value?.framesPerSecond || 0); + if (width <= 0 || height <= 0 || framesPerSecond <= 0) { + return null; + } + return { + width: Math.round(width), + height: Math.round(height), + framesPerSecond: Math.round(framesPerSecond) + }; + } + + function resetBrowserStatsState() { + browserStatsState.lastBytesReceived = null; + browserStatsState.lastFramesDecoded = null; + browserStatsState.lastTimestamp = null; + } + + function stopBrowserStatsLoop() { + if (browserStatsState.timer) { + window.clearInterval(browserStatsState.timer); + browserStatsState.timer = null; + } + resetBrowserStatsState(); + } + + function videoInboundStatsFromReport(stats) { + const reports = new Map(); + stats.forEach((report) => reports.set(report.id, report)); + for (const report of reports.values()) { + const reportKind = report.kind || report.mediaType; + if ( + report.type !== "inbound-rtp" || + reportKind !== "video" || + !report.codecId + ) { + continue; + } + const codec = reports.get(report.codecId); + if (!codec?.mimeType) continue; + const mimeType = String(codec.mimeType).toLowerCase(); + if (mimeType !== "video/av1") continue; + return { report, codec }; + } + return null; + } + + function logBrowserVideoStats(report, codec, derived) { + const codecName = browserStatsCodecName(codec); + const width = Number(report.frameWidth || player.videoWidth || 0); + const height = Number(report.frameHeight || player.videoHeight || 0); + const fps = Number(report.framesPerSecond || derived?.framesPerSecond || 0); + const bitrateBps = Number(derived?.bitrateBps || 0); + const droppedFrames = Number(report.framesDropped || 0); + const jitter = Number(report.jitter || 0); + const packetsLost = Number(report.packetsLost || 0); + const sourceSpec = expectedSourceVideoSpec; + const isBelowSourceSpec = Boolean(sourceSpec && ( + (width > 0 && width < sourceSpec.width) || + (height > 0 && height < sourceSpec.height) || + (fps > 0 && Math.round(fps) < sourceSpec.framesPerSecond) + )); + console.info( + "[VoidDisplay] WebRTC browser stats", + { + selectedCodec: codecName, + decodedWidth: width, + decodedHeight: height, + framesPerSecond: Math.round(fps), + sourceWidth: sourceSpec?.width || null, + sourceHeight: sourceSpec?.height || null, + sourceFramesPerSecond: sourceSpec?.framesPerSecond || null, + belowSourceSpec: isBelowSourceSpec, + bitrateBps: Math.round(bitrateBps), + droppedFrames, + jitter, + packetsLost } - await new Promise((resolve) => window.setTimeout(resolve, h264StatsPollIntervalMs)); + ); + if (state === "streaming" && width > 0 && height > 0) { + const roundedFps = Math.max(0, Math.round(fps)); + if (isBelowSourceSpec) { + setStatus(t( + "statusLiveBelowSource", + codecName, + width, + height, + roundedFps, + sourceSpec.width, + sourceSpec.height, + sourceSpec.framesPerSecond + )); + } else { + setStatus(t("statusLiveWithStats", codecName, width, height, roundedFps)); + } + } + } + + async function pollBrowserStats() { + if (!peer || typeof peer.getStats !== "function") return; + const stats = await peer.getStats(); + const selected = videoInboundStatsFromReport(stats); + if (!selected) return; + const now = Number(selected.report.timestamp || performance.now()); + const bytesReceived = Number(selected.report.bytesReceived || 0); + const framesDecoded = Number(selected.report.framesDecoded || 0); + let derived = null; + if ( + browserStatsState.lastTimestamp !== null && + now > browserStatsState.lastTimestamp + ) { + const elapsedSeconds = (now - browserStatsState.lastTimestamp) / 1000; + const byteDelta = Math.max(0, bytesReceived - browserStatsState.lastBytesReceived); + const frameDelta = Math.max(0, framesDecoded - browserStatsState.lastFramesDecoded); + derived = { + bitrateBps: elapsedSeconds > 0 ? (byteDelta * 8) / elapsedSeconds : 0, + framesPerSecond: elapsedSeconds > 0 ? frameDelta / elapsedSeconds : 0 + }; } - throw new Error(t("overlayH264StatsRequiredBody")); + browserStatsState.lastTimestamp = now; + browserStatsState.lastBytesReceived = bytesReceived; + browserStatsState.lastFramesDecoded = framesDecoded; + logBrowserVideoStats(selected.report, selected.codec, derived); + } + + function startBrowserStatsLoop() { + stopBrowserStatsLoop(); + browserStatsState.timer = window.setInterval(() => { + pollBrowserStats().catch((error) => { + console.warn("[VoidDisplay] WebRTC browser stats failed", error); + }); + }, browserStatsLogInterval); + pollBrowserStats().catch(() => {}); } async function startPeerConnection() { closePeer(); peer = new RTCPeerConnection({ iceServers: bootstrap.iceServers ?? [] }); let usesTransceiverOffer = false; - const h264Preferences = h264ReceiverCodecPreferences(); + negotiatedVideoCodec = null; + console.info("[VoidDisplay] WebRTC receiver video capabilities " + receiverVideoCodecCapabilitySummary()); + const codecPreferences = receiverCodecPreferences(); if (typeof peer.addTransceiver === "function") { const transceiver = peer.addTransceiver("video", { direction: "recvonly" }); if ( - h264Preferences && + codecPreferences && typeof transceiver.setCodecPreferences === "function" ) { - transceiver.setCodecPreferences(h264Preferences); + try { + transceiver.setCodecPreferences(codecPreferences); + } catch (error) { + throw codecRequirementError(error?.message || t("overlayCodecRequiredBody")); + } } usesTransceiverOffer = true; } @@ -622,12 +938,14 @@

Connecting…

if (event.streams && event.streams[0]) { player.srcObject = event.streams[0]; try { - await verifySelectedH264Codec(); + await verifySelectedCodec(negotiatedVideoCodec || "av1"); setStatus(t("statusLive")); setOverlay(t("overlayLiveTitle"), t("overlayLiveBody"), false); + reconnectIndex = 0; transition("streaming"); + startBrowserStatsLoop(); } catch (error) { - failH264Requirement(error); + failCodecRequirement(error); } } }; @@ -706,6 +1024,10 @@

Connecting…

await startPeerConnection(); setOverlay(t("overlayNegotiatingTitle"), t("overlayNegotiatingBody"), true); } catch (error) { + if (isCodecRequirementError(error)) { + failCodecRequirement(error); + return; + } setStatus(t("statusNegotiationFailed")); setOverlay( t("overlayNegotiationFailedTitle"), @@ -738,14 +1060,15 @@

Connecting…

case "answer": if (!peer || typeof payload.sdp !== "string") return; try { - assertH264AnswerSDP(payload.sdp); + negotiatedVideoCodec = selectedCodecFromAnswerSDP(payload.sdp); + expectedSourceVideoSpec = sourceSpecFromSignal(payload.sourceVideoSpec); await peer.setRemoteDescription({ type: "answer", sdp: payload.sdp }); setStatus(t("statusConnected")); } catch (error) { - failH264Requirement(error); + failCodecRequirement(error); } break; case "ice_candidate": @@ -766,7 +1089,17 @@

Connecting…

closeSocketAndClearReference(); transition("closed"); break; + case "codec_pending": + setStatus(t("statusReconnecting", reconnectDelays[Math.min(reconnectIndex, reconnectDelays.length - 1)])); + setOverlay(t("overlayCodecPendingTitle"), t("overlayCodecPendingBody"), true); + closePeer(); + schedulePeerRetry(t("overlayCodecPendingTitle"), t("overlayCodecPendingBody")); + break; case "error": + if (isCodecErrorReason(payload.reason)) { + failCodecRequirement(new Error(t("overlayCodecAnswerRequiredBody"))); + break; + } terminalStop = true; setStatus(t("statusError")); setOverlay(t("overlayStreamErrorTitle"), payload.reason || t("overlayStreamErrorFallback"), true); diff --git a/Sources/VoidDisplaySharing/Services/WebServiceController.swift b/Sources/VoidDisplaySharing/Services/WebServiceController.swift index 9625dc9..e6bea9e 100644 --- a/Sources/VoidDisplaySharing/Services/WebServiceController.swift +++ b/Sources/VoidDisplaySharing/Services/WebServiceController.swift @@ -486,6 +486,21 @@ package final class WebServiceController: WebServiceControllerProtocol { } defer { close(socketDescriptor) } + var reuseAddress = Int32(1) + let optionResult = setsockopt( + socketDescriptor, + SOL_SOCKET, + SO_REUSEADDR, + &reuseAddress, + socklen_t(MemoryLayout.size) + ) + guard optionResult == 0 else { + return .listenerFailed( + port: requestedPort, + message: String(localized: "Failed to prepare web service socket for port \(requestedPort).") + ) + } + var address = sockaddr_in() address.sin_len = UInt8(MemoryLayout.size) address.sin_family = sa_family_t(AF_INET) diff --git a/Sources/VoidDisplaySharing/Views/SharePerformanceModePicker.swift b/Sources/VoidDisplaySharing/Views/SharePerformanceModePicker.swift index f355235..d827963 100644 --- a/Sources/VoidDisplaySharing/Views/SharePerformanceModePicker.swift +++ b/Sources/VoidDisplaySharing/Views/SharePerformanceModePicker.swift @@ -25,7 +25,7 @@ package struct SharePerformanceModePicker: View { } .frame(maxWidth: .infinity, alignment: .center) - Text("Automatic balances H.264 compatibility, clarity, and encoder load.") + Text("Automatic and Smooth send AV1 at source resolution on LAN. Actual frame rate follows encoder capability.") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/Sources/VoidDisplaySharing/Web/RelayHTTPClient.swift b/Sources/VoidDisplaySharing/Web/RelayHTTPClient.swift index 222e1a6..184ba38 100644 --- a/Sources/VoidDisplaySharing/Web/RelayHTTPClient.swift +++ b/Sources/VoidDisplaySharing/Web/RelayHTTPClient.swift @@ -9,7 +9,7 @@ package protocol RelayHTTPClienting: Sendable { sdpMid: String?, sdpMLineIndex: Int32 ) async throws - nonisolated func viewerOffer(roomID: String, clientID: String, sdp: String) async throws -> String + nonisolated func viewerOffer(roomID: String, clientID: String, sdp: String) async throws -> RelayViewerOfferResponse nonisolated func viewerCandidate( roomID: String, clientID: String, @@ -31,6 +31,16 @@ package struct RelayPublisherOfferResponse: Sendable, Equatable { } } +package struct RelayViewerOfferResponse: Sendable, Equatable { + package let sdp: String + package let codec: WebRTCVideoCodec + + package init(sdp: String, codec: WebRTCVideoCodec) { + self.sdp = sdp + self.codec = codec + } +} + package struct RelayReadyEvent: Decodable, Sendable { package let type: String package let loopback: String @@ -51,6 +61,12 @@ package final class RelayHTTPClient: RelayHTTPClienting, @unchecked Sendable { private struct SignalResponse: Decodable { let type: String let sdp: String? + let codec: String? + let reason: String? + } + + private struct ErrorResponse: Decodable { + let type: String? let reason: String? } @@ -100,7 +116,7 @@ package final class RelayHTTPClient: RelayHTTPClienting, @unchecked Sendable { ) } - package nonisolated func viewerOffer(roomID: String, clientID: String, sdp: String) async throws -> String { + package nonisolated func viewerOffer(roomID: String, clientID: String, sdp: String) async throws -> RelayViewerOfferResponse { try await postOffer(path: ["room", roomID, "viewer", clientID], sdp: sdp) } @@ -127,7 +143,7 @@ package final class RelayHTTPClient: RelayHTTPClienting, @unchecked Sendable { try? await sendEmpty(method: "DELETE", path: ["room", roomID, "publisher", publisherID]) } - private nonisolated func postOffer(path: [String], sdp: String) async throws -> String { + private nonisolated func postOffer(path: [String], sdp: String) async throws -> RelayViewerOfferResponse { let response: SignalResponse = try await sendJSON( method: "POST", path: path, @@ -136,7 +152,11 @@ package final class RelayHTTPClient: RelayHTTPClienting, @unchecked Sendable { guard response.type == "answer", let answerSDP = response.sdp else { throw RelayHTTPError.unexpectedResponse(response.reason ?? response.type) } - return answerSDP + guard let rawCodec = response.codec, + let codec = WebRTCVideoCodec(rawValue: rawCodec) else { + throw RelayHTTPError.unexpectedResponse(response.codec ?? "missing_codec") + } + return RelayViewerOfferResponse(sdp: answerSDP, codec: codec) } private nonisolated func postCandidate( @@ -209,7 +229,9 @@ package final class RelayHTTPClient: RelayHTTPClienting, @unchecked Sendable { throw RelayHTTPError.invalidHTTPResponse } guard (200..<300).contains(httpResponse.statusCode) else { - let reason = String(data: data, encoding: .utf8) ?? "status_\(httpResponse.statusCode)" + let reason = (try? JSONDecoder().decode(ErrorResponse.self, from: data).reason) + ?? String(data: data, encoding: .utf8) + ?? "status_\(httpResponse.statusCode)" throw RelayHTTPError.httpStatus(httpResponse.statusCode, reason) } } @@ -220,6 +242,15 @@ package enum RelayHTTPError: Error, LocalizedError, Equatable { case httpStatus(Int, String) case unexpectedResponse(String) + package var relayReason: String? { + switch self { + case .httpStatus(_, let reason), .unexpectedResponse(let reason): + reason + case .invalidHTTPResponse: + nil + } + } + package var errorDescription: String? { switch self { case .invalidHTTPResponse: diff --git a/Sources/VoidDisplaySharing/Web/RelayProcessController.swift b/Sources/VoidDisplaySharing/Web/RelayProcessController.swift index a8c02be..fe7160c 100644 --- a/Sources/VoidDisplaySharing/Web/RelayProcessController.swift +++ b/Sources/VoidDisplaySharing/Web/RelayProcessController.swift @@ -156,6 +156,7 @@ package final class RelayProcessController: @unchecked Sendable { "--control-token", controlToken, "--listen-udp", ":0", "--loopback-http", "127.0.0.1:0", + "--parent-pid", "\(ProcessInfo.processInfo.processIdentifier)", ] process.standardOutput = stdout process.standardError = stderr diff --git a/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift b/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift index 7dc6543..f6c893c 100644 --- a/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift +++ b/Sources/VoidDisplaySharing/Web/RelaySessionHub.swift @@ -8,6 +8,7 @@ import Synchronization package protocol RelayPublisherSessioning: AnyObject, Sendable { nonisolated func start() async throws nonisolated func updateEncodingProfile(_ profile: WebRTCStreamingProfile) + nonisolated func updateActiveCodecs(_ activeCodecs: Set) nonisolated func submitFrame(pixelBuffer: CVPixelBuffer, ptsUs: UInt64) nonisolated func close() } @@ -48,6 +49,7 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { let sessionEpoch: UInt64 let target: ShareTarget let eventSink: @Sendable (SharingSessionEvent) -> Void + var selectedCodec: WebRTCVideoCodec? var nextEventSequence: UInt64 = 0 var isSending = false var pendingSignals: [QueuedSignal] = [] @@ -57,7 +59,9 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { var clients: [ObjectIdentifier: ClientState] = [:] var nextSessionEpoch: UInt64 = 0 var onDemandChanged: @Sendable (Bool) -> Void - var streamingProfile = WebRTCStreamingProfile(performanceMode: .automatic) + var performanceMode: CapturePerformanceMode = .automatic + var sourceVideoSpec: SourceVideoSpec = .defaultShared + var streamingProfile = WebRTCStreamingProfile(performanceMode: .automatic, sourceVideoSpec: .defaultShared) var publisher: (any RelayPublisherSessioning)? var publisherTask: Task? var publisherGeneration: UInt64 = 0 @@ -114,12 +118,29 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { } package nonisolated func updatePerformanceMode(_ mode: CapturePerformanceMode) { - let profile = WebRTCStreamingProfile(performanceMode: mode) - let publisher = state.withLock { state -> (any RelayPublisherSessioning)? in - guard state.streamingProfile != profile else { return nil } + let (publisher, profile) = state.withLock { state -> ((any RelayPublisherSessioning)?, WebRTCStreamingProfile?) in + let profile = WebRTCStreamingProfile(performanceMode: mode, sourceVideoSpec: state.sourceVideoSpec) + guard state.streamingProfile != profile else { return (nil, nil) } + state.performanceMode = mode state.streamingProfile = profile - return state.publisher + return (state.publisher, profile) } + guard let profile else { return } + publisher?.updateEncodingProfile(profile) + } + + package nonisolated func updateSourceVideoSpec(_ spec: SourceVideoSpec) { + let (publisher, profile) = state.withLock { state -> ((any RelayPublisherSessioning)?, WebRTCStreamingProfile?) in + guard state.sourceVideoSpec != spec else { return (nil, nil) } + state.sourceVideoSpec = spec + let profile = WebRTCStreamingProfile( + performanceMode: state.performanceMode, + sourceVideoSpec: spec + ) + state.streamingProfile = profile + return (state.publisher, profile) + } + guard let profile else { return } publisher?.updateEncodingProfile(profile) } @@ -350,17 +371,19 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { roomID: String, generation: UInt64 ) { - let decision = state.withLock { state -> (shouldClose: Bool, profile: WebRTCStreamingProfile?) in + let decision = state.withLock { + state -> (shouldClose: Bool, profile: WebRTCStreamingProfile?, activeCodecs: Set) in guard state.publisherGeneration == generation else { - return (true, nil) + return (true, nil, []) } state.publisherTask = nil guard !state.clients.isEmpty, state.roomID == roomID else { - return (true, nil) + return (true, nil, []) } let profile = state.streamingProfile + let activeCodecs = Self.activeCodecs(from: state.clients) state.publisher = publisher - return (false, profile) + return (false, profile, activeCodecs) } if decision.shouldClose { publisher.close() @@ -368,6 +391,7 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { } if let profile = decision.profile { publisher.updateEncodingProfile(profile) + publisher.updateActiveCodecs(decision.activeCodecs) } AppLog.web.info("Relay publisher started for room \(roomID, privacy: .public).") } @@ -409,15 +433,54 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { await relayClient.removeViewer(roomID: roomID, clientID: clientID) return } - send(message: SignalingOutboundMessage(type: .answer, sdp: answer), to: key) + let update = state.withLock { + state -> ( + publisher: (any RelayPublisherSessioning)?, + activeCodecs: Set, + sourceVideoSpec: SourceVideoSpec + ) in + guard var client = state.clients[key], + client.clientID == clientID, + client.sessionEpoch == sessionEpoch, + Self.roomID(for: client.target) == roomID else { + return (nil, [], state.sourceVideoSpec) + } + client.selectedCodec = answer.codec + state.clients[key] = client + return (state.publisher, Self.activeCodecs(from: state.clients), state.sourceVideoSpec) + } + update.publisher?.updateActiveCodecs(update.activeCodecs) + send( + message: SignalingOutboundMessage( + type: .answer, + sdp: answer.sdp, + sourceVideoSpec: SourceVideoSpecSignalPayload(spec: update.sourceVideoSpec) + ), + to: key + ) emitEvent(phase: .peerConnected, source: .peerConnection, for: key) } catch { guard isCurrentViewer(key: key, roomID: roomID, clientID: clientID, sessionEpoch: sessionEpoch) else { return } AppLog.web.warning("Relay viewer offer failed: \(String(describing: error), privacy: .public)") - send(message: SignalingOutboundMessage(type: .error, reason: "relay_viewer_offer_failed"), to: key) - removeClient(for: key, cancelConnection: true) + let relayReason = (error as? RelayHTTPError)?.relayReason + if relayReason == "publisher_codec_pending" { + enqueue( + message: SignalingOutboundMessage(type: .codecPending, reason: "publisher_codec_pending"), + to: key, + disconnectAfterSend: false, + replacePending: true + ) + return + } + let message: SignalingOutboundMessage + if relayReason == "unsupported_video_codec_offered" || relayReason == "supported_video_codec_missing" { + message = SignalingOutboundMessage(type: .error, reason: relayReason) + } else { + message = SignalingOutboundMessage(type: .error, reason: "relay_viewer_offer_failed") + } + enqueue(message: message, to: key, disconnectAfterSend: true, replacePending: true) } } @@ -598,13 +661,28 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { } private nonisolated func removeClient(for key: ObjectIdentifier, cancelConnection: Bool) { - let (removed, shouldSignalDemandOff, callback, roomID) = state.withLock { - state -> (ClientState?, Bool, @Sendable (Bool) -> Void, String?) in + let (removed, shouldSignalDemandOff, callback, roomID, publisher, activeCodecs) = state.withLock { + state -> ( + ClientState?, + Bool, + @Sendable (Bool) -> Void, + String?, + (any RelayPublisherSessioning)?, + Set + ) in let removed = state.clients.removeValue(forKey: key) - return (removed, state.clients.isEmpty, state.onDemandChanged, state.roomID) + return ( + removed, + state.clients.isEmpty, + state.onDemandChanged, + state.roomID, + state.publisher, + Self.activeCodecs(from: state.clients) + ) } guard let removed else { return } + publisher?.updateActiveCodecs(activeCodecs) removed.eventSink( SharingSessionEvent( target: removed.target, @@ -672,6 +750,10 @@ package final class RelaySessionHub: Sendable, SignalSessionHub { } } + private nonisolated static func activeCodecs(from clients: [ObjectIdentifier: ClientState]) -> Set { + Set(clients.values.compactMap(\.selectedCodec)) + } + private nonisolated static func roomID(for target: ShareTarget) -> String { switch target { case .main: diff --git a/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift b/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift index 41f01a1..ac43813 100644 --- a/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift +++ b/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift @@ -18,16 +18,23 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { case closed } + private struct VideoTransceiverBinding { + let codec: WebRTCVideoCodec + let transceiver: RTCRtpTransceiver + } + nonisolated(unsafe) private let peerConnection: RTCPeerConnection private let mediaPipeline: WebRTCMediaPipeline private let relayClient: any RelayHTTPClienting private let roomID: String private let stateLock = Mutex(.idle) private let profileState: Mutex + private let activeCodecsState = Mutex>([]) private let publisherIDState = Mutex(nil) private let iceGatheringWaiters = Mutex<[CheckedContinuation]>([]) private let pendingPublisherCandidates = Mutex<[RTCIceCandidate]>([]) - nonisolated(unsafe) private var videoTransceiver: RTCRtpTransceiver? + nonisolated(unsafe) private var videoTransceivers: [VideoTransceiverBinding] = [] + nonisolated(unsafe) private var diagnosticsTask: Task? package nonisolated init?( roomID: String, @@ -89,12 +96,35 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { package nonisolated func updateEncodingProfile(_ profile: WebRTCStreamingProfile) { profileState.withLock { $0 = profile } mediaPipeline.updateEncodingProfile(profile) - configureDesktopVideoSender(videoTransceiver?.sender, profile: profile) - _ = peerConnection.setBweMinBitrateBps( - NSNumber(value: profile.minBitrateBps), - currentBitrateBps: nil, - maxBitrateBps: NSNumber(value: profile.maxBitrateBps) - ) + let activeCodecs = activeCodecsState.withLock { $0 } + for binding in videoTransceivers { + configureDesktopVideoSender( + binding.transceiver.sender, + codec: binding.codec, + profile: profile, + isActive: activeCodecs.contains(binding.codec) + ) + } + updateBandwidthEstimate(profile: profile, activeCodecs: activeCodecs) + } + + package nonisolated func updateActiveCodecs(_ activeCodecs: Set) { + let configuredCodecs = Set(videoTransceivers.map(\.codec)) + let nextActiveCodecs = activeCodecs.intersection(configuredCodecs) + activeCodecsState.withLock { $0 = nextActiveCodecs } + mediaPipeline.updateActiveCodecs(nextActiveCodecs) + let profile = profileState.withLock { $0 } + for binding in videoTransceivers { + configureDesktopVideoSender( + binding.transceiver.sender, + codec: binding.codec, + profile: profile, + isActive: nextActiveCodecs.contains(binding.codec) + ) + } + updateBandwidthEstimate(profile: profile, activeCodecs: nextActiveCodecs) + let activeSummary = nextActiveCodecs.map(\.logName).sorted().joined(separator: ",") + AppLog.web.info("WebRTC publisher active codecs updated active=\(activeSummary, privacy: .public).") } package nonisolated func submitFrame(pixelBuffer: CVPixelBuffer, ptsUs: UInt64) { @@ -109,6 +139,8 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { return current } pendingPublisherCandidates.withLock { $0.removeAll() } + diagnosticsTask?.cancel() + diagnosticsTask = nil resumeIceGatheringWaiters() peerConnection.close() guard let publisherID else { return } @@ -120,23 +152,30 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { private nonisolated func startInternal() async throws { let capabilitySummary = mediaPipeline.senderVideoCodecCapabilitySummary() AppLog.web.info("WebRTC sender video capabilities \(capabilitySummary, privacy: .public).") - guard let h264Codecs = mediaPipeline.requiredH264Codecs() else { - throw PublisherSessionError.h264Unavailable - } - let transceiverInit = RTCRtpTransceiverInit() - transceiverInit.direction = .sendOnly - transceiverInit.streamIds = ["screen"] - guard let transceiver = peerConnection.addTransceiver(with: mediaPipeline.videoTrack, init: transceiverInit) else { - throw PublisherSessionError.videoTransceiverUnavailable + guard let av1Codecs = mediaPipeline.requiredCodecs(for: .av1) else { + throw PublisherSessionError.av1Unavailable } - do { - try transceiver.setCodecPreferences(h264Codecs, error: ()) - } catch { - throw PublisherSessionError.codecPreferencesFailed(String(describing: error)) + let av1Transceiver = try addVideoTransceiver( + codec: .av1, + track: mediaPipeline.av1VideoTrack, + codecs: av1Codecs + ) + let configuredTransceivers = [VideoTransceiverBinding(codec: .av1, transceiver: av1Transceiver)] + let configuredCodecs: Set = [.av1] + videoTransceivers = configuredTransceivers + let initialActiveCodecs = activeCodecsState.withLock { $0.intersection(configuredCodecs) } + mediaPipeline.updateActiveCodecs(initialActiveCodecs) + let initialProfile = profileState.withLock { $0 } + for binding in configuredTransceivers { + configureDesktopVideoSender( + binding.transceiver.sender, + codec: binding.codec, + profile: initialProfile, + isActive: initialActiveCodecs.contains(binding.codec) + ) } - videoTransceiver = transceiver - configureDesktopVideoSender(transceiver.sender, profile: profileState.withLock { $0 }) - AppLog.web.info("WebRTC publisher transceiver configured H264 codecs=\(h264Codecs.count, privacy: .public).") + updateBandwidthEstimate(profile: initialProfile, activeCodecs: initialActiveCodecs) + AppLog.web.info("WebRTC publisher transceiver configured AV1 codecs=\(av1Codecs.count, privacy: .public).") let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) let offer = try await createOffer(constraints: constraints) @@ -157,9 +196,29 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { } let answer = RTCSessionDescription(type: .answer, sdp: response.sdp) try await setRemoteDescription(answer) + startDiagnosticsLoop() AppLog.web.info("WebRTC publisher connected to relay room \(self.roomID, privacy: .public).") } + private nonisolated func addVideoTransceiver( + codec: WebRTCVideoCodec, + track: RTCVideoTrack, + codecs: [RTCRtpCodecCapability] + ) throws -> RTCRtpTransceiver { + let transceiverInit = RTCRtpTransceiverInit() + transceiverInit.direction = .sendOnly + transceiverInit.streamIds = ["screen"] + guard let transceiver = peerConnection.addTransceiver(with: track, init: transceiverInit) else { + throw PublisherSessionError.videoTransceiverUnavailable(codec) + } + do { + try transceiver.setCodecPreferences(codecs, error: ()) + } catch { + throw PublisherSessionError.codecPreferencesFailed("\(codec.logName): \(String(describing: error))") + } + return transceiver + } + private nonisolated func createOffer(constraints: RTCMediaConstraints) async throws -> RTCSessionDescription { try await withCheckedThrowingContinuation { continuation in peerConnection.offer(for: constraints) { offer, error in @@ -253,24 +312,115 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { private nonisolated func configureDesktopVideoSender( _ sender: RTCRtpSender?, - profile: WebRTCStreamingProfile + codec: WebRTCVideoCodec, + profile: WebRTCStreamingProfile, + isActive: Bool ) { guard let sender else { return } let parameters = sender.parameters if parameters.encodings.isEmpty { parameters.encodings = [RTCRtpEncodingParameters()] } + let sourceDimensions = profile.sourceVideoSpec.dimensions + let outputDimensions = profile.outputDimensions( + for: codec, + width: Int32(sourceDimensions.width), + height: Int32(sourceDimensions.height) + ) + let bitrateLimits = profile.bitrateLimits( + for: codec, + outputWidth: outputDimensions.width, + outputHeight: outputDimensions.height + ) + let framesPerSecond = profile.framesPerSecond(for: codec) for encoding in parameters.encodings { - encoding.maxBitrateBps = NSNumber(value: profile.maxBitrateBps) - encoding.minBitrateBps = NSNumber(value: profile.minBitrateBps) - encoding.maxFramerate = NSNumber(value: profile.framesPerSecond) + encoding.isActive = isActive + encoding.maxBitrateBps = NSNumber(value: bitrateLimits.maxBitrateBps) + encoding.minBitrateBps = NSNumber(value: bitrateLimits.minBitrateBps) + encoding.maxFramerate = NSNumber(value: framesPerSecond) encoding.scaleResolutionDownBy = NSNumber(value: 1.0) encoding.bitratePriority = 4.0 + encoding.networkPriority = .high } parameters.degradationPreference = Self.maintainResolutionPreference sender.parameters = parameters } + private nonisolated func updateBandwidthEstimate( + profile: WebRTCStreamingProfile, + activeCodecs: Set + ) { + let sourceDimensions = profile.sourceVideoSpec.dimensions + let codecsForBudget = activeCodecs.isEmpty ? Set(WebRTCVideoCodec.allCases) : activeCodecs + let limits = codecsForBudget.map { codec in + let outputDimensions = profile.outputDimensions( + for: codec, + width: Int32(sourceDimensions.width), + height: Int32(sourceDimensions.height) + ) + return profile.bitrateLimits( + for: codec, + outputWidth: outputDimensions.width, + outputHeight: outputDimensions.height + ) + } + let minBitrateBps = limits.map(\.minBitrateBps).min() ?? profile.minBitrateBps + let maxBitrateBps = limits.map(\.maxBitrateBps).reduce(0, +) + _ = peerConnection.setBweMinBitrateBps( + NSNumber(value: minBitrateBps), + currentBitrateBps: nil, + maxBitrateBps: NSNumber(value: max(maxBitrateBps, profile.maxBitrateBps)) + ) + } + + private nonisolated func startDiagnosticsLoop() { + diagnosticsTask?.cancel() + diagnosticsTask = Task { [weak self] in + await self?.runDiagnosticsLoop() + } + } + + private nonisolated func runDiagnosticsLoop() async { + while !Task.isCancelled, !isClosed { + await logSenderDiagnostics() + try? await Task.sleep(nanoseconds: 5_000_000_000) + } + } + + private nonisolated func logSenderDiagnostics() async { + let bindings = videoTransceivers + for binding in bindings { + let report = await statistics(for: binding.transceiver.sender) + logOutboundVideoStats(report: report, codec: binding.codec) + } + } + + private nonisolated func statistics(for sender: RTCRtpSender) async -> RTCStatisticsReport { + await withCheckedContinuation { continuation in + peerConnection.statistics(for: sender) { report in + continuation.resume(returning: report) + } + } + } + + private nonisolated func logOutboundVideoStats(report: RTCStatisticsReport, codec: WebRTCVideoCodec) { + for statistic in report.statistics.values where statistic.type == "outbound-rtp" { + let values = statistic.values + let kind = (values["kind"] ?? values["mediaType"])?.description.lowercased() + guard kind == nil || kind == "video" else { continue } + let width = values["frameWidth"]?.description ?? "unknown" + let height = values["frameHeight"]?.description ?? "unknown" + let fps = values["framesPerSecond"]?.description ?? "unknown" + let targetBitrate = values["targetBitrate"]?.description ?? "unknown" + let qualityLimitationReason = values["qualityLimitationReason"]?.description ?? "unknown" + let encoderImplementation = values["encoderImplementation"]?.description ?? "unknown" + AppLog.web.info( + "WebRTC publisher outbound stats codec=\(codec.logName, privacy: .public) encoded=\(width, privacy: .public)x\(height, privacy: .public) fps=\(fps, privacy: .public) targetBitrate=\(targetBitrate, privacy: .public) qualityLimitationReason=\(qualityLimitationReason, privacy: .public) encoder=\(encoderImplementation, privacy: .public)." + ) + return + } + } + private nonisolated func sendPublisherCandidate(_ candidate: RTCIceCandidate) { guard let publisherID = publisherIDState.withLock({ $0 }) else { pendingPublisherCandidates.withLock { $0.append(candidate) } @@ -342,8 +492,8 @@ extension WebRTCPublisherSession: RTCPeerConnectionDelegate { package enum PublisherSessionError: Error, LocalizedError, Equatable { case closed - case h264Unavailable - case videoTransceiverUnavailable + case av1Unavailable + case videoTransceiverUnavailable(WebRTCVideoCodec) case codecPreferencesFailed(String) case offerMissing @@ -351,10 +501,10 @@ package enum PublisherSessionError: Error, LocalizedError, Equatable { switch self { case .closed: "Publisher session is closed." - case .h264Unavailable: - String(localized: "This Mac's WebRTC stack did not expose H.264 video encoding.") - case .videoTransceiverUnavailable: - "WebRTC publisher video transceiver is unavailable." + case .av1Unavailable: + String(localized: "This Mac's WebRTC stack did not expose AV1 video encoding.") + case .videoTransceiverUnavailable(let codec): + "WebRTC publisher \(codec.logName) video transceiver is unavailable." case .codecPreferencesFailed(let reason): "Failed to set publisher codec preferences: \(reason)" case .offerMissing: diff --git a/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift b/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift index 2cd0dbb..145b9b4 100644 --- a/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift +++ b/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift @@ -29,58 +29,147 @@ package enum SignalSessionClientAddResult: Sendable, Equatable { case rejected(reason: String) } +package enum WebRTCVideoCodec: String, CaseIterable, Sendable { + case av1 + + package var logName: String { + switch self { + case .av1: + "AV1" + } + } +} + package struct WebRTCStreamingProfile: Sendable, Equatable { - package static let h264AutomaticPixelBudgetPerSecond: Int64 = 26_542_080 - package static let h264SmoothPixelBudgetPerSecond: Int64 = 26_542_080 - package static let h264PowerEfficientPixelBudgetPerSecond: Int64 = 15_552_000 - private static let h264Level31MaxMacroblocksPerFrame = 3_600 - private static let h264Level31MaxMacroblocksPerSecond = 108_000 + private static let av1SourceBitsPerPixel: Double = 0.10 + private static let av1PowerEfficientBitsPerPixel: Double = 0.05 + package let performanceMode: CapturePerformanceMode + package let sourceVideoSpec: SourceVideoSpec package let framesPerSecond: Int package let minBitrateBps: Int package let maxBitrateBps: Int package let pixelBudgetPerSecond: Int64? package init( + performanceMode: CapturePerformanceMode, + sourceVideoSpec: SourceVideoSpec, framesPerSecond: Int, - minBitrateBps: Int, - maxBitrateBps: Int, pixelBudgetPerSecond: Int64? ) { - self.framesPerSecond = framesPerSecond - self.minBitrateBps = minBitrateBps - self.maxBitrateBps = maxBitrateBps + self.performanceMode = performanceMode + self.sourceVideoSpec = sourceVideoSpec + self.framesPerSecond = max(1, framesPerSecond) self.pixelBudgetPerSecond = pixelBudgetPerSecond + let sourceDimensions = sourceVideoSpec.dimensions + let maxBitrateBps = Self.targetMaxBitrateBps( + for: .av1, + dimensions: sourceDimensions, + framesPerSecond: self.framesPerSecond, + performanceMode: performanceMode + ) + self.maxBitrateBps = maxBitrateBps + self.minBitrateBps = Self.targetMinBitrateBps(maxBitrateBps: maxBitrateBps) } - package init(performanceMode: CapturePerformanceMode) { + package init( + performanceMode: CapturePerformanceMode, + sourceVideoSpec: SourceVideoSpec = .defaultShared + ) { let budget = SharedCapturePerformanceBudget(performanceMode: performanceMode) + let sourceFramesPerSecond = sourceVideoSpec.framesPerSecond switch performanceMode { case .automatic: self.init( - framesPerSecond: 30, - minBitrateBps: 1_500_000, - maxBitrateBps: 8_000_000, - pixelBudgetPerSecond: Self.h264AutomaticPixelBudgetPerSecond + performanceMode: performanceMode, + sourceVideoSpec: sourceVideoSpec, + framesPerSecond: sourceFramesPerSecond, + pixelBudgetPerSecond: nil ) case .smooth: self.init( - framesPerSecond: budget.framesPerSecond, - minBitrateBps: 1_500_000, - maxBitrateBps: 8_000_000, - pixelBudgetPerSecond: Self.h264SmoothPixelBudgetPerSecond + performanceMode: performanceMode, + sourceVideoSpec: sourceVideoSpec, + framesPerSecond: sourceFramesPerSecond, + pixelBudgetPerSecond: nil ) case .powerEfficient: self.init( - framesPerSecond: budget.framesPerSecond, - minBitrateBps: 800_000, - maxBitrateBps: 5_000_000, - pixelBudgetPerSecond: Self.h264PowerEfficientPixelBudgetPerSecond + performanceMode: performanceMode, + sourceVideoSpec: sourceVideoSpec, + framesPerSecond: min(sourceFramesPerSecond, budget.framesPerSecond), + pixelBudgetPerSecond: SharedCapturePerformanceBudget.powerEfficientPixelBudgetPerSecond ) } } + package func bitrateLimits( + for codec: WebRTCVideoCodec, + outputWidth: Int32, + outputHeight: Int32 + ) -> (minBitrateBps: Int, maxBitrateBps: Int) { + let maxBitrateBps = Self.targetMaxBitrateBps( + for: codec, + dimensions: CapturePixelDimensions(width: Int(outputWidth), height: Int(outputHeight)), + framesPerSecond: framesPerSecond(for: codec), + performanceMode: performanceMode + ) + return ( + minBitrateBps: Self.targetMinBitrateBps(maxBitrateBps: maxBitrateBps), + maxBitrateBps: maxBitrateBps + ) + } + + package func outputDimensions( + for codec: WebRTCVideoCodec, + width: Int32, + height: Int32 + ) -> (width: Int32, height: Int32) { + outputDimensions( + forWidth: width, + height: height, + framesPerSecond: framesPerSecond(for: codec), + pixelBudgetPerSecond: pixelBudgetPerSecond(for: codec) + ) + } + package func outputDimensions(forWidth width: Int32, height: Int32) -> (width: Int32, height: Int32) { + outputDimensions( + forWidth: width, + height: height, + framesPerSecond: framesPerSecond, + pixelBudgetPerSecond: pixelBudgetPerSecond + ) + } + + package func framesPerSecond(for codec: WebRTCVideoCodec) -> Int { + framesPerSecond + } + + package func outputVideoSpec(for codec: WebRTCVideoCodec) -> SourceVideoSpec { + let sourceDimensions = sourceVideoSpec.dimensions + let outputDimensions = outputDimensions( + for: codec, + width: Int32(sourceDimensions.width), + height: Int32(sourceDimensions.height) + ) + return SourceVideoSpec( + width: Int(outputDimensions.width), + height: Int(outputDimensions.height), + framesPerSecond: framesPerSecond(for: codec) + ) + } + + private func pixelBudgetPerSecond(for codec: WebRTCVideoCodec) -> Int64? { + pixelBudgetPerSecond + } + + private func outputDimensions( + forWidth width: Int32, + height: Int32, + framesPerSecond: Int, + pixelBudgetPerSecond: Int64? + ) -> (width: Int32, height: Int32) { guard width > 0, height > 0 else { return (width, height) } @@ -92,53 +181,31 @@ package struct WebRTCStreamingProfile: Sendable, Equatable { let dimensions = budget.captureDimensions( for: CapturePixelDimensions(width: Int(width), height: Int(height)) ) - let h264Dimensions = Self.constrainToH264Level31( - dimensions, - framesPerSecond: framesPerSecond - ) return ( - width: Int32(h264Dimensions.width), - height: Int32(h264Dimensions.height) + width: Int32(dimensions.width), + height: Int32(dimensions.height) ) } - private static func constrainToH264Level31( - _ dimensions: CapturePixelDimensions, - framesPerSecond: Int - ) -> CapturePixelDimensions { - let framesPerSecond = max(1, framesPerSecond) - let frameMacroblockBudget = min( - h264Level31MaxMacroblocksPerFrame, - h264Level31MaxMacroblocksPerSecond / framesPerSecond - ) - guard frameMacroblockBudget > 0, - macroblockCount(for: dimensions) > frameMacroblockBudget else { - return dimensions - } - - var constrained = dimensions.constrained( - toFramePixelBudget: Int64(frameMacroblockBudget * 16 * 16) - ) - while macroblockCount(for: constrained) > frameMacroblockBudget { - if constrained.width >= constrained.height { - constrained = CapturePixelDimensions( - width: constrained.width - 2, - height: constrained.height - ) - } else { - constrained = CapturePixelDimensions( - width: constrained.width, - height: constrained.height - 2 - ) - } + private static func targetMaxBitrateBps( + for codec: WebRTCVideoCodec, + dimensions: CapturePixelDimensions, + framesPerSecond: Int, + performanceMode: CapturePerformanceMode + ) -> Int { + let pixelRate = Double(dimensions.pixelCount) * Double(max(1, framesPerSecond)) + let bitsPerPixel: Double = switch (codec, performanceMode) { + case (.av1, .powerEfficient): + av1PowerEfficientBitsPerPixel + case (.av1, _): + av1SourceBitsPerPixel } - return constrained + let target = Int((pixelRate * bitsPerPixel).rounded()) + return max(2_000_000, target) } - private static func macroblockCount(for dimensions: CapturePixelDimensions) -> Int { - let macroblockWidth = (dimensions.width + 15) / 16 - let macroblockHeight = (dimensions.height + 15) / 16 - return macroblockWidth * macroblockHeight + private static func targetMinBitrateBps(maxBitrateBps: Int) -> Int { + max(1_500_000, maxBitrateBps / 4) } } @@ -199,6 +266,7 @@ package enum SignalingMessageType: String, Codable { case iceCandidate = "ice_candidate" case iceComplete = "ice_complete" case ready + case codecPending = "codec_pending" case stopped case error } @@ -211,6 +279,18 @@ package struct SignalingInboundMessage: Decodable { package let sdpMLineIndex: Int? } +package struct SourceVideoSpecSignalPayload: Codable, Sendable, Equatable { + package let width: Int + package let height: Int + package let framesPerSecond: Int + + package init(spec: SourceVideoSpec) { + self.width = spec.dimensions.width + self.height = spec.dimensions.height + self.framesPerSecond = spec.framesPerSecond + } +} + package struct SignalingOutboundMessage: Encodable { package let type: SignalingMessageType package let reason: String? @@ -218,6 +298,7 @@ package struct SignalingOutboundMessage: Encodable { package let candidate: String? package let sdpMid: String? package let sdpMLineIndex: Int? + package let sourceVideoSpec: SourceVideoSpecSignalPayload? package init( type: SignalingMessageType, @@ -225,7 +306,8 @@ package struct SignalingOutboundMessage: Encodable { sdp: String? = nil, candidate: String? = nil, sdpMid: String? = nil, - sdpMLineIndex: Int? = nil + sdpMLineIndex: Int? = nil, + sourceVideoSpec: SourceVideoSpecSignalPayload? = nil ) { self.type = type self.reason = reason @@ -233,6 +315,7 @@ package struct SignalingOutboundMessage: Encodable { self.candidate = candidate self.sdpMid = sdpMid self.sdpMLineIndex = sdpMLineIndex + self.sourceVideoSpec = sourceVideoSpec } } @@ -319,28 +402,39 @@ package struct WebRTCCodecPreferenceDescriptor: Sendable, Equatable { } } +extension WebRTCVideoCodec { + package var codecName: String { + switch self { + case .av1: + "AV1" + } + } +} + package enum WebRTCCodecPreference { - package nonisolated static func requiredH264DescriptorIndexes( + package nonisolated static func requiredDescriptorIndexes( + for codec: WebRTCVideoCodec, from descriptors: [WebRTCCodecPreferenceDescriptor] ) -> [Int]? { - let h264Indexes = descriptors.indices.filter { - descriptors[$0].name.caseInsensitiveCompare(kRTCH264CodecName) == .orderedSame + let primaryIndexes = descriptors.indices.filter { + descriptors[$0].name.caseInsensitiveCompare(codec.codecName) == .orderedSame } - guard !h264Indexes.isEmpty else { return nil } + guard !primaryIndexes.isEmpty else { return nil } - let h264PayloadTypes = Set(h264Indexes.compactMap { descriptors[$0].payloadType }) + let primaryPayloadTypes = Set(primaryIndexes.compactMap { descriptors[$0].payloadType }) let rtxIndexes = descriptors.indices.filter { index in let descriptor = descriptors[index] guard descriptor.name.caseInsensitiveCompare(kRTCRtxCodecName) == .orderedSame, let apt = descriptor.parameters["apt"].flatMap(Int.init) else { return false } - return h264PayloadTypes.contains(apt) + return primaryPayloadTypes.contains(apt) } - return h264Indexes + rtxIndexes + return primaryIndexes + rtxIndexes } - package nonisolated static func requiredH264Codecs( + package nonisolated static func requiredCodecs( + for codec: WebRTCVideoCodec, from codecs: [RTCRtpCodecCapability] ) -> [RTCRtpCodecCapability]? { let descriptors = codecs.map { @@ -350,7 +444,7 @@ package enum WebRTCCodecPreference { parameters: $0.parameters ) } - guard let indexes = requiredH264DescriptorIndexes(from: descriptors) else { + guard let indexes = requiredDescriptorIndexes(for: codec, from: descriptors) else { return nil } return indexes.map { codecs[$0] } @@ -372,34 +466,63 @@ package enum WebRTCCodecPreference { package nonisolated static func capabilitySummary( from descriptors: [WebRTCCodecPreferenceDescriptor] ) -> String { - let h264Descriptors = descriptors.filter { - $0.name.caseInsensitiveCompare(kRTCH264CodecName) == .orderedSame + let av1Descriptors = descriptors.filter { + $0.name.caseInsensitiveCompare(WebRTCVideoCodec.av1.codecName) == .orderedSame } - let h264Summary = h264Descriptors.map { descriptor in + let otherCodecCount = descriptors.count - av1Descriptors.count + return [ + "AV1=\(capabilityProbeSummary(from: av1Descriptors))", + "unsupportedVideoCodecCount=\(otherCodecCount)", + ].joined(separator: "; ") + } + + private nonisolated static func capabilityProbeSummary( + from descriptors: [WebRTCCodecPreferenceDescriptor] + ) -> String { + guard !descriptors.isEmpty else { return "missing" } + return descriptors.map { descriptor in let payloadType = descriptor.payloadType.map(String.init) ?? "none" let parameters = descriptor.parameters .sorted { $0.key < $1.key } .map { "\($0.key)=\($0.value)" } - .joined(separator: ";") - return "\(descriptor.name)(pt=\(payloadType),params=\(parameters))" + .joined(separator: ",") + return "\(descriptor.name)(pt=\(payloadType),fmtp=\(parameters.isEmpty ? "none" : parameters))" } .joined(separator: ",") - let h264Text = h264Summary.isEmpty ? "none" : h264Summary - return "\(h264Text); nonH264CodecCount=\(descriptors.count - h264Descriptors.count)" } package nonisolated static func sdpVideoCodecSummary(from sdp: String) -> String { let lines = sdp.split(whereSeparator: \.isNewline).map(String.init) - var videoPayloadTypes: [String] = [] - var payloadNames: [String: String] = [:] + var currentVideoPayloadTypes: [String] = [] + var currentPayloadNames: [String: String] = [:] var isVideoMedia = false + var summary: [String] = [] + var unexpectedCodecCount = 0 + + func flushVideoMedia() { + guard isVideoMedia else { return } + for payloadType in currentVideoPayloadTypes { + guard let name = currentPayloadNames[payloadType] else { continue } + let normalizedName = name.lowercased() + guard normalizedName == "av1" || normalizedName == "rtx" else { + unexpectedCodecCount += 1 + continue + } + summary.append("\(payloadType):\(name)") + } + } for line in lines { if line.hasPrefix("m=") { + flushVideoMedia() isVideoMedia = line.hasPrefix("m=video ") + currentVideoPayloadTypes = [] + currentPayloadNames = [:] if isVideoMedia { let parts = line.split(separator: " ").map(String.init) - videoPayloadTypes = parts.count > 3 ? Array(parts.dropFirst(3)) : [] + if parts.count > 3 { + currentVideoPayloadTypes = Array(parts.dropFirst(3)) + } } continue } @@ -412,14 +535,12 @@ package enum WebRTCCodecPreference { .split(separator: "/", maxSplits: 1) .first .map(String.init) ?? "" - payloadNames[payloadType] = codecName + currentPayloadNames[payloadType] = codecName } + flushVideoMedia() - let summary = videoPayloadTypes.compactMap { payloadType -> String? in - guard let name = payloadNames[payloadType] else { return nil } - return "\(payloadType):\(name)" - } - return summary.isEmpty ? "none" : summary.joined(separator: ",") + let text = summary.isEmpty ? "none" : summary.joined(separator: ",") + return "\(text); unexpectedVideoCodecCount=\(unexpectedCodecCount)" } } @@ -436,7 +557,15 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { private struct RuntimeDiagnostics { var submittedFrameCount = 0 - var consumedFrameCount = 0 + var forwardedAV1FrameCount = 0 + } + + private struct CodecOutputState: Sendable, Equatable { + var sourceWidth: Int32 + var sourceHeight: Int32 + var outputWidth: Int32 + var outputHeight: Int32 + var framesPerSecond: Int } private static let sslInitialized: Void = { @@ -444,18 +573,19 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { }() private let factory: RTCPeerConnectionFactory - nonisolated(unsafe) package let videoSource: RTCVideoSource - nonisolated(unsafe) package let videoTrack: RTCVideoTrack - nonisolated(unsafe) private let capturer: RTCVideoCapturer + nonisolated(unsafe) private let av1VideoSource: RTCVideoSource + nonisolated(unsafe) package let av1VideoTrack: RTCVideoTrack + nonisolated(unsafe) private let av1Capturer: RTCVideoCapturer private let queue = DispatchQueue( label: "com.developerchen.voiddisplay.webrtc.media", qos: .userInitiated ) private let profile = Mutex(WebRTCStreamingProfile(performanceMode: .automatic)) + private let activeCodecs = Mutex>([]) private let runtimeDiagnostics = Mutex(RuntimeDiagnostics()) nonisolated(unsafe) private var frameMailbox: WebRTCFrameMailbox! - nonisolated(unsafe) private var lastFormat: (width: Int32, height: Int32, framesPerSecond: Int)? - nonisolated(unsafe) private var timestampSequencer = WebRTCFrameTimestampSequencer() + nonisolated(unsafe) private var av1OutputState: CodecOutputState? + nonisolated(unsafe) private var av1TimestampSequencer = WebRTCFrameTimestampSequencer() nonisolated package init() { _ = Self.sslInitialized @@ -463,9 +593,9 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory() ) - self.videoSource = factory.videoSource() - self.videoTrack = factory.videoTrack(with: videoSource, trackId: "screen-video-track") - self.capturer = RTCVideoCapturer(delegate: videoSource) + self.av1VideoSource = factory.videoSource() + self.av1VideoTrack = factory.videoTrack(with: av1VideoSource, trackId: "screen-video-av1") + self.av1Capturer = RTCVideoCapturer(delegate: av1VideoSource) self.frameMailbox = WebRTCFrameMailbox( scheduler: { [weak self] operation in self?.queue.async(execute: operation) @@ -488,9 +618,9 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { return factory.peerConnection(with: configuration, constraints: constraints, delegate: nil) } - nonisolated package func requiredH264Codecs() -> [RTCRtpCodecCapability]? { + nonisolated package func requiredCodecs(for codec: WebRTCVideoCodec) -> [RTCRtpCodecCapability]? { let capabilities = factory.rtpSenderCapabilities(forKind: kRTCMediaStreamTrackKindVideo) - return WebRTCCodecPreference.requiredH264Codecs(from: capabilities.codecs) + return WebRTCCodecPreference.requiredCodecs(for: codec, from: capabilities.codecs) } nonisolated package func senderVideoCodecCapabilitySummary() -> String { @@ -498,23 +628,34 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { return WebRTCCodecPreference.capabilitySummary(from: capabilities.codecs) } + nonisolated package func updateActiveCodecs(_ codecs: Set) { + activeCodecs.withLock { $0 = codecs } + queue.async { [weak self] in + guard let self, + let currentState = self.av1OutputState else { + return + } + self.adaptOutputFormats( + sourceWidth: currentState.sourceWidth, + sourceHeight: currentState.sourceHeight, + profile: self.profile.withLock { $0 }, + activeCodecs: codecs + ) + } + } + nonisolated package func updateEncodingProfile(_ profile: WebRTCStreamingProfile) { self.profile.withLock { $0 = profile } queue.async { [weak self] in - guard let self, let lastFormat = self.lastFormat else { return } - let dimensions = profile.outputDimensions( - forWidth: lastFormat.width, - height: lastFormat.height - ) - self.videoSource.adaptOutputFormat( - toWidth: dimensions.width, - height: dimensions.height, - fps: Int32(profile.framesPerSecond) - ) - self.lastFormat = ( - width: lastFormat.width, - height: lastFormat.height, - framesPerSecond: profile.framesPerSecond + guard let self, + let currentState = self.av1OutputState else { + return + } + self.adaptOutputFormats( + sourceWidth: currentState.sourceWidth, + sourceHeight: currentState.sourceHeight, + profile: profile, + activeCodecs: self.activeCodecs.withLock { $0 } ) } } @@ -537,38 +678,133 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { let width = Int32(CVPixelBufferGetWidth(pixelBuffer)) let height = Int32(CVPixelBufferGetHeight(pixelBuffer)) let currentProfile = profile.withLock { $0 } - let outputDimensions = currentProfile.outputDimensions(forWidth: width, height: height) - if lastFormat?.width != width || - lastFormat?.height != height || - lastFormat?.framesPerSecond != currentProfile.framesPerSecond { - videoSource.adaptOutputFormat( - toWidth: outputDimensions.width, - height: outputDimensions.height, - fps: Int32(currentProfile.framesPerSecond) + let currentActiveCodecs = activeCodecs.withLock { $0 } + adaptOutputFormats( + sourceWidth: width, + sourceHeight: height, + profile: currentProfile, + activeCodecs: currentActiveCodecs + ) + + for codec in WebRTCVideoCodec.allCases where currentActiveCodecs.contains(codec) { + forwardFrame( + codec: codec, + pixelBuffer: pixelBuffer, + ptsUs: pendingFrame.ptsUs, + sourceWidth: width, + sourceHeight: height, + profile: currentProfile ) - lastFormat = (width, height, currentProfile.framesPerSecond) } + } - let rtcBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer) - let timestampNs = timestampSequencer.nextTimestampNs( - ptsUs: pendingFrame.ptsUs, - framesPerSecond: currentProfile.framesPerSecond + private func adaptOutputFormats( + sourceWidth: Int32, + sourceHeight: Int32, + profile: WebRTCStreamingProfile, + activeCodecs: Set + ) { + for codec in WebRTCVideoCodec.allCases where activeCodecs.contains(codec) { + adaptOutputFormat(for: codec, sourceWidth: sourceWidth, sourceHeight: sourceHeight, profile: profile) + } + } + + private func adaptOutputFormat( + for codec: WebRTCVideoCodec, + sourceWidth: Int32, + sourceHeight: Int32, + profile: WebRTCStreamingProfile + ) { + let outputDimensions = profile.outputDimensions( + for: codec, + width: sourceWidth, + height: sourceHeight ) - let frame = RTCVideoFrame( - buffer: rtcBuffer, - rotation: ._0, - timeStampNs: timestampNs + let nextState = CodecOutputState( + sourceWidth: sourceWidth, + sourceHeight: sourceHeight, + outputWidth: outputDimensions.width, + outputHeight: outputDimensions.height, + framesPerSecond: profile.framesPerSecond(for: codec) ) - let shouldLogFirstForwardedFrame = runtimeDiagnostics.withLock { diagnostics -> Bool in - diagnostics.consumedFrameCount += 1 - return diagnostics.consumedFrameCount == 1 + switch codec { + case .av1 where av1OutputState == nextState: + return + default: + break } - if shouldLogFirstForwardedFrame { + videoSource(for: codec).adaptOutputFormat( + toWidth: outputDimensions.width, + height: outputDimensions.height, + fps: Int32(profile.framesPerSecond(for: codec)) + ) + switch codec { + case .av1: + av1OutputState = nextState + } + } + + private func forwardFrame( + codec: WebRTCVideoCodec, + pixelBuffer: CVPixelBuffer, + ptsUs: UInt64, + sourceWidth: Int32, + sourceHeight: Int32, + profile: WebRTCStreamingProfile + ) { + let rtcBuffer = RTCCVPixelBuffer(pixelBuffer: pixelBuffer) + let framesPerSecond = profile.framesPerSecond(for: codec) + let timestampNs = nextTimestampNs(for: codec, ptsUs: ptsUs, framesPerSecond: framesPerSecond) + let frame = RTCVideoFrame(buffer: rtcBuffer, rotation: ._0, timeStampNs: timestampNs) + let outputDimensions = profile.outputDimensions( + for: codec, + width: sourceWidth, + height: sourceHeight + ) + if shouldLogFirstForwardedFrame(for: codec) { AppLog.web.info( - "WebRTC media pipeline forwarded first RTC frame input=\(width, privacy: .public)x\(height, privacy: .public) output=\(outputDimensions.width, privacy: .public)x\(outputDimensions.height, privacy: .public) fps=\(currentProfile.framesPerSecond, privacy: .public) ptsUs=\(pendingFrame.ptsUs, privacy: .public) timestampNs=\(timestampNs, privacy: .public)." + "WebRTC media pipeline forwarded first \(codec.logName, privacy: .public) RTC frame input=\(sourceWidth, privacy: .public)x\(sourceHeight, privacy: .public) output=\(outputDimensions.width, privacy: .public)x\(outputDimensions.height, privacy: .public) fps=\(framesPerSecond, privacy: .public) ptsUs=\(ptsUs, privacy: .public) timestampNs=\(timestampNs, privacy: .public)." ) } - videoSource.capturer(capturer, didCapture: frame) + videoSource(for: codec).capturer(capturer(for: codec), didCapture: frame) + } + + private func nextTimestampNs( + for codec: WebRTCVideoCodec, + ptsUs: UInt64, + framesPerSecond: Int + ) -> Int64 { + switch codec { + case .av1: + av1TimestampSequencer.nextTimestampNs( + ptsUs: ptsUs, + framesPerSecond: framesPerSecond + ) + } + } + + private func shouldLogFirstForwardedFrame(for codec: WebRTCVideoCodec) -> Bool { + runtimeDiagnostics.withLock { diagnostics -> Bool in + switch codec { + case .av1: + diagnostics.forwardedAV1FrameCount += 1 + return diagnostics.forwardedAV1FrameCount == 1 + } + } + } + + private func videoSource(for codec: WebRTCVideoCodec) -> RTCVideoSource { + switch codec { + case .av1: + av1VideoSource + } + } + + private func capturer(for codec: WebRTCVideoCodec) -> RTCVideoCapturer { + switch codec { + case .av1: + av1Capturer + } } } diff --git a/Tests/VoidDisplayAppTests/TestSupport/TestSignalSessionHub.swift b/Tests/VoidDisplayAppTests/TestSupport/TestSignalSessionHub.swift index e64c8ed..5e8787f 100644 --- a/Tests/VoidDisplayAppTests/TestSupport/TestSignalSessionHub.swift +++ b/Tests/VoidDisplayAppTests/TestSupport/TestSignalSessionHub.swift @@ -83,6 +83,8 @@ final class TestSignalSessionHub: SignalSessionHub, @unchecked Sendable { } } + nonisolated func updateSourceVideoSpec(_: SourceVideoSpec) {} + nonisolated func updatePerformanceMode(_: CapturePerformanceMode) {} nonisolated func receiveSignalText(_ text: String, from connection: any SignalSocketConnection) { diff --git a/Tests/VoidDisplayCaptureTests/Services/DisplayCaptureProfileStateMachineTests.swift b/Tests/VoidDisplayCaptureTests/Services/DisplayCaptureProfileStateMachineTests.swift index 7acbbc5..7ac55b9 100644 --- a/Tests/VoidDisplayCaptureTests/Services/DisplayCaptureProfileStateMachineTests.swift +++ b/Tests/VoidDisplayCaptureTests/Services/DisplayCaptureProfileStateMachineTests.swift @@ -199,8 +199,8 @@ struct DisplayCaptureProfileStateMachineTests { } } - @Test func previewFrameRateClampCapsHighRefreshAndPreservesLowerRefresh() { - #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 144) == 60) + @Test func previewFrameRateKeepsHighRefreshAndPreservesFallback() { + #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 144) == 144) #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 50) == 50) #expect(DisplayCaptureSession.clampedPreviewFramesPerSecond(for: 0) == 60) } @@ -260,6 +260,27 @@ struct DisplayCaptureProfileStateMachineTests { performanceMode: .powerEfficient ) == .fps30 ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .shareOnly, + performanceMode: .automatic, + sourceFramesPerSecond: 90 + ).framesPerSecond == 90 + ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .mixed, + performanceMode: .smooth, + sourceFramesPerSecond: 120 + ).framesPerSecond == 120 + ) + #expect( + DisplayCaptureConfigurationStateMachine.defaultFrameRateTier( + for: .mixed, + performanceMode: .powerEfficient, + sourceFramesPerSecond: 120 + ) == .fps30 + ) } @Test func captureSizeContextAppliesSharedPixelBudgetByPerformanceMode() { @@ -269,8 +290,8 @@ struct DisplayCaptureProfileStateMachineTests { ) #expect(context.captureSize(for: .previewOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 3_840, height: 2_160)) - #expect(context.captureSize(for: .shareOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 2_560, height: 1_440)) - #expect(context.captureSize(for: .mixed, performanceMode: .automatic) == DisplayCaptureDimensions(width: 2_560, height: 1_440)) + #expect(context.captureSize(for: .shareOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 3_840, height: 2_160)) + #expect(context.captureSize(for: .mixed, performanceMode: .automatic) == DisplayCaptureDimensions(width: 3_840, height: 2_160)) #expect(context.captureSize(for: .shareOnly, performanceMode: .powerEfficient) == DisplayCaptureDimensions(width: 1_920, height: 1_080)) #expect(context.captureSize(for: .shareOnly, performanceMode: .smooth) == DisplayCaptureDimensions(width: 3_840, height: 2_160)) } @@ -294,8 +315,8 @@ struct DisplayCaptureProfileStateMachineTests { physicalSize: DisplayCaptureDimensions(width: 2_161, height: 3_841) ) - #expect(wideContext.captureSize(for: .shareOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 2_968, height: 1_242)) - #expect(portraitContext.captureSize(for: .shareOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 1_440, height: 2_560)) + #expect(wideContext.captureSize(for: .shareOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 3_440, height: 1_440)) + #expect(portraitContext.captureSize(for: .shareOnly, performanceMode: .automatic) == DisplayCaptureDimensions(width: 2_161, height: 3_841)) } @Test func automaticMixedModeKeeps60AcrossPreviewPressureWindows() { diff --git a/Tests/VoidDisplayCaptureTests/TestSupport/CaptureTestSupport.swift b/Tests/VoidDisplayCaptureTests/TestSupport/CaptureTestSupport.swift index b7ce79a..fba8165 100644 --- a/Tests/VoidDisplayCaptureTests/TestSupport/CaptureTestSupport.swift +++ b/Tests/VoidDisplayCaptureTests/TestSupport/CaptureTestSupport.swift @@ -35,6 +35,8 @@ final class TestDisplayShareFrameConsumer: DisplayShareFrameConsumer { nonisolated var hasDemand: Bool { false } + nonisolated func updateSourceVideoSpec(_: SourceVideoSpec) {} + nonisolated func updatePerformanceMode(_ mode: CapturePerformanceMode) { performanceModes.withLock { $0.append(mode) } } diff --git a/Tests/VoidDisplayFoundationTests/SharedCapturePerformanceBudgetTests.swift b/Tests/VoidDisplayFoundationTests/SharedCapturePerformanceBudgetTests.swift index 1dc280a..1055bcc 100644 --- a/Tests/VoidDisplayFoundationTests/SharedCapturePerformanceBudgetTests.swift +++ b/Tests/VoidDisplayFoundationTests/SharedCapturePerformanceBudgetTests.swift @@ -5,7 +5,7 @@ struct SharedCapturePerformanceBudgetTests { @Test func performanceModesMapToExpectedPixelBudgets() { #expect(SharedCapturePerformanceBudget(performanceMode: .automatic) == SharedCapturePerformanceBudget( framesPerSecond: 60, - pixelBudgetPerSecond: 221_184_000 + pixelBudgetPerSecond: nil )) #expect(SharedCapturePerformanceBudget(performanceMode: .smooth) == SharedCapturePerformanceBudget( framesPerSecond: 60, @@ -18,27 +18,27 @@ struct SharedCapturePerformanceBudgetTests { } @Test func pixelBudgetComputesEvenDimensionsWithoutUpscaling() { - let automaticBudget = SharedCapturePerformanceBudget(performanceMode: .automatic) + let powerEfficientBudget = SharedCapturePerformanceBudget(performanceMode: .powerEfficient) #expect( - automaticBudget.captureDimensions( + powerEfficientBudget.captureDimensions( for: CapturePixelDimensions(width: 3_840, height: 2_160) - ) == CapturePixelDimensions(width: 2_560, height: 1_440) + ) == CapturePixelDimensions(width: 1_920, height: 1_080) ) #expect( - automaticBudget.captureDimensions( + powerEfficientBudget.captureDimensions( for: CapturePixelDimensions(width: 1_920, height: 1_080) ) == CapturePixelDimensions(width: 1_920, height: 1_080) ) #expect( - automaticBudget.captureDimensions( + powerEfficientBudget.captureDimensions( for: CapturePixelDimensions(width: 3_440, height: 1_440) - ) == CapturePixelDimensions(width: 2_968, height: 1_242) + ) == CapturePixelDimensions(width: 2_224, height: 932) ) #expect( - automaticBudget.captureDimensions( + powerEfficientBudget.captureDimensions( for: CapturePixelDimensions(width: 2_161, height: 3_841) - ) == CapturePixelDimensions(width: 1_440, height: 2_560) + ) == CapturePixelDimensions(width: 1_080, height: 1_920) ) } diff --git a/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift b/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift index 42c555d..b733782 100644 --- a/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift +++ b/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift @@ -121,23 +121,46 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains(#"function localOfferSDPWithIceCredentials() {"#)) #expect(responseText.contains(#"async function waitForLocalOfferSDP() {"#)) #expect(responseText.contains(#"RTCRtpReceiver.getCapabilities("video")"#)) - #expect(responseText.contains(#"transceiver.setCodecPreferences(h264Preferences);"#)) - #expect(responseText.contains(#"assertH264AnswerSDP(payload.sdp);"#)) - #expect(responseText.contains(#"await verifySelectedH264Codec();"#)) + #expect(responseText.contains(#"function receiverCodecPreferences() {"#)) + #expect(responseText.contains(#"function receiverVideoCodecCapabilitySummary() {"#)) + #expect(responseText.contains(#""unsupportedVideoCodecCount=" + String(allCodecs.length - probedCodecCount)"#)) + #expect(responseText.contains(#"WebRTC receiver video capabilities "#)) + #expect(!responseText.contains(#"function initialForceH264Only() {"#)) + #expect(!responseText.contains(#"forceH264Only"#)) + #expect(responseText.contains(#"return normalizedVideoCodecName(codec) === "video/av1";"#)) + #expect(responseText.contains(#"function isRetransmissionCodec(codec) {"#)) + #expect(!responseText.contains(#"function receiverSupportsCodec(mimeType) {"#)) + #expect(responseText.contains(#"function matchingRtxCodecs(allCodecs, primaryCodecs) {"#)) + #expect(responseText.contains(#"return supportedCodecs.concat(matchingRtxCodecs(allCodecs, supportedCodecs));"#)) + #expect(responseText.contains(#"transceiver.setCodecPreferences(codecPreferences);"#)) + #expect(responseText.contains(#"const supportedPrimaryCodecs = [...new Set("#)) + #expect(responseText.contains(#"negotiatedVideoCodec = selectedCodecFromAnswerSDP(payload.sdp);"#)) + #expect(responseText.contains(#"await verifySelectedCodec(negotiatedVideoCodec || "av1");"#)) + #expect(responseText.contains(#"function isCodecErrorReason(reason) {"#)) + #expect(!responseText.contains(#"function shouldRetryWithH264Fallback() {"#)) + #expect(!responseText.contains(#"function retryWithH264Fallback() {"#)) + #expect(responseText.contains(#"reason === "unsupported_video_codec_offered" || reason === "supported_video_codec_missing""#)) + #expect(responseText.contains(#"if (codecMimeType === "video/rtx") {"#)) #expect(responseText.contains(#"peer.addTransceiver("video", { direction: "recvonly" });"#)) #expect(responseText.contains(#"sdp: await waitForLocalOfferSDP()"#)) #expect(!responseText.contains(#"sdp: offer.sdp"#)) #expect(responseText.contains(#"const reconnectDelays = [250, 500, 1000, 2000, 4000];"#)) - #expect(responseText.contains(#"function scheduleReconnect() {"#)) + #expect(responseText.contains(#"function scheduleReconnect(overlayTitle = t("overlayReconnectTitle"), overlayBody = t("overlayReconnectBody")) {"#)) + #expect(responseText.contains(#"function schedulePeerRetry(overlayTitle, overlayBody) {"#)) #expect(responseText.contains(#"peer = new RTCPeerConnection({ iceServers: bootstrap.iceServers ?? [] });"#)) - #expect(responseText.contains(#"setOverlay(t("overlayReconnectTitle"), t("overlayReconnectBody"), true);"#)) + #expect(responseText.contains(#"setOverlay(overlayTitle, overlayBody, true);"#)) #expect(responseText.contains(#"setOverlay(t("overlaySharingStoppedTitle"), t("overlaySharingStoppedBody"), true);"#)) #expect(responseText.contains(#"case "stopped":"#)) + #expect(responseText.contains(#"case "codec_pending":"#)) + #expect(responseText.contains(#"schedulePeerRetry(t("overlayCodecPendingTitle"), t("overlayCodecPendingBody"));"#)) #expect(responseText.contains(#"case "error":"#)) #expect(responseText.contains(#"connect();"#)) #expect(responseText.contains(#"heroEyebrow: "VOIDDISPLAY 实时画面""#)) - #expect(responseText.contains(#"overlayH264RequiredTitle: "H.264 required""#)) - #expect(responseText.contains(#"overlayH264RequiredTitle: "需要 H.264""#)) + #expect(responseText.contains(#"overlayCodecRequiredTitle: "AV1 required""#)) + #expect(responseText.contains(#"overlayCodecRequiredTitle: "需要 AV1""#)) + #expect(responseText.contains(#"overlayCodecPendingTitle: "Preparing video codec""#)) + #expect(responseText.contains(#"overlayCodecPendingTitle: "正在准备视频编码""#)) + #expect(!responseText.contains(#"overlayCodecFallbackTitle"#)) #expect(responseText.contains(#"fullscreenEnter: "全屏""#)) #expect(responseText.contains(#"pageTitle: "Screen Share""#)) #expect(responseText.contains("hero-eyebrow")) @@ -202,6 +225,15 @@ struct WebServerSocketIntegrationTests { #expect(smokeResult.documentTitle == "Screen Share") #expect(smokeResult.scaleButtonText == "Fit") #expect(smokeResult.toggleCallCount >= 2) + #expect(smokeResult.codecPreferenceMimeTypes == ["video/AV1", "video/rtx"]) + #expect(smokeResult.codecPreferenceFmtpLines == ["", "apt=98"]) + #expect(smokeResult.receiverCapabilitySummary.contains("AV1=video/av1(pt=98,fmtp=none)")) + #expect(smokeResult.receiverCapabilitySummary.contains("unsupportedVideoCodecCount=7")) + #expect(smokeResult.selectedCodec == "av1") + #expect(smokeResult.rejectsUnexpectedCodec) + #expect(smokeResult.marksMissingCodecAsRequirementError) + #expect(smokeResult.marksMissingCapabilitiesAsRequirementError) + #expect(smokeResult.belowSourceStatus.contains("source 2560×1440 60fps")) } @Test func oversizedIncompleteSignalFrameClosesConnection() async throws { @@ -832,6 +864,14 @@ private struct DisplayPageScriptSmokeResult: Equatable { let documentTitle: String let scaleButtonText: String let toggleCallCount: Int + let codecPreferenceMimeTypes: [String] + let codecPreferenceFmtpLines: [String] + let receiverCapabilitySummary: String + let selectedCodec: String + let rejectsUnexpectedCodec: Bool + let marksMissingCodecAsRequirementError: Bool + let marksMissingCapabilitiesAsRequirementError: Bool + let belowSourceStatus: String } @MainActor @@ -946,7 +986,8 @@ private func evaluateDisplayPageRuntimeScript( RTCPeerConnection: RTCPeerConnection, location: { protocol: "http:", - host: "127.0.0.1" + host: "127.0.0.1", + search: "" }, setTimeout: function() { return 1; }, clearTimeout: function() {}, @@ -955,6 +996,7 @@ private func evaluateDisplayPageRuntimeScript( var console = { log: function() {}, + info: function() {}, warn: function() {}, error: function() {} }; @@ -973,6 +1015,81 @@ private func evaluateDisplayPageRuntimeScript( """ transition("streaming"); __elements["scale-mode-btn"].__handlers["click"](); + function RTCRtpReceiver() {} + RTCRtpReceiver.getCapabilities = function() { + return { + codecs: [ + { mimeType: "video/VP8", payloadType: 96 }, + { mimeType: "video/AV1", payloadType: 98 }, + { mimeType: "video/H264", payloadType: 102 }, + { mimeType: "video/H265", payloadType: 116, sdpFmtpLine: "profile-id=1;tx-mode=SRST" }, + { mimeType: "video/rtx", sdpFmtpLine: "apt=96" }, + { mimeType: "video/rtx", sdpFmtpLine: "apt=98" }, + { mimeType: "video/rtx", sdpFmtpLine: "apt=102" }, + { mimeType: "video/rtx", sdpFmtpLine: "apt=116" } + ] + }; + }; + window.RTCRtpReceiver = RTCRtpReceiver; + var __codecPreferenceMimeTypes = receiverCodecPreferences().map(function(codec) { + return codec.mimeType; + }); + var __codecPreferenceFmtpLines = receiverCodecPreferences().map(function(codec) { + return codec.sdpFmtpLine || ""; + }); + var __receiverCapabilitySummary = receiverVideoCodecCapabilitySummary(); + var __selectedCodec = selectedCodecFromAnswerSDP( + "v=0\\r\\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 98 99\\r\\n" + + "a=rtpmap:98 AV1/90000\\r\\n" + + "a=rtpmap:99 rtx/90000\\r\\n" + ); + var __rejectsUnexpectedCodec = false; + try { + selectedCodecFromAnswerSDP( + "v=0\\r\\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 96\\r\\n" + + "a=rtpmap:96 VP8/90000\\r\\n" + + "m=video 9 UDP/TLS/RTP/SAVPF 102\\r\\n" + + "a=rtpmap:102 H264/90000\\r\\n" + ); + } catch (error) { + __rejectsUnexpectedCodec = true; + } + RTCRtpReceiver.getCapabilities = function() { + return { + codecs: [ + { mimeType: "video/VP8" } + ] + }; + }; + var __marksMissingCodecAsRequirementError = false; + try { + receiverCodecPreferences(); + } catch (error) { + __marksMissingCodecAsRequirementError = isCodecRequirementError(error); + } + delete window.RTCRtpReceiver; + var __marksMissingCapabilitiesAsRequirementError = false; + try { + receiverCodecPreferences(); + } catch (error) { + __marksMissingCapabilitiesAsRequirementError = isCodecRequirementError(error); + } + expectedSourceVideoSpec = sourceSpecFromSignal({ width: 2560, height: 1440, framesPerSecond: 60 }); + logBrowserVideoStats( + { + frameWidth: 1280, + frameHeight: 720, + framesPerSecond: 30, + framesDropped: 0, + jitter: 0, + packetsLost: 0 + }, + { mimeType: "video/AV1" }, + { bitrateBps: 4000000 } + ); + var __belowSourceStatus = __elements["status"].textContent; """ ) if let exceptionMessage { @@ -982,10 +1099,32 @@ private func evaluateDisplayPageRuntimeScript( let documentTitle = context.evaluateScript("document.title")?.toString() ?? "" let scaleButtonText = context.evaluateScript("__elements['scale-mode-btn'].textContent")?.toString() ?? "" let toggleCallCount = Int(context.evaluateScript("__toggleCount")?.toInt32() ?? 0) + let codecPreferenceMimeTypesJSON = context.evaluateScript("JSON.stringify(__codecPreferenceMimeTypes)")?.toString() ?? "[]" + let codecPreferenceMimeTypesData = Data(codecPreferenceMimeTypesJSON.utf8) + let codecPreferenceMimeTypes = (try? JSONDecoder().decode([String].self, from: codecPreferenceMimeTypesData)) ?? [] + let codecPreferenceFmtpLinesJSON = context.evaluateScript("JSON.stringify(__codecPreferenceFmtpLines)")?.toString() ?? "[]" + let codecPreferenceFmtpLinesData = Data(codecPreferenceFmtpLinesJSON.utf8) + let codecPreferenceFmtpLines = (try? JSONDecoder().decode([String].self, from: codecPreferenceFmtpLinesData)) ?? [] + let receiverCapabilitySummary = context.evaluateScript("__receiverCapabilitySummary")?.toString() ?? "" + let selectedCodec = context.evaluateScript("__selectedCodec")?.toString() ?? "" + let rejectsUnexpectedCodec = context.evaluateScript("__rejectsUnexpectedCodec")?.toBool() ?? false + let marksMissingCodecAsRequirementError = + context.evaluateScript("__marksMissingCodecAsRequirementError")?.toBool() ?? false + let marksMissingCapabilitiesAsRequirementError = + context.evaluateScript("__marksMissingCapabilitiesAsRequirementError")?.toBool() ?? false + let belowSourceStatus = context.evaluateScript("__belowSourceStatus")?.toString() ?? "" return DisplayPageScriptSmokeResult( documentTitle: documentTitle, scaleButtonText: scaleButtonText, - toggleCallCount: toggleCallCount + toggleCallCount: toggleCallCount, + codecPreferenceMimeTypes: codecPreferenceMimeTypes, + codecPreferenceFmtpLines: codecPreferenceFmtpLines, + receiverCapabilitySummary: receiverCapabilitySummary, + selectedCodec: selectedCodec, + rejectsUnexpectedCodec: rejectsUnexpectedCodec, + marksMissingCodecAsRequirementError: marksMissingCodecAsRequirementError, + marksMissingCapabilitiesAsRequirementError: marksMissingCapabilitiesAsRequirementError, + belowSourceStatus: belowSourceStatus ) } diff --git a/Tests/VoidDisplaySharingTests/TestSupport/TestSignalSessionHub.swift b/Tests/VoidDisplaySharingTests/TestSupport/TestSignalSessionHub.swift index e64c8ed..5e8787f 100644 --- a/Tests/VoidDisplaySharingTests/TestSupport/TestSignalSessionHub.swift +++ b/Tests/VoidDisplaySharingTests/TestSupport/TestSignalSessionHub.swift @@ -83,6 +83,8 @@ final class TestSignalSessionHub: SignalSessionHub, @unchecked Sendable { } } + nonisolated func updateSourceVideoSpec(_: SourceVideoSpec) {} + nonisolated func updatePerformanceMode(_: CapturePerformanceMode) {} nonisolated func receiveSignalText(_ text: String, from connection: any SignalSocketConnection) { diff --git a/Tests/VoidDisplaySharingTests/Web/RelayHTTPClientTests.swift b/Tests/VoidDisplaySharingTests/Web/RelayHTTPClientTests.swift index 9c8e335..2f9fd76 100644 --- a/Tests/VoidDisplaySharingTests/Web/RelayHTTPClientTests.swift +++ b/Tests/VoidDisplaySharingTests/Web/RelayHTTPClientTests.swift @@ -60,6 +60,53 @@ struct RelayHTTPClientTests { #expect(requests.map(\.method) == ["POST", "DELETE"]) #expect(requests.first?.body.contains(#""candidate":"candidate:1""#) == true) } + + @Test func viewerOfferExposesRelayErrorReason() async throws { + let transport = RelayHTTPClientMockTransport( + responses: [ + MockHTTPResponse( + statusCode: 400, + body: #"{"type":"error","reason":"publisher_codec_pending"}"# + ) + ] + ) + let client = RelayHTTPClient( + baseURL: URL(string: "http://relay.test")!, + controlToken: "token", + session: transport.session + ) + + do { + _ = try await client.viewerOffer(roomID: "2", clientID: "viewer-1", sdp: "viewer-offer") + Issue.record("viewerOffer succeeded for relay error response") + } catch let error as RelayHTTPError { + #expect(error == .httpStatus(400, "publisher_codec_pending")) + #expect(error.relayReason == "publisher_codec_pending") + } + } + + @Test func viewerOfferDecodesSelectedCodec() async throws { + let transport = RelayHTTPClientMockTransport( + responses: [ + MockHTTPResponse( + statusCode: 200, + body: #"{"type":"answer","sdp":"viewer-answer","codec":"av1"}"# + ) + ] + ) + let client = RelayHTTPClient( + baseURL: URL(string: "http://relay.test")!, + controlToken: "token", + session: transport.session + ) + + let response = try await client.viewerOffer(roomID: "2", clientID: "viewer-1", sdp: "viewer-offer") + + #expect(response == RelayViewerOfferResponse(sdp: "viewer-answer", codec: .av1)) + let request = try #require(transport.requests().first) + #expect(request.path == "/room/2/viewer/viewer-1") + #expect(request.body.contains(#""sdp":"viewer-offer""#)) + } } private struct MockHTTPResponse: Sendable { diff --git a/Tests/VoidDisplaySharingTests/Web/RelaySessionHubTests.swift b/Tests/VoidDisplaySharingTests/Web/RelaySessionHubTests.swift index 1cf1841..d9cae82 100644 --- a/Tests/VoidDisplaySharingTests/Web/RelaySessionHubTests.swift +++ b/Tests/VoidDisplaySharingTests/Web/RelaySessionHubTests.swift @@ -53,11 +53,11 @@ private final class FakeRelayClient: RelayHTTPClienting, @unchecked Sendable { } private let state = Mutex(State()) - private let onViewerOffer: (@Sendable () async -> Void)? + private let onViewerOffer: (@Sendable () async throws -> Void)? private let onViewerCandidate: (@Sendable () async -> Void)? init( - onViewerOffer: (@Sendable () async -> Void)? = nil, + onViewerOffer: (@Sendable () async throws -> Void)? = nil, onViewerCandidate: (@Sendable () async -> Void)? = nil ) { self.onViewerOffer = onViewerOffer @@ -79,10 +79,10 @@ private final class FakeRelayClient: RelayHTTPClienting, @unchecked Sendable { state.withLock { $0.publisherCandidates.append((roomID, publisherID, candidate)) } } - nonisolated func viewerOffer(roomID: String, clientID: String, sdp: String) async throws -> String { + nonisolated func viewerOffer(roomID: String, clientID: String, sdp: String) async throws -> RelayViewerOfferResponse { state.withLock { $0.viewerOffers.append((roomID, clientID, sdp)) } - await onViewerOffer?() - return "relay-viewer-answer-\(clientID)" + try await onViewerOffer?() + return RelayViewerOfferResponse(sdp: "relay-viewer-answer-\(clientID)", codec: .av1) } nonisolated func viewerCandidate( @@ -126,6 +126,7 @@ private final class FakePublisherSession: RelayPublisherSessioning, @unchecked S var startCallCount = 0 var closeCallCount = 0 var profiles: [WebRTCStreamingProfile] = [] + var activeCodecs: [Set] = [] } private let state = Mutex(State()) @@ -150,6 +151,10 @@ private final class FakePublisherSession: RelayPublisherSessioning, @unchecked S state.withLock { $0.profiles.append(profile) } } + nonisolated func updateActiveCodecs(_ activeCodecs: Set) { + state.withLock { $0.activeCodecs.append(activeCodecs) } + } + nonisolated func submitFrame(pixelBuffer _: CVPixelBuffer, ptsUs _: UInt64) {} nonisolated func close() { @@ -167,6 +172,10 @@ private final class FakePublisherSession: RelayPublisherSessioning, @unchecked S func profiles() -> [WebRTCStreamingProfile] { state.withLock { $0.profiles } } + + func activeCodecs() -> [Set] { + state.withLock { $0.activeCodecs } + } } private final class PublisherFactoryRecorder: @unchecked Sendable { @@ -201,6 +210,10 @@ private final class RelayHubBox: @unchecked Sendable { func updatePerformanceMode(_ mode: CapturePerformanceMode) { state.withLock { $0 }?.updatePerformanceMode(mode) } + + func updateSourceVideoSpec(_ spec: SourceVideoSpec) { + state.withLock { $0 }?.updateSourceVideoSpec(spec) + } } private final class AsyncGate: @unchecked Sendable { @@ -285,6 +298,11 @@ struct RelaySessionHubTests { #expect(client.viewerOffers().first?.sdp == "viewer-offer") let answer = try #require(socket.decodedTextPayloads().first(where: { $0.contains(#""type":"answer""#) })) #expect(answer.contains(#""sdp":"relay-viewer-answer-viewer-1""#)) + #expect(answer.contains(#""sourceVideoSpec""#)) + #expect(answer.contains(#""width":1920"#)) + #expect(answer.contains(#""height":1080"#)) + #expect(answer.contains(#""framesPerSecond":60"#)) + #expect(await waitUntil { factory.records().first?.publisher.activeCodecs().contains(Set([.av1])) == true }) } @MainActor @Test func staleViewerOfferCleansRelayViewerAndDoesNotSendAnswer() async { @@ -309,6 +327,54 @@ struct RelaySessionHubTests { #expect(socket.decodedTextPayloads().contains(where: { $0.contains(#""type":"answer""#) }) == false) } + @MainActor @Test func publisherCodecPendingSendsRetryMessageAndKeepsViewerConnected() async throws { + let client = FakeRelayClient(onViewerOffer: { + throw RelayHTTPError.httpStatus(400, "publisher_codec_pending") + }) + let factory = PublisherFactoryRecorder() + let demandEvents = Mutex<[Bool]>([]) + let hub = RelaySessionHub( + onDemandChanged: { value in demandEvents.withLock { $0.append(value) } }, + relayClientProvider: { client }, + publisherFactory: factory.make + ) + let socket = RelayTestSocketConnection() + #expect(isRelayAccepted(hub.addClient(socket, target: .id(2), makeClientID: { "viewer-1" }, eventSink: { _ in }))) + + hub.receiveSignalText(#"{"type":"offer","sdp":"viewer-offer"}"#, from: socket) + + #expect(await waitUntil { client.viewerOffers().count == 1 }) + #expect(await waitUntil { socket.decodedTextPayloads().contains(where: { $0.contains(#""type":"codec_pending""#) }) }) + #expect(await waitUntil { factory.records().count == 1 }) + #expect(socket.cancelCallCount == 0) + #expect(hub.activeClientCount == 1) + #expect(factory.records().first?.publisher.closeCallCount() == 0) + #expect(demandEvents.withLock { $0 } == [true]) + let codecPending = try #require(socket.decodedTextPayloads().first(where: { $0.contains(#""type":"codec_pending""#) })) + #expect(codecPending.contains(#""reason":"publisher_codec_pending""#)) + #expect(socket.decodedTextPayloads().contains(where: { $0.contains(#""type":"error""#) }) == false) + } + + @MainActor @Test func viewerCodecRelayErrorForwardsSpecificReason() async throws { + let client = FakeRelayClient(onViewerOffer: { + throw RelayHTTPError.httpStatus(400, "unsupported_video_codec_offered") + }) + let factory = PublisherFactoryRecorder() + let hub = RelaySessionHub( + relayClientProvider: { client }, + publisherFactory: factory.make + ) + let socket = RelayTestSocketConnection() + #expect(isRelayAccepted(hub.addClient(socket, target: .id(2), makeClientID: { "viewer-1" }, eventSink: { _ in }))) + + hub.receiveSignalText(#"{"type":"offer","sdp":"viewer-offer"}"#, from: socket) + + #expect(await waitUntil { client.viewerOffers().count == 1 }) + #expect(await waitUntil { socket.cancelCallCount == 1 }) + let error = try #require(socket.decodedTextPayloads().first(where: { $0.contains(#""type":"error""#) })) + #expect(error.contains(#""reason":"unsupported_video_codec_offered""#)) + } + @MainActor @Test func tenViewersCreateOnePublisherSession() async { let client = FakeRelayClient() let factory = PublisherFactoryRecorder() @@ -349,6 +415,28 @@ struct RelaySessionHubTests { #expect(factory.records().count == 1) } + @MainActor @Test func sourceSpecChangeUpdatesPublisherProfile() async { + let client = FakeRelayClient() + let factory = PublisherFactoryRecorder() + let hub = RelaySessionHub( + relayClientProvider: { client }, + publisherFactory: factory.make + ) + let socket = RelayTestSocketConnection() + #expect(isRelayAccepted(hub.addClient(socket, target: .id(2), makeClientID: { "viewer-1" }, eventSink: { _ in }))) + #expect(await waitUntil { factory.records().count == 1 }) + + let sourceSpec = SourceVideoSpec(width: 2_560, height: 1_440, framesPerSecond: 120) + hub.updateSourceVideoSpec(sourceSpec) + + let expectedProfile = WebRTCStreamingProfile( + performanceMode: .automatic, + sourceVideoSpec: sourceSpec + ) + let publisher = factory.records().first?.publisher + #expect(publisher?.profiles().contains(expectedProfile) == true) + } + @MainActor @Test func publisherStartupRefreshesProfileOutsideStateLock() async { let client = FakeRelayClient() let hubBox = RelayHubBox() diff --git a/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift b/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift index 2e5d22a..96c8ba3 100644 --- a/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift +++ b/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift @@ -34,68 +34,47 @@ private final class MailboxReference: @unchecked Sendable { } struct WebRTCSessionSupportTests { - @Test func streamingProfilesMatchPerformanceModes() { - #expect(WebRTCStreamingProfile(performanceMode: .smooth) == WebRTCStreamingProfile( - framesPerSecond: 60, - minBitrateBps: 1_500_000, - maxBitrateBps: 8_000_000, - pixelBudgetPerSecond: WebRTCStreamingProfile.h264SmoothPixelBudgetPerSecond - )) - #expect(WebRTCStreamingProfile(performanceMode: .automatic) == WebRTCStreamingProfile( - framesPerSecond: 30, - minBitrateBps: 1_500_000, - maxBitrateBps: 8_000_000, - pixelBudgetPerSecond: WebRTCStreamingProfile.h264AutomaticPixelBudgetPerSecond - )) - #expect(WebRTCStreamingProfile(performanceMode: .powerEfficient) == WebRTCStreamingProfile( - framesPerSecond: 30, - minBitrateBps: 800_000, - maxBitrateBps: 5_000_000, - pixelBudgetPerSecond: WebRTCStreamingProfile.h264PowerEfficientPixelBudgetPerSecond - )) + @Test func automaticAndSmoothProfilesUseSourceSpecForAV1() { + let sourceSpec = SourceVideoSpec(width: 2_560, height: 1_440, framesPerSecond: 60) + for mode in [CapturePerformanceMode.automatic, .smooth] { + let profile = WebRTCStreamingProfile(performanceMode: mode, sourceVideoSpec: sourceSpec) + let av1Dimensions = profile.outputDimensions(for: .av1, width: 2_560, height: 1_440) + + #expect(profile.framesPerSecond == 60) + #expect(profile.framesPerSecond(for: .av1) == 60) + #expect(profile.pixelBudgetPerSecond == nil) + #expect(av1Dimensions.width == 2_560) + #expect(av1Dimensions.height == 1_440) + #expect(profile.outputVideoSpec(for: .av1) == sourceSpec) + } } - @Test func streamingProfilesApplyPixelBudgetByMode() { - let smoothDimensions = WebRTCStreamingProfile(performanceMode: .smooth) - .outputDimensions(forWidth: 3_840, height: 2_160) - let automaticDimensions = WebRTCStreamingProfile(performanceMode: .automatic) - .outputDimensions(forWidth: 3_840, height: 2_160) - let powerEfficientDimensions = WebRTCStreamingProfile(performanceMode: .powerEfficient) - .outputDimensions(forWidth: 3_840, height: 2_160) - let wideDimensions = WebRTCStreamingProfile(performanceMode: .automatic) - .outputDimensions(forWidth: 3_440, height: 1_440) - let portraitDimensions = WebRTCStreamingProfile(performanceMode: .automatic) - .outputDimensions(forWidth: 2_160, height: 3_840) - - #expect(smoothDimensions.width == 886) - #expect(smoothDimensions.height == 498) - #expect(automaticDimensions.width == 1_254) - #expect(automaticDimensions.height == 704) - #expect(powerEfficientDimensions.width == 960) - #expect(powerEfficientDimensions.height == 540) - #expect(wideDimensions.width == 1_454) - #expect(wideDimensions.height == 608) - #expect(portraitDimensions.width == 704) - #expect(portraitDimensions.height == 1_254) + @Test func powerEfficientProfileKeepsOnlyActiveDowngradeBudget() { + let sourceSpec = SourceVideoSpec(width: 2_560, height: 1_440, framesPerSecond: 60) + let profile = WebRTCStreamingProfile(performanceMode: .powerEfficient, sourceVideoSpec: sourceSpec) + let dimensions = profile.outputDimensions(for: .av1, width: 2_560, height: 1_440) + + #expect(profile.framesPerSecond == 30) + #expect(profile.pixelBudgetPerSecond == SharedCapturePerformanceBudget.powerEfficientPixelBudgetPerSecond) + #expect(dimensions.width == 1_920) + #expect(dimensions.height == 1_080) } - @Test func streamingProfileOutputFitsH264Level31MacroblockBudget() { - let samples: [(WebRTCStreamingProfile, Int32, Int32)] = [ - (WebRTCStreamingProfile(performanceMode: .smooth), 3_840, 2_160), - (WebRTCStreamingProfile(performanceMode: .automatic), 3_840, 2_160), - (WebRTCStreamingProfile(performanceMode: .powerEfficient), 3_840, 2_160), - (WebRTCStreamingProfile(performanceMode: .automatic), 2_428, 1_518), - (WebRTCStreamingProfile(performanceMode: .smooth), 2_428, 1_518), - (WebRTCStreamingProfile(performanceMode: .automatic), 2_160, 3_840), - ] + @Test func bitrateProfilesScaleWithAV1PixelRate() { + let profile1440p60 = WebRTCStreamingProfile( + performanceMode: .automatic, + sourceVideoSpec: SourceVideoSpec(width: 2_560, height: 1_440, framesPerSecond: 60) + ) + let profile4K60 = WebRTCStreamingProfile( + performanceMode: .automatic, + sourceVideoSpec: SourceVideoSpec(width: 3_840, height: 2_160, framesPerSecond: 60) + ) - for (profile, width, height) in samples { - let dimensions = profile.outputDimensions(forWidth: width, height: height) - let macroblocks = macroblockCount(width: dimensions.width, height: dimensions.height) + let av1Limits = profile1440p60.bitrateLimits(for: .av1, outputWidth: 2_560, outputHeight: 1_440) + let av14KLimits = profile4K60.bitrateLimits(for: .av1, outputWidth: 3_840, outputHeight: 2_160) - #expect(macroblocks <= 3_600) - #expect(macroblocks * profile.framesPerSecond <= 108_000) - } + #expect(av1Limits.maxBitrateBps == 22_118_400) + #expect(av14KLimits.maxBitrateBps == 49_766_400) } #if canImport(WebRTC) @@ -114,7 +93,7 @@ struct WebRTCSessionSupportTests { #expect(sequencer.nextTimestampNs(ptsUs: 0, framesPerSecond: 30) == 49_999_999) } - @Test func h264CodecPreferenceKeepsOnlyH264AndMatchingRtx() { + @Test func codecPreferenceKeepsOnlyRequestedCodecAndMatchingRtx() { let descriptors = [ WebRTCCodecPreferenceDescriptor( name: kRTCVp8CodecName, @@ -127,19 +106,19 @@ struct WebRTCSessionSupportTests { parameters: ["apt": "96"] ), WebRTCCodecPreferenceDescriptor( - name: kRTCH264CodecName, - payloadType: 102, + name: "AV1", + payloadType: 104, parameters: [:] ), WebRTCCodecPreferenceDescriptor( name: kRTCRtxCodecName, - payloadType: 103, - parameters: ["apt": "102"] + payloadType: 105, + parameters: ["apt": "104"] ), ] - #expect(WebRTCCodecPreference.requiredH264DescriptorIndexes(from: descriptors) == [2, 3]) - #expect(WebRTCCodecPreference.requiredH264DescriptorIndexes(from: Array(descriptors.prefix(2))) == nil) + #expect(WebRTCCodecPreference.requiredDescriptorIndexes(for: .av1, from: descriptors) == [2, 3]) + #expect(WebRTCCodecPreference.requiredDescriptorIndexes(for: .av1, from: Array(descriptors.prefix(2))) == nil) } @Test func sdpVideoCodecSummaryListsVideoPayloadNames() { @@ -147,20 +126,33 @@ struct WebRTCSessionSupportTests { v=0 m=audio 9 UDP/TLS/RTP/SAVPF 111 a=rtpmap:111 opus/48000/2 + m=video 9 UDP/TLS/RTP/SAVPF 104 105 + a=rtpmap:104 AV1/90000 + a=rtpmap:105 rtx/90000 m=video 9 UDP/TLS/RTP/SAVPF 102 103 - a=rtpmap:102 H264/90000 + a=rtpmap:102 VP8/90000 a=rtpmap:103 rtx/90000 """ - #expect(WebRTCCodecPreference.sdpVideoCodecSummary(from: sdp) == "102:H264,103:rtx") + #expect(WebRTCCodecPreference.sdpVideoCodecSummary(from: sdp) == "104:AV1,105:rtx,103:rtx; unexpectedVideoCodecCount=1") + + let reusedPayloadTypeSDP = """ + v=0 + m=video 9 UDP/TLS/RTP/SAVPF 96 + a=rtpmap:96 AV1/90000 + m=video 9 UDP/TLS/RTP/SAVPF 96 + a=rtpmap:96 VP8/90000 + """ + + #expect(WebRTCCodecPreference.sdpVideoCodecSummary(from: reusedPayloadTypeSDP) == "96:AV1; unexpectedVideoCodecCount=1") } - @Test func capabilitySummaryDoesNotPrintNonH264CodecNames() { + @Test func capabilitySummaryPrintsAV1ProbeWithoutUnsupportedCodecNames() { let descriptors = [ WebRTCCodecPreferenceDescriptor( - name: kRTCH264CodecName, - payloadType: 102, - parameters: ["profile-level-id": "42e01f"] + name: "AV1", + payloadType: 35, + parameters: [:] ), WebRTCCodecPreferenceDescriptor( name: kRTCVp8CodecName, @@ -171,10 +163,19 @@ struct WebRTCSessionSupportTests { let summary = WebRTCCodecPreference.capabilitySummary(from: descriptors) - #expect(summary.contains("H264")) - #expect(summary.contains("nonH264CodecCount=1")) + #expect(summary.contains("AV1=AV1(pt=35,fmtp=none)")) + #expect(summary.contains("unsupportedVideoCodecCount=1")) #expect(!summary.contains("VP8")) } + + @Test func runtimeSenderCapabilityProbePrintsCurrentWebRTCBinaryCodecs() { + let pipeline = WebRTCMediaPipeline() + let summary = pipeline.senderVideoCodecCapabilitySummary() + + print("VoidDisplay WebRTC sender codec capability probe: \(summary)") + #expect(summary.contains("AV1=")) + #expect(summary.contains("unsupportedVideoCodecCount=")) + } #endif @Test func frameMailboxKeepsOnlyLatestPendingFrameBeforeDrainStarts() { @@ -218,9 +219,3 @@ struct WebRTCSessionSupportTests { #expect(scheduler.count() == 0) } } - -private func macroblockCount(width: Int32, height: Int32) -> Int { - let macroblockWidth = (Int(width) + 15) / 16 - let macroblockHeight = (Int(height) + 15) / 16 - return macroblockWidth * macroblockHeight -} diff --git a/Tests/VoidDisplayTestingSupport/TestDisplayShareFrameConsumer.swift b/Tests/VoidDisplayTestingSupport/TestDisplayShareFrameConsumer.swift index 0d93ad5..8399295 100644 --- a/Tests/VoidDisplayTestingSupport/TestDisplayShareFrameConsumer.swift +++ b/Tests/VoidDisplayTestingSupport/TestDisplayShareFrameConsumer.swift @@ -6,6 +6,10 @@ package final class TestDisplayShareFrameConsumer: DisplayShareFrameConsumer { package nonisolated var hasDemand: Bool { false } + package nonisolated func updateSourceVideoSpec(_ spec: SourceVideoSpec) { + _ = spec + } + package nonisolated func updatePerformanceMode(_ mode: CapturePerformanceMode) { _ = mode } diff --git a/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main.go b/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main.go index af40f15..acdc6f0 100644 --- a/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main.go +++ b/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "syscall" + "time" "voiddisplay-relay/internal/relay" ) @@ -16,9 +17,11 @@ func main() { var controlToken string var loopbackHTTP string var listenUDP string + var parentPID int flag.StringVar(&controlToken, "control-token", "", "shared control token for loopback requests") flag.StringVar(&loopbackHTTP, "loopback-http", "127.0.0.1:0", "loopback HTTP listen address") flag.StringVar(&listenUDP, "listen-udp", ":0", "UDP4 listen address for WebRTC traffic") + flag.IntVar(&parentPID, "parent-pid", 0, "parent process identifier to monitor") flag.Parse() logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})) @@ -30,6 +33,9 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + if parentPID > 1 { + go monitorParentProcess(ctx, stop, parentPID, logger) + } err := server.ListenAndServe(ctx, loopbackHTTP, func(loopback string) { fmt.Fprintln(os.Stdout, relay.ReadyJSON(loopback)) @@ -39,3 +45,30 @@ func main() { os.Exit(1) } } + +func monitorParentProcess(ctx context.Context, cancel context.CancelFunc, parentPID int, logger *slog.Logger) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if os.Getppid() == 1 { + logger.Info("relay parent process exited", "parent_pid", parentPID) + cancel() + return + } + err := syscall.Kill(parentPID, 0) + if err == nil || err == syscall.EPERM { + continue + } + if err == syscall.ESRCH { + logger.Info("relay parent process is no longer alive", "parent_pid", parentPID) + cancel() + return + } + logger.Warn("relay parent process check failed", "parent_pid", parentPID, "error", err) + } + } +} diff --git a/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main_test.go b/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main_test.go new file mode 100644 index 0000000..8de2080 --- /dev/null +++ b/Tools/VoidDisplayRelay/cmd/voiddisplay-relay/main_test.go @@ -0,0 +1,23 @@ +package main + +import ( + "context" + "io" + "log/slog" + "testing" + "time" +) + +func TestMonitorParentProcessCancelsWhenParentMissing(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + + go monitorParentProcess(ctx, cancel, 1<<30, logger) + + select { + case <-ctx.Done(): + case <-time.After(2500 * time.Millisecond): + t.Fatal("expected missing parent process to cancel relay context") + } +} diff --git a/Tools/VoidDisplayRelay/internal/relay/server.go b/Tools/VoidDisplayRelay/internal/relay/server.go index 5b092b7..d8b889f 100644 --- a/Tools/VoidDisplayRelay/internal/relay/server.go +++ b/Tools/VoidDisplayRelay/internal/relay/server.go @@ -9,13 +9,13 @@ import ( "log/slog" "net" "net/http" - "strconv" "strings" "sync" "sync/atomic" "time" "github.com/pion/ice/v4" + "github.com/pion/interceptor" "github.com/pion/rtcp" "github.com/pion/rtp" pionsdp "github.com/pion/sdp/v3" @@ -56,6 +56,7 @@ type candidateRequest struct { type signalResponse struct { Type string `json:"type"` SDP string `json:"sdp,omitempty"` + Codec string `json:"codec,omitempty"` Reason string `json:"reason,omitempty"` } @@ -77,152 +78,61 @@ type Snapshot struct { } type RoomSnapshot struct { - ID string `json:"id"` - HasPublisher bool `json:"hasPublisher"` - PublisherID string `json:"publisherID,omitempty"` - SubscriberCount int `json:"subscriberCount"` - PublisherPacketCount uint64 `json:"publisherPacketCount"` - ForwardedPacketCount uint64 `json:"forwardedPacketCount"` - WrittenPacketCount uint64 `json:"writtenPacketCount"` - DroppedPacketCount uint64 `json:"droppedPacketCount"` - SlowSubscriberCount int `json:"slowSubscriberCount"` - PLIForwardCount uint64 `json:"pliForwardCount"` - FIRForwardCount uint64 `json:"firForwardCount"` -} - -var errH264VideoCodecMissing = errors.New("h264_video_codec_missing") - -var h264RTCPFeedback = []webrtc.RTCPFeedback{ + ID string `json:"id"` + HasPublisher bool `json:"hasPublisher"` + PublisherID string `json:"publisherID,omitempty"` + PublisherCodecs []string `json:"publisherCodecs,omitempty"` + SubscriberCodecCounts map[string]int `json:"subscriberCodecCounts,omitempty"` + SubscriberCount int `json:"subscriberCount"` + PublisherPacketCount uint64 `json:"publisherPacketCount"` + ForwardedPacketCount uint64 `json:"forwardedPacketCount"` + WrittenPacketCount uint64 `json:"writtenPacketCount"` + DroppedPacketCount uint64 `json:"droppedPacketCount"` + SlowSubscriberCount int `json:"slowSubscriberCount"` + PLIForwardCount uint64 `json:"pliForwardCount"` + FIRForwardCount uint64 `json:"firForwardCount"` + NACKForwardCount uint64 `json:"nackForwardCount"` +} + +type videoCodec string + +const ( + videoCodecAV1 videoCodec = "av1" +) + +var errSupportedVideoCodecMissing = errors.New("supported_video_codec_missing") +var errUnsupportedVideoCodecOffered = errors.New("unsupported_video_codec_offered") +var errPublisherCodecPending = errors.New("publisher_codec_pending") +var errPublisherCodecDuplicate = errors.New("publisher_video_codec_duplicate") + +var av1RTCPFeedback = []webrtc.RTCPFeedback{ {Type: "goog-remb"}, {Type: "ccm", Parameter: "fir"}, {Type: "nack"}, {Type: "nack", Parameter: "pli"}, } -var h264CodecParameters = []webrtc.RTPCodecParameters{ - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f", - RTCPFeedback: h264RTCPFeedback, - }, - PayloadType: 102, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, - ClockRate: 90000, - SDPFmtpLine: "apt=102", - }, - PayloadType: 103, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f", - RTCPFeedback: h264RTCPFeedback, - }, - PayloadType: 104, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, - ClockRate: 90000, - SDPFmtpLine: "apt=104", - }, - PayloadType: 105, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f", - RTCPFeedback: h264RTCPFeedback, - }, - PayloadType: 106, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, - ClockRate: 90000, - SDPFmtpLine: "apt=106", - }, - PayloadType: 107, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f", - RTCPFeedback: h264RTCPFeedback, - }, - PayloadType: 108, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, - ClockRate: 90000, - SDPFmtpLine: "apt=108", - }, - PayloadType: 109, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f", - RTCPFeedback: h264RTCPFeedback, - }, - PayloadType: 127, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, - ClockRate: 90000, - SDPFmtpLine: "apt=127", - }, - PayloadType: 125, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f", - RTCPFeedback: h264RTCPFeedback, - }, - PayloadType: 39, - }, +var av1CodecParameters = []webrtc.RTPCodecParameters{ { RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeRTX, - ClockRate: 90000, - SDPFmtpLine: "apt=39", - }, - PayloadType: 40, - }, - { - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, + MimeType: webrtc.MimeTypeAV1, ClockRate: 90000, - SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f", - RTCPFeedback: h264RTCPFeedback, + RTCPFeedback: av1RTCPFeedback, }, - PayloadType: 112, + PayloadType: 45, }, { RTPCodecCapability: webrtc.RTPCodecCapability{ MimeType: webrtc.MimeTypeRTX, ClockRate: 90000, - SDPFmtpLine: "apt=112", + SDPFmtpLine: "apt=45", }, - PayloadType: 113, + PayloadType: 46, }, } -func registerH264Codecs(mediaEngine *webrtc.MediaEngine) error { - for _, codec := range h264CodecParameters { +func registerVideoCodecs(mediaEngine *webrtc.MediaEngine) error { + for _, codec := range av1CodecParameters { if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { return err } @@ -230,42 +140,220 @@ func registerH264Codecs(mediaEngine *webrtc.MediaEngine) error { return nil } -func h264TrackCapability() webrtc.RTPCodecCapability { +func trackCapability(codec videoCodec) (webrtc.RTPCodecCapability, error) { + if codec != videoCodecAV1 { + return webrtc.RTPCodecCapability{}, errUnsupportedVideoCodecOffered + } return webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, + MimeType: webrtc.MimeTypeAV1, ClockRate: 90000, - RTCPFeedback: h264RTCPFeedback, + RTCPFeedback: av1RTCPFeedback, + }, nil +} + +func codecParametersForVideoCodec(codec videoCodec) ([]webrtc.RTPCodecParameters, error) { + if codec != videoCodecAV1 { + return nil, errUnsupportedVideoCodecOffered + } + return append([]webrtc.RTPCodecParameters(nil), av1CodecParameters...), nil +} + +func codecFromName(name string) (videoCodec, bool) { + switch { + case strings.EqualFold(name, "AV1"), strings.EqualFold(name, webrtc.MimeTypeAV1): + return videoCodecAV1, true + default: + return "", false } } -func requireH264VideoCodec(sdp string) error { +type videoMediaCodecSet struct { + codecs map[videoCodec]struct{} + unsupportedPrimaryCount int +} + +func videoMediaCodecSets(sdp string) ([]videoMediaCodecSet, error) { var description pionsdp.SessionDescription if err := description.UnmarshalString(sdp); err != nil { - return err - } - for _, media := range description.MediaDescriptions { - if !strings.EqualFold(media.MediaName.Media, "video") { - continue + return nil, err + } + mediaCodecSets := make([]videoMediaCodecSet, 0) + payloadTypes := make(map[string]struct{}) + payloadNames := make(map[string]string) + inVideo := false + flush := func() { + if !inVideo { + return } - payloadTypes := make([]uint8, 0, len(media.MediaName.Formats)) - for _, format := range media.MediaName.Formats { - payloadType, err := strconv.ParseUint(format, 10, 8) - if err != nil { + codecSet := make(map[videoCodec]struct{}) + unsupportedPrimaryCount := 0 + for payloadType := range payloadTypes { + name := payloadNames[payloadType] + if videoCodec, ok := codecFromName(name); ok { + codecSet[videoCodec] = struct{}{} continue } - payloadTypes = append(payloadTypes, uint8(payloadType)) + if !strings.EqualFold(name, "rtx") { + unsupportedPrimaryCount++ + } } - codecs, err := description.GetCodecsForPayloadTypes(payloadTypes) - if err != nil { - return err + mediaCodecSets = append(mediaCodecSets, videoMediaCodecSet{ + codecs: codecSet, + unsupportedPrimaryCount: unsupportedPrimaryCount, + }) + } + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "m=") { + flush() + inVideo = strings.HasPrefix(line, "m=video ") + payloadTypes = make(map[string]struct{}) + payloadNames = make(map[string]string) + if inVideo { + parts := strings.Fields(line) + for _, payloadType := range parts[3:] { + payloadTypes[payloadType] = struct{}{} + } + } + continue } - for _, codec := range codecs { - if strings.EqualFold(codec.Name, "H264") { - return nil + if !inVideo || !strings.HasPrefix(line, "a=rtpmap:") { + continue + } + parts := strings.Fields(strings.TrimPrefix(line, "a=rtpmap:")) + if len(parts) < 2 { + continue + } + payloadType := parts[0] + payloadNames[payloadType] = strings.SplitN(parts[1], "/", 2)[0] + } + flush() + return mediaCodecSets, nil +} + +func publisherVideoCodecs(sdp string) ([]videoCodec, error) { + mediaCodecSets, err := videoMediaCodecSets(sdp) + if err != nil { + return nil, err + } + codecs := make([]videoCodec, 0, len(mediaCodecSets)) + seen := make(map[videoCodec]struct{}) + for _, mediaCodecSet := range mediaCodecSets { + if mediaCodecSet.unsupportedPrimaryCount > 0 { + return nil, errUnsupportedVideoCodecOffered + } + if len(mediaCodecSet.codecs) == 0 { + return nil, errSupportedVideoCodecMissing + } + if _, ok := mediaCodecSet.codecs[videoCodecAV1]; ok && len(mediaCodecSet.codecs) == 1 { + if _, duplicate := seen[videoCodecAV1]; duplicate { + return nil, errPublisherCodecDuplicate } + seen[videoCodecAV1] = struct{}{} + codecs = append(codecs, videoCodecAV1) + continue } + return nil, errUnsupportedVideoCodecOffered + } + if len(codecs) == 0 { + return nil, errSupportedVideoCodecMissing + } + return codecs, nil +} + +func codecSetFromList(codecs []videoCodec) map[videoCodec]struct{} { + result := make(map[videoCodec]struct{}, len(codecs)) + for _, codec := range codecs { + result[codec] = struct{}{} } - return errH264VideoCodecMissing + return result +} + +func codecListFromSet(codecSet map[videoCodec]struct{}) []videoCodec { + codecs := make([]videoCodec, 0, len(codecSet)) + if _, ok := codecSet[videoCodecAV1]; ok { + codecs = append(codecs, videoCodecAV1) + } + return codecs +} + +func codecStrings(codecs []videoCodec) []string { + result := make([]string, 0, len(codecs)) + for _, codec := range codecs { + result = append(result, string(codec)) + } + return result +} + +func headerExtensionIDs(parameters []webrtc.RTPHeaderExtensionParameter) map[string]uint8 { + result := make(map[string]uint8, len(parameters)) + for _, parameter := range parameters { + if parameter.ID <= 0 || parameter.ID > 255 || parameter.URI == "" { + continue + } + result[parameter.URI] = uint8(parameter.ID) + } + return result +} + +func headerExtensionRewrites( + publisherExtensions map[string]uint8, + viewerExtensions map[string]uint8, +) map[uint8]uint8 { + rewrites := make(map[uint8]uint8) + for uri, publisherID := range publisherExtensions { + viewerID, ok := viewerExtensions[uri] + if !ok { + rewrites[publisherID] = 0 + continue + } + rewrites[publisherID] = viewerID + } + return rewrites +} + +func copyHeaderExtensionMap(input map[string]uint8) map[string]uint8 { + output := make(map[string]uint8, len(input)) + for key, value := range input { + output[key] = value + } + return output +} + +func copyExtensionRewriteMap(input map[uint8]uint8) map[uint8]uint8 { + output := make(map[uint8]uint8, len(input)) + for key, value := range input { + output[key] = value + } + return output +} + +func selectViewerCodec(sdp string, available map[videoCodec]struct{}) (videoCodec, error) { + mediaCodecSets, err := videoMediaCodecSets(sdp) + if err != nil { + return "", err + } + if len(available) == 0 { + return "", errPublisherCodecPending + } + offered := make(map[videoCodec]struct{}) + for _, mediaCodecSet := range mediaCodecSets { + if mediaCodecSet.unsupportedPrimaryCount > 0 { + return "", errUnsupportedVideoCodecOffered + } + if len(mediaCodecSet.codecs) == 0 { + return "", errSupportedVideoCodecMissing + } + for codec := range mediaCodecSet.codecs { + offered[codec] = struct{}{} + } + } + if _, publisherHasAV1 := available[videoCodecAV1]; publisherHasAV1 { + if _, viewerHasAV1 := offered[videoCodecAV1]; viewerHasAV1 { + return videoCodecAV1, nil + } + } + return "", errSupportedVideoCodecMissing } func NewServer(config Config) *Server { @@ -408,7 +496,23 @@ func (s *Server) startWebRTC() error { } udpMux := ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: udpConn}) mediaEngine := &webrtc.MediaEngine{} - if err := registerH264Codecs(mediaEngine); err != nil { + if err := registerVideoCodecs(mediaEngine); err != nil { + _ = udpMux.Close() + _ = udpConn.Close() + return err + } + interceptorRegistry := &interceptor.Registry{} + if err := webrtc.ConfigureNack(mediaEngine, interceptorRegistry); err != nil { + _ = udpMux.Close() + _ = udpConn.Close() + return err + } + if err := webrtc.ConfigureRTCPReports(interceptorRegistry); err != nil { + _ = udpMux.Close() + _ = udpConn.Close() + return err + } + if err := webrtc.ConfigureStatsInterceptor(interceptorRegistry); err != nil { _ = udpMux.Close() _ = udpConn.Close() return err @@ -419,6 +523,7 @@ func (s *Server) startWebRTC() error { api := webrtc.NewAPI( webrtc.WithMediaEngine(mediaEngine), webrtc.WithSettingEngine(settingEngine), + webrtc.WithInterceptorRegistry(interceptorRegistry), ) listenAddresses := make([]string, 0) @@ -537,7 +642,7 @@ func (s *Server) handleViewerOffer(w http.ResponseWriter, r *http.Request, roomI writeJSON(w, http.StatusBadRequest, signalResponse{Type: "error", Reason: err.Error()}) return } - writeJSON(w, http.StatusOK, signalResponse{Type: "answer", SDP: answer}) + writeJSON(w, http.StatusOK, signalResponse{Type: "answer", SDP: answer.SDP, Codec: string(answer.Codec)}) } func (s *Server) handleViewerCandidate(w http.ResponseWriter, r *http.Request, roomID string, clientID string) { @@ -632,11 +737,14 @@ type Room struct { viewers map[string]*viewerSession subscribers map[string]*viewerRTPWriter pendingViewerICE map[string][]webrtc.ICECandidateInit - publisherSSRC uint32 + publisherCodecs map[videoCodec]struct{} + publisherSSRCs map[videoCodec]uint32 + publisherExtensions map[videoCodec]map[string]uint8 publisherPacketCount atomic.Uint64 forwardedPacketCount atomic.Uint64 pliForwardCount atomic.Uint64 firForwardCount atomic.Uint64 + nackForwardCount atomic.Uint64 } type rtpSink interface { @@ -654,6 +762,11 @@ type publisherOfferResult struct { PublisherID string } +type viewerOfferResult struct { + SDP string + Codec videoCodec +} + type publisherSession struct { id string pc peerConnection @@ -663,6 +776,7 @@ type viewerSession struct { pc peerConnection sender *webrtc.RTPSender writer *viewerRTPWriter + codec videoCodec } type peerConnection interface { @@ -674,29 +788,35 @@ type peerConnection interface { const subscriberRTPQueueSize = 512 type viewerRTPWriter struct { - roomID string - clientID string - logger *slog.Logger - sink rtpSink - queue chan *rtp.Packet - done chan struct{} - mu sync.Mutex - closed bool - written atomic.Uint64 - dropped atomic.Uint64 -} - -func newViewerRTPWriter(roomID string, clientID string, sink rtpSink, logger *slog.Logger) *viewerRTPWriter { + roomID string + clientID string + codec videoCodec + logger *slog.Logger + sink rtpSink + queue chan *rtp.Packet + done chan struct{} + mu sync.Mutex + closed bool + viewerExtensions map[string]uint8 + extensionRewrites map[uint8]uint8 + written atomic.Uint64 + dropped atomic.Uint64 +} + +func newViewerRTPWriter(roomID string, clientID string, codec videoCodec, sink rtpSink, logger *slog.Logger) *viewerRTPWriter { if logger == nil { logger = slog.Default() } writer := &viewerRTPWriter{ - roomID: roomID, - clientID: clientID, - logger: logger, - sink: sink, - queue: make(chan *rtp.Packet, subscriberRTPQueueSize), - done: make(chan struct{}), + roomID: roomID, + clientID: clientID, + codec: codec, + logger: logger, + sink: sink, + queue: make(chan *rtp.Packet, subscriberRTPQueueSize), + done: make(chan struct{}), + viewerExtensions: make(map[string]uint8), + extensionRewrites: make(map[uint8]uint8), } go writer.run() return writer @@ -709,6 +829,7 @@ func (w *viewerRTPWriter) enqueue(packet *rtp.Packet) bool { return false } packetCopy := packet.Clone() + w.rewriteHeaderExtensions(packetCopy) select { case w.queue <- packetCopy: return true @@ -719,6 +840,49 @@ func (w *viewerRTPWriter) enqueue(packet *rtp.Packet) bool { } } +func (w *viewerRTPWriter) setViewerExtensions(extensions map[string]uint8) { + w.mu.Lock() + defer w.mu.Unlock() + w.viewerExtensions = copyHeaderExtensionMap(extensions) +} + +func (w *viewerRTPWriter) setExtensionRewrites(rewrites map[uint8]uint8) { + w.mu.Lock() + defer w.mu.Unlock() + w.extensionRewrites = copyExtensionRewriteMap(rewrites) +} + +func (w *viewerRTPWriter) setPublisherExtensions(extensions map[string]uint8) { + w.mu.Lock() + defer w.mu.Unlock() + w.extensionRewrites = headerExtensionRewrites(extensions, w.viewerExtensions) +} + +func (w *viewerRTPWriter) rewriteHeaderExtensions(packet *rtp.Packet) { + type pendingRewrite struct { + viewerID uint8 + payload []byte + } + pendingRewrites := make([]pendingRewrite, 0, len(w.extensionRewrites)) + for publisherID, viewerID := range w.extensionRewrites { + payload := packet.GetExtension(publisherID) + if payload == nil { + continue + } + payloadCopy := append([]byte(nil), payload...) + _ = packet.DelExtension(publisherID) + if viewerID != 0 { + pendingRewrites = append(pendingRewrites, pendingRewrite{ + viewerID: viewerID, + payload: payloadCopy, + }) + } + } + for _, rewrite := range pendingRewrites { + _ = packet.SetExtension(rewrite.viewerID, rewrite.payload) + } +} + func (w *viewerRTPWriter) run() { for { select { @@ -753,12 +917,15 @@ func NewRoom(id string, logger *slog.Logger, newPeerConnection peerConnectionFac logger = slog.Default() } return &Room{ - id: id, - logger: logger, - newPeerConnection: newPeerConnection, - viewers: make(map[string]*viewerSession), - subscribers: make(map[string]*viewerRTPWriter), - pendingViewerICE: make(map[string][]webrtc.ICECandidateInit), + id: id, + logger: logger, + newPeerConnection: newPeerConnection, + viewers: make(map[string]*viewerSession), + subscribers: make(map[string]*viewerRTPWriter), + pendingViewerICE: make(map[string][]webrtc.ICECandidateInit), + publisherCodecs: make(map[videoCodec]struct{}), + publisherSSRCs: make(map[videoCodec]uint32), + publisherExtensions: make(map[videoCodec]map[string]uint8), } } @@ -767,11 +934,14 @@ func newRoomForTest(id string, logger *slog.Logger) *Room { logger = slog.Default() } return &Room{ - id: id, - logger: logger, - viewers: make(map[string]*viewerSession), - subscribers: make(map[string]*viewerRTPWriter), - pendingViewerICE: make(map[string][]webrtc.ICECandidateInit), + id: id, + logger: logger, + viewers: make(map[string]*viewerSession), + subscribers: make(map[string]*viewerRTPWriter), + pendingViewerICE: make(map[string][]webrtc.ICECandidateInit), + publisherCodecs: make(map[videoCodec]struct{}), + publisherSSRCs: make(map[videoCodec]uint32), + publisherExtensions: make(map[videoCodec]map[string]uint8), } } @@ -781,9 +951,12 @@ func (r *Room) Snapshot() RoomSnapshot { if r.publisher != nil { publisherID = r.publisher.id } + publisherCodecs := codecStrings(codecListFromSet(r.publisherCodecs)) writers := make([]*viewerRTPWriter, 0, len(r.subscribers)) + subscriberCodecCounts := make(map[string]int) for _, writer := range r.subscribers { writers = append(writers, writer) + subscriberCodecCounts[string(writer.codec)]++ } r.mu.Unlock() var writtenPacketCount uint64 @@ -798,17 +971,20 @@ func (r *Room) Snapshot() RoomSnapshot { } } return RoomSnapshot{ - ID: r.id, - HasPublisher: publisherID != "", - PublisherID: publisherID, - SubscriberCount: len(writers), - PublisherPacketCount: r.publisherPacketCount.Load(), - ForwardedPacketCount: r.forwardedPacketCount.Load(), - WrittenPacketCount: writtenPacketCount, - DroppedPacketCount: droppedPacketCount, - SlowSubscriberCount: slowSubscriberCount, - PLIForwardCount: r.pliForwardCount.Load(), - FIRForwardCount: r.firForwardCount.Load(), + ID: r.id, + HasPublisher: publisherID != "", + PublisherID: publisherID, + PublisherCodecs: publisherCodecs, + SubscriberCodecCounts: subscriberCodecCounts, + SubscriberCount: len(writers), + PublisherPacketCount: r.publisherPacketCount.Load(), + ForwardedPacketCount: r.forwardedPacketCount.Load(), + WrittenPacketCount: writtenPacketCount, + DroppedPacketCount: droppedPacketCount, + SlowSubscriberCount: slowSubscriberCount, + PLIForwardCount: r.pliForwardCount.Load(), + FIRForwardCount: r.firForwardCount.Load(), + NACKForwardCount: r.nackForwardCount.Load(), } } @@ -816,38 +992,37 @@ func (r *Room) SetPublisherOffer(sdp string) (publisherOfferResult, error) { if r.newPeerConnection == nil { return publisherOfferResult{}, errors.New("room_peer_connection_factory_missing") } - if err := requireH264VideoCodec(sdp); err != nil { + if _, err := publisherVideoCodecs(sdp); err != nil { return publisherOfferResult{}, err } pc, err := r.newPeerConnection() if err != nil { return publisherOfferResult{}, err } - _, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ - Direction: webrtc.RTPTransceiverDirectionRecvonly, - }) - if err != nil { - _ = pc.Close() - return publisherOfferResult{}, err - } publisherID := r.nextPublisherIdentifier() r.mu.Lock() previous := r.publisher - previousSSRC := r.publisherSSRC + previousCodecs := r.publisherCodecs + previousSSRCs := r.publisherSSRCs + previousExtensions := r.publisherExtensions r.publisher = &publisherSession{id: publisherID, pc: pc} - r.publisherSSRC = 0 + r.publisherCodecs = make(map[videoCodec]struct{}) + r.publisherSSRCs = make(map[videoCodec]uint32) + r.publisherExtensions = make(map[videoCodec]map[string]uint8) r.mu.Unlock() rollbackPublisher := func() { r.mu.Lock() if r.publisher != nil && r.publisher.id == publisherID { r.publisher = previous - r.publisherSSRC = previousSSRC + r.publisherCodecs = previousCodecs + r.publisherSSRCs = previousSSRCs + r.publisherExtensions = previousExtensions } r.mu.Unlock() _ = pc.Close() } pc.OnTrack(func(remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { - r.publisherTrackStarted(publisherID, remote) + r.publisherTrackStarted(publisherID, remote, receiver) }) pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { if state == webrtc.PeerConnectionStateFailed || @@ -877,11 +1052,22 @@ func (r *Room) SetPublisherOffer(sdp string) (publisherOfferResult, error) { rollbackPublisher() return publisherOfferResult{}, errors.New("publisher_local_description_missing") } + negotiatedCodecs, err := publisherVideoCodecs(localDescription.SDP) + if err != nil { + rollbackPublisher() + return publisherOfferResult{}, err + } + negotiatedCodecSet := codecSetFromList(negotiatedCodecs) + r.mu.Lock() + if r.publisher != nil && r.publisher.id == publisherID { + r.publisherCodecs = negotiatedCodecSet + } + r.mu.Unlock() if previous != nil { _ = previous.pc.Close() } - r.logger.Info("publisher ready", "room", r.id, "publisherID", publisherID) + r.logger.Info("publisher ready", "room", r.id, "publisherID", publisherID, "codecs", codecStrings(negotiatedCodecs)) return publisherOfferResult{SDP: localDescription.SDP, PublisherID: publisherID}, nil } @@ -904,30 +1090,58 @@ func (r *Room) AddPublisherCandidate(publisherID string, candidate webrtc.ICECan return publisher.pc.AddICECandidate(candidate) } -func (r *Room) SetViewerOffer(clientID string, sdp string) (string, error) { +func (r *Room) SetViewerOffer(clientID string, sdp string) (viewerOfferResult, error) { if r.newPeerConnection == nil { - return "", errors.New("room_peer_connection_factory_missing") + return viewerOfferResult{}, errors.New("room_peer_connection_factory_missing") } - if err := requireH264VideoCodec(sdp); err != nil { - return "", err + r.mu.Lock() + publisherCodecs := make(map[videoCodec]struct{}, len(r.publisherCodecs)) + for codec := range r.publisherCodecs { + publisherCodecs[codec] = struct{}{} + } + r.mu.Unlock() + selectedCodec, err := selectViewerCodec(sdp, publisherCodecs) + if err != nil { + return viewerOfferResult{}, err } pc, err := r.newPeerConnection() if err != nil { - return "", err + return viewerOfferResult{}, err + } + capability, err := trackCapability(selectedCodec) + if err != nil { + _ = pc.Close() + return viewerOfferResult{}, err } track, err := webrtc.NewTrackLocalStaticRTP( - h264TrackCapability(), - "screen", + capability, + "screen-"+string(selectedCodec), "voiddisplay", ) if err != nil { _ = pc.Close() - return "", err + return viewerOfferResult{}, err + } + transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + if err != nil { + _ = pc.Close() + return viewerOfferResult{}, err } - sender, err := pc.AddTrack(track) + codecParameters, err := codecParametersForVideoCodec(selectedCodec) if err != nil { _ = pc.Close() - return "", err + return viewerOfferResult{}, err + } + if err := transceiver.SetCodecPreferences(codecParameters); err != nil { + _ = pc.Close() + return viewerOfferResult{}, err + } + sender := transceiver.Sender() + if sender == nil { + _ = pc.Close() + return viewerOfferResult{}, errors.New("viewer_sender_missing") } pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) { if state == webrtc.PeerConnectionStateFailed || @@ -939,29 +1153,34 @@ func (r *Room) SetViewerOffer(clientID string, sdp string) (string, error) { offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: sdp} if err := pc.SetRemoteDescription(offer); err != nil { _ = pc.Close() - return "", err + return viewerOfferResult{}, err } answer, err := pc.CreateAnswer(nil) if err != nil { _ = pc.Close() - return "", err + return viewerOfferResult{}, err } gatherComplete := webrtc.GatheringCompletePromise(pc) if err := pc.SetLocalDescription(answer); err != nil { _ = pc.Close() - return "", err + return viewerOfferResult{}, err } <-gatherComplete localDescription := pc.LocalDescription() if localDescription == nil { _ = pc.Close() - return "", errors.New("viewer_local_description_missing") + return viewerOfferResult{}, errors.New("viewer_local_description_missing") } - writer := newViewerRTPWriter(r.id, clientID, track, r.logger) + writer := newViewerRTPWriter(r.id, clientID, selectedCodec, track, r.logger) + viewerExtensions := headerExtensionIDs(sender.GetParameters().HeaderExtensions) + writer.setViewerExtensions(viewerExtensions) r.mu.Lock() previous := r.viewers[clientID] - r.viewers[clientID] = &viewerSession{pc: pc, sender: sender, writer: writer} + if publisherExtensions := r.publisherExtensions[selectedCodec]; len(publisherExtensions) > 0 { + writer.setExtensionRewrites(headerExtensionRewrites(publisherExtensions, viewerExtensions)) + } + r.viewers[clientID] = &viewerSession{pc: pc, sender: sender, writer: writer, codec: selectedCodec} r.subscribers[clientID] = writer pending := append([]webrtc.ICECandidateInit(nil), r.pendingViewerICE[clientID]...) delete(r.pendingViewerICE, clientID) @@ -972,9 +1191,9 @@ func (r *Room) SetViewerOffer(clientID string, sdp string) (string, error) { } r.applyICECandidates(clientID, pc, pending) - r.logger.Info("viewer ready", "room", r.id, "clientID", clientID, "subscribers", subscriberCount) - go r.readViewerRTCP(clientID, sender) - return localDescription.SDP, nil + r.logger.Info("viewer ready", "room", r.id, "clientID", clientID, "codec", selectedCodec, "subscribers", subscriberCount) + go r.readViewerRTCP(clientID, selectedCodec, sender) + return viewerOfferResult{SDP: localDescription.SDP, Codec: selectedCodec}, nil } func (r *Room) AddViewerCandidate(clientID string, candidate webrtc.ICECandidateInit) error { @@ -1015,7 +1234,9 @@ func (r *Room) Close() { r.viewers = make(map[string]*viewerSession) r.subscribers = make(map[string]*viewerRTPWriter) r.pendingViewerICE = make(map[string][]webrtc.ICECandidateInit) - r.publisherSSRC = 0 + r.publisherCodecs = make(map[videoCodec]struct{}) + r.publisherSSRCs = make(map[videoCodec]uint32) + r.publisherExtensions = make(map[videoCodec]map[string]uint8) r.mu.Unlock() if publisher != nil { _ = publisher.pc.Close() @@ -1031,7 +1252,9 @@ func (r *Room) StopPublisher(publisherID string) { if r.publisher != nil && r.publisher.id == publisherID { publisher = r.publisher r.publisher = nil - r.publisherSSRC = 0 + r.publisherCodecs = make(map[videoCodec]struct{}) + r.publisherSSRCs = make(map[videoCodec]uint32) + r.publisherExtensions = make(map[videoCodec]map[string]uint8) } r.mu.Unlock() if publisher == nil { @@ -1079,15 +1302,32 @@ func (r *Room) handlePublisherDisconnected(publisherID string, state webrtc.Peer r.Close() } -func (r *Room) publisherTrackStarted(publisherID string, remote *webrtc.TrackRemote) { +func (r *Room) publisherTrackStarted(publisherID string, remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { + codec, ok := codecFromName(remote.Codec().MimeType) + if !ok { + r.logger.Warn("publisher track ignored unsupported codec", "room", r.id, "publisherID", publisherID, "codec", remote.Codec().MimeType) + return + } r.mu.Lock() if r.publisher == nil || r.publisher.id != publisherID { r.mu.Unlock() r.logger.Debug("ignored stale publisher track", "room", r.id, "publisherID", publisherID) return } - r.publisherSSRC = uint32(remote.SSRC()) + ssrc := uint32(remote.SSRC()) + r.publisherSSRCs[codec] = ssrc + publisherExtensions := headerExtensionIDs(receiver.GetParameters().HeaderExtensions) + r.publisherExtensions[codec] = publisherExtensions + writers := make([]*viewerRTPWriter, 0, len(r.subscribers)) + for _, writer := range r.subscribers { + if writer.codec == codec { + writers = append(writers, writer) + } + } r.mu.Unlock() + for _, writer := range writers { + writer.setPublisherExtensions(publisherExtensions) + } r.logger.Info( "publisher track started", "room", @@ -1097,8 +1337,9 @@ func (r *Room) publisherTrackStarted(publisherID string, remote *webrtc.TrackRem "codec", remote.Codec().MimeType, "ssrc", - uint32(remote.SSRC()), + ssrc, ) + defer r.publisherTrackStopped(publisherID, codec, ssrc) for { packet, _, err := remote.ReadRTP() if err != nil { @@ -1107,18 +1348,35 @@ func (r *Room) publisherTrackStarted(publisherID string, remote *webrtc.TrackRem } return } - if !r.ForwardRTPFromPublisher(publisherID, packet) { + if !r.ForwardRTPFromPublisher(publisherID, codec, packet) { return } r.publisherPacketCount.Add(1) } } +func (r *Room) publisherTrackStopped(publisherID string, codec videoCodec, ssrc uint32) { + r.mu.Lock() + defer r.mu.Unlock() + if r.publisher == nil || r.publisher.id != publisherID { + return + } + if r.publisherSSRCs[codec] != ssrc { + return + } + delete(r.publisherSSRCs, codec) + delete(r.publisherExtensions, codec) +} + func (r *Room) ForwardRTP(packet *rtp.Packet) { - r.forwardRTP(packet) + r.forwardRTPToSubscribers("", packet) } -func (r *Room) ForwardRTPFromPublisher(publisherID string, packet *rtp.Packet) bool { +func (r *Room) ForwardRTPForCodec(codec videoCodec, packet *rtp.Packet) { + r.forwardRTPToSubscribers(codec, packet) +} + +func (r *Room) ForwardRTPFromPublisher(publisherID string, codec videoCodec, packet *rtp.Packet) bool { r.mu.Lock() isCurrent := r.publisher != nil && r.publisher.id == publisherID r.mu.Unlock() @@ -1126,14 +1384,17 @@ func (r *Room) ForwardRTPFromPublisher(publisherID string, packet *rtp.Packet) b r.logger.Debug("ignored stale publisher RTP", "room", r.id, "publisherID", publisherID) return false } - r.forwardRTP(packet) + r.forwardRTPToSubscribers(codec, packet) return true } -func (r *Room) forwardRTP(packet *rtp.Packet) { +func (r *Room) forwardRTPToSubscribers(codec videoCodec, packet *rtp.Packet) { r.mu.Lock() subscribers := make([]*viewerRTPWriter, 0, len(r.subscribers)) for _, subscriber := range r.subscribers { + if codec != "" && subscriber.codec != codec { + continue + } subscribers = append(subscribers, subscriber) } r.mu.Unlock() @@ -1144,7 +1405,7 @@ func (r *Room) forwardRTP(packet *rtp.Packet) { } } -func (r *Room) readViewerRTCP(clientID string, sender *webrtc.RTPSender) { +func (r *Room) readViewerRTCP(clientID string, codec videoCodec, sender *webrtc.RTPSender) { for { packets, _, err := sender.ReadRTCP() if err != nil { @@ -1153,14 +1414,14 @@ func (r *Room) readViewerRTCP(clientID string, sender *webrtc.RTPSender) { } return } - r.forwardFeedback(packets) + r.forwardFeedback(codec, packets) } } -func (r *Room) forwardFeedback(packets []rtcp.Packet) { +func (r *Room) forwardFeedback(codec videoCodec, packets []rtcp.Packet) { r.mu.Lock() publisher := r.publisher - publisherSSRC := r.publisherSSRC + publisherSSRC := r.publisherSSRCs[codec] r.mu.Unlock() if publisher == nil || publisherSSRC == 0 { return @@ -1187,6 +1448,15 @@ func (r *Room) forwardFeedback(packets []rtcp.Packet) { MediaSSRC: publisherSSRC, FIR: entries, }) + case *rtcp.TransportLayerNack: + r.nackForwardCount.Add(1) + nacks := make([]rtcp.NackPair, len(value.Nacks)) + copy(nacks, value.Nacks) + forwarded = append(forwarded, &rtcp.TransportLayerNack{ + SenderSSRC: value.SenderSSRC, + MediaSSRC: publisherSSRC, + Nacks: nacks, + }) } } if len(forwarded) == 0 { diff --git a/Tools/VoidDisplayRelay/internal/relay/server_test.go b/Tools/VoidDisplayRelay/internal/relay/server_test.go index b334157..816dab3 100644 --- a/Tools/VoidDisplayRelay/internal/relay/server_test.go +++ b/Tools/VoidDisplayRelay/internal/relay/server_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "net/http" "strings" "sync" @@ -20,8 +21,8 @@ func TestRoomForwardRTPFansOutOnePublisherPacketToViewers(t *testing.T) { room := newRoomForTest("2", nil) first := &recordingSink{} second := &recordingSink{} - room.subscribers["first"] = newViewerRTPWriter("2", "first", first, nil) - room.subscribers["second"] = newViewerRTPWriter("2", "second", second, nil) + room.subscribers["first"] = newViewerRTPWriter("2", "first", videoCodecAV1, first, nil) + room.subscribers["second"] = newViewerRTPWriter("2", "second", videoCodecAV1, second, nil) defer room.Close() packet := &rtp.Packet{ @@ -49,8 +50,8 @@ func TestRoomRemoveViewerStopsFutureRTPForThatViewer(t *testing.T) { room := newRoomForTest("2", nil) removed := &recordingSink{} active := &recordingSink{} - room.subscribers["removed"] = newViewerRTPWriter("2", "removed", removed, nil) - room.subscribers["active"] = newViewerRTPWriter("2", "active", active, nil) + room.subscribers["removed"] = newViewerRTPWriter("2", "removed", videoCodecAV1, removed, nil) + room.subscribers["active"] = newViewerRTPWriter("2", "active", videoCodecAV1, active, nil) room.viewers["removed"] = &viewerSession{pc: &fakePeerConnection{}} room.pendingViewerICE["removed"] = []webrtc.ICECandidateInit{{Candidate: "candidate:removed"}} defer room.Close() @@ -70,6 +71,20 @@ func TestRoomRemoveViewerStopsFutureRTPForThatViewer(t *testing.T) { } } +func TestRoomSnapshotCountsSubscriberCodecs(t *testing.T) { + room := newRoomForTest("2", nil) + room.subscribers["av1-a"] = newViewerRTPWriter("2", "av1-a", videoCodecAV1, &recordingSink{}, nil) + room.subscribers["av1-b"] = newViewerRTPWriter("2", "av1-b", videoCodecAV1, &recordingSink{}, nil) + room.subscribers["av1-c"] = newViewerRTPWriter("2", "av1-c", videoCodecAV1, &recordingSink{}, nil) + defer room.Close() + + snapshot := room.Snapshot() + + if snapshot.SubscriberCodecCounts["av1"] != 3 { + t.Fatalf("AV1 subscriber count = %d, want 3", snapshot.SubscriberCodecCounts["av1"]) + } +} + func TestRoomIgnoresStalePublisherCandidate(t *testing.T) { room := newRoomForTest("2", nil) pc := &fakePeerConnection{} @@ -84,6 +99,26 @@ func TestRoomIgnoresStalePublisherCandidate(t *testing.T) { } } +func TestRoomPublisherTrackStoppedClearsOnlyMatchingActiveCodec(t *testing.T) { + room := newRoomForTest("2", nil) + room.publisher = &publisherSession{id: "publisher-1", pc: &fakePeerConnection{}} + room.publisherSSRCs[videoCodecAV1] = 1234 + room.publisherExtensions[videoCodecAV1] = map[string]uint8{"av1-extension": 3} + + room.publisherTrackStopped("publisher-1", videoCodecAV1, 9999) + if room.publisherSSRCs[videoCodecAV1] != 1234 { + t.Fatalf("AV1 SSRC cleared for nonmatching SSRC") + } + + room.publisherTrackStopped("publisher-1", videoCodecAV1, 1234) + if _, ok := room.publisherSSRCs[videoCodecAV1]; ok { + t.Fatal("AV1 SSRC kept after matching track stopped") + } + if _, ok := room.publisherExtensions[videoCodecAV1]; ok { + t.Fatal("AV1 extensions kept after matching track stopped") + } +} + func TestRoomBuffersViewerCandidateBeforeViewerExists(t *testing.T) { room := newRoomForTest("2", nil) @@ -115,24 +150,29 @@ func TestRoomApplyPendingICECandidates(t *testing.T) { } } -func TestRoomForwardFeedbackRetargetsPLIAndFIRToPublisherSSRC(t *testing.T) { +func TestRoomForwardFeedbackRetargetsPLIAndFIRAndNACKToPublisherSSRC(t *testing.T) { pc := &fakePeerConnection{} room := newRoomForTest("2", nil) room.publisher = &publisherSession{id: "publisher-1", pc: pc} - room.publisherSSRC = 1234 + room.publisherSSRCs[videoCodecAV1] = 1234 - room.forwardFeedback([]rtcp.Packet{ + room.forwardFeedback(videoCodecAV1, []rtcp.Packet{ &rtcp.PictureLossIndication{SenderSSRC: 1, MediaSSRC: 55}, &rtcp.FullIntraRequest{ SenderSSRC: 2, MediaSSRC: 56, FIR: []rtcp.FIREntry{{SSRC: 56, SequenceNumber: 9}}, }, + &rtcp.TransportLayerNack{ + SenderSSRC: 3, + MediaSSRC: 57, + Nacks: []rtcp.NackPair{{PacketID: 100}}, + }, }) packets := pc.writtenRTCP() - if len(packets) != 2 { - t.Fatalf("written RTCP count = %d, want 2", len(packets)) + if len(packets) != 3 { + t.Fatalf("written RTCP count = %d, want 3", len(packets)) } pli, ok := packets[0].(*rtcp.PictureLossIndication) if !ok || pli.MediaSSRC != 1234 { @@ -142,12 +182,19 @@ func TestRoomForwardFeedbackRetargetsPLIAndFIRToPublisherSSRC(t *testing.T) { if !ok || fir.MediaSSRC != 1234 || fir.FIR[0].SSRC != 1234 { t.Fatalf("FIR retarget mismatch: %#v", packets[1]) } + nack, ok := packets[2].(*rtcp.TransportLayerNack) + if !ok || nack.MediaSSRC != 1234 || nack.Nacks[0].PacketID != 100 { + t.Fatalf("NACK retarget mismatch: %#v", packets[2]) + } if got := room.pliForwardCount.Load(); got != 1 { t.Fatalf("PLI count = %d, want 1", got) } if got := room.firForwardCount.Load(); got != 1 { t.Fatalf("FIR count = %d, want 1", got) } + if got := room.nackForwardCount.Load(); got != 1 { + t.Fatalf("NACK count = %d, want 1", got) + } } func TestRoomStalePublisherStopAndRTPDoNotAffectCurrentPublisher(t *testing.T) { @@ -155,11 +202,11 @@ func TestRoomStalePublisherStopAndRTPDoNotAffectCurrentPublisher(t *testing.T) { pc := &fakePeerConnection{} sink := &recordingSink{} room.publisher = &publisherSession{id: "current", pc: pc} - room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", sink, nil) + room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", videoCodecAV1, sink, nil) defer room.Close() room.StopPublisher("stale") - forwarded := room.ForwardRTPFromPublisher("stale", &rtp.Packet{ + forwarded := room.ForwardRTPFromPublisher("stale", videoCodecAV1, &rtp.Packet{ Header: rtp.Header{Timestamp: 77}, Payload: []byte{1}, }) @@ -180,8 +227,8 @@ func TestRoomSlowViewerDropsOnlyThatViewer(t *testing.T) { slow := newBlockingSink() defer slow.release() active := &recordingSink{} - room.subscribers["slow"] = newViewerRTPWriter("2", "slow", slow, nil) - room.subscribers["active"] = newViewerRTPWriter("2", "active", active, nil) + room.subscribers["slow"] = newViewerRTPWriter("2", "slow", videoCodecAV1, slow, nil) + room.subscribers["active"] = newViewerRTPWriter("2", "active", videoCodecAV1, active, nil) defer room.Close() for index := 0; index < subscriberRTPQueueSize+20; index++ { @@ -207,7 +254,7 @@ func TestRoomSlowViewerDropsOnlyThatViewer(t *testing.T) { func TestViewerWriterRejectsEnqueueAfterClose(t *testing.T) { sink := &recordingSink{} - writer := newViewerRTPWriter("2", "viewer", sink, nil) + writer := newViewerRTPWriter("2", "viewer", videoCodecAV1, sink, nil) writer.close() if writer.enqueue(&rtp.Packet{Header: rtp.Header{Timestamp: 10}, Payload: []byte{1}}) { @@ -218,10 +265,40 @@ func TestViewerWriterRejectsEnqueueAfterClose(t *testing.T) { } } +func TestViewerWriterRewritesHeaderExtensionIDs(t *testing.T) { + sink := &recordingSink{} + writer := newViewerRTPWriter("2", "viewer", videoCodecAV1, sink, nil) + defer writer.close() + writer.setExtensionRewrites(map[uint8]uint8{3: 4, 4: 0}) + packet := &rtp.Packet{ + Header: rtp.Header{Timestamp: 10}, + Payload: []byte{1}, + } + if err := packet.SetExtension(3, []byte{9, 8}); err != nil { + t.Fatal(err) + } + if err := packet.SetExtension(4, []byte{7, 6}); err != nil { + t.Fatal(err) + } + + if !writer.enqueue(packet) { + t.Fatal("writer rejected RTP") + } + + waitFor(t, func() bool { return sink.count() == 1 }) + forwarded := sink.onlyPacket(t) + if got := forwarded.GetExtension(4); !bytes.Equal(got, []byte{9, 8}) { + t.Fatalf("viewer extension 4 = %v, want [9 8]", got) + } + if got := forwarded.GetExtension(3); got != nil { + t.Fatalf("publisher extension 3 was not removed: %v", got) + } +} + func TestRoomConcurrentRemoveAndForwardDoesNotRace(t *testing.T) { room := newRoomForTest("2", nil) sink := &recordingSink{} - room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", sink, nil) + room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", videoCodecAV1, sink, nil) room.viewers["viewer"] = &viewerSession{pc: &fakePeerConnection{}, writer: room.subscribers["viewer"]} var wg sync.WaitGroup @@ -266,7 +343,8 @@ func TestRoomInvalidPublisherOfferPreservesCurrentPublisher(t *testing.T) { room := NewRoom("2", nil, server.newPeerConnection) current := &fakePeerConnection{} room.publisher = &publisherSession{id: "current", pc: current} - room.publisherSSRC = 1234 + room.publisherCodecs[videoCodecAV1] = struct{}{} + room.publisherSSRCs[videoCodecAV1] = 1234 if _, err := room.SetPublisherOffer("invalid-sdp"); err == nil { t.Fatal("SetPublisherOffer succeeded for invalid SDP") @@ -275,8 +353,8 @@ func TestRoomInvalidPublisherOfferPreservesCurrentPublisher(t *testing.T) { if room.publisher == nil || room.publisher.id != "current" { t.Fatalf("current publisher was not preserved: %#v", room.publisher) } - if room.publisherSSRC != 1234 { - t.Fatalf("publisher SSRC = %d, want 1234", room.publisherSSRC) + if room.publisherSSRCs[videoCodecAV1] != 1234 { + t.Fatalf("publisher AV1 SSRC = %d, want 1234", room.publisherSSRCs[videoCodecAV1]) } if current.isClosed() { t.Fatal("current publisher was closed") @@ -296,7 +374,7 @@ func TestRoomPublisherRejectsVP8OnlyOffer(t *testing.T) { } } -func TestRoomPublisherAcceptsH264Offer(t *testing.T) { +func TestRoomPublisherRejectsH265OnlyOffer(t *testing.T) { server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) if err := server.startWebRTC(); err != nil { t.Fatal(err) @@ -304,14 +382,51 @@ func TestRoomPublisherAcceptsH264Offer(t *testing.T) { defer server.Close() room := NewRoom("2", nil, server.newPeerConnection) - result, err := room.SetPublisherOffer(createPublisherOfferWithCodec(t, webrtc.MimeTypeH264)) + if _, err := room.SetPublisherOffer(createPublisherOfferWithCodec(t, webrtc.MimeTypeH265)); err == nil { + t.Fatal("SetPublisherOffer accepted H265-only SDP") + } +} + +func TestRoomPublisherRejectsMixedSupportedAndUnsupportedVideoOffer(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + offer := appendUnsupportedVideoCodecForTest( + createPublisherOfferWithCodec(t, webrtc.MimeTypeAV1), + "96", + "VP8", + ) + + _, err := room.SetPublisherOffer(offer) + + if !errors.Is(err, errUnsupportedVideoCodecOffered) { + t.Fatalf("SetPublisherOffer error = %v, want unsupported codec", err) + } +} + +func TestRoomPublisherAcceptsAV1Offer(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + + result, err := room.SetPublisherOffer(createPublisherOfferWithCodec(t, webrtc.MimeTypeAV1)) if err != nil { t.Fatalf("SetPublisherOffer returned error: %v", err) } - assertVideoSDPOnlyH264(t, result.SDP) + assertVideoSDPOnlyCodec(t, result.SDP, videoCodecAV1) + snapshot := room.Snapshot() + if strings.Join(snapshot.PublisherCodecs, ",") != "av1" { + t.Fatalf("publisher codecs = %v, want [av1]", snapshot.PublisherCodecs) + } } -func TestRoomPublisherAcceptsSwiftH264HighProfileOffer(t *testing.T) { +func TestRoomPublisherRejectsDuplicateCodecVideoMLine(t *testing.T) { server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) if err := server.startWebRTC(); err != nil { t.Fatal(err) @@ -319,44 +434,163 @@ func TestRoomPublisherAcceptsSwiftH264HighProfileOffer(t *testing.T) { defer server.Close() room := NewRoom("2", nil, server.newPeerConnection) - result, err := room.SetPublisherOffer(createPublisherOfferWithH264Fmtp( - t, - "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f", - )) + _, err := room.SetPublisherOffer(createPublisherOfferWithCodecs(t, []videoCodec{videoCodecAV1, videoCodecAV1})) + + if !errors.Is(err, errPublisherCodecDuplicate) { + t.Fatalf("SetPublisherOffer error = %v, want duplicate codec", err) + } +} + +func TestRoomViewerAnswerUsesOnlyAV1(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + room.publisherCodecs[videoCodecAV1] = struct{}{} + room.publisherSSRCs[videoCodecAV1] = 1234 + + answer, err := room.SetViewerOffer("viewer", createViewerOfferWithCodec(t, videoCodecAV1)) if err != nil { - t.Fatalf("SetPublisherOffer returned error: %v", err) + t.Fatalf("SetViewerOffer returned error: %v", err) + } + assertVideoSDPOnlyCodec(t, answer.SDP, videoCodecAV1) + if answer.Codec != videoCodecAV1 { + t.Fatalf("viewer answer codec = %s, want av1", answer.Codec) + } + if room.viewers["viewer"].codec != videoCodecAV1 { + t.Fatalf("viewer codec = %s, want av1", room.viewers["viewer"].codec) + } +} + +func TestRoomViewerRejectsOfferWhenViewerLacksAV1(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + room.publisherCodecs[videoCodecAV1] = struct{}{} + room.publisherSSRCs[videoCodecAV1] = 1234 + + _, err := room.SetViewerOffer("viewer", createViewerOfferWithMimeType(t, webrtc.MimeTypeH264)) + + if !errors.Is(err, errUnsupportedVideoCodecOffered) && !errors.Is(err, errSupportedVideoCodecMissing) { + t.Fatalf("SetViewerOffer error = %v, want unsupported or missing codec", err) + } +} + +func TestRoomViewerUsesNegotiatedAV1BeforeAV1RTPStarts(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) } - assertVideoSDPOnlyH264(t, result.SDP) - if !strings.Contains(result.SDP, "profile-level-id=640c1f") { - t.Fatalf("publisher answer did not retain Swift H264 profile 640c1f:\n%s", result.SDP) + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + room.publisherCodecs[videoCodecAV1] = struct{}{} + room.publisherSSRCs[videoCodecAV1] = 5678 + + answer, err := room.SetViewerOffer("viewer", createViewerOfferWithCodec(t, videoCodecAV1)) + if err != nil { + t.Fatalf("SetViewerOffer returned error: %v", err) + } + assertVideoSDPOnlyCodec(t, answer.SDP, videoCodecAV1) + if answer.Codec != videoCodecAV1 { + t.Fatalf("viewer answer codec = %s, want av1", answer.Codec) + } + if room.viewers["viewer"].codec != videoCodecAV1 { + t.Fatalf("viewer codec = %s, want av1", room.viewers["viewer"].codec) } } -func TestRoomViewerAnswerUsesOnlyH264(t *testing.T) { +func TestRoomViewerUsesNegotiatedCodecsBeforePublisherRTPStarts(t *testing.T) { server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) if err := server.startWebRTC(); err != nil { t.Fatal(err) } defer server.Close() room := NewRoom("2", nil, server.newPeerConnection) + room.publisherCodecs[videoCodecAV1] = struct{}{} - answer, err := room.SetViewerOffer("viewer", createViewerOffer(t)) + answer, err := room.SetViewerOffer("viewer", createViewerOfferWithCodec(t, videoCodecAV1)) if err != nil { t.Fatalf("SetViewerOffer returned error: %v", err) } - assertVideoSDPOnlyH264(t, answer) + assertVideoSDPOnlyCodec(t, answer.SDP, videoCodecAV1) + if answer.Codec != videoCodecAV1 { + t.Fatalf("viewer answer codec = %s, want av1", answer.Codec) + } + if room.viewers["viewer"].codec != videoCodecAV1 { + t.Fatalf("viewer codec = %s, want av1", room.viewers["viewer"].codec) + } +} + +func TestRoomViewerReturnsCodecPendingUntilPublisherCodecsAreNegotiated(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + + _, err := room.SetViewerOffer("viewer", createViewerOfferWithCodecs(t, []videoCodec{videoCodecAV1, videoCodecAV1})) + + if !errors.Is(err, errPublisherCodecPending) { + t.Fatalf("SetViewerOffer error = %v, want codec pending", err) + } +} + +func TestRoomViewerRejectsUnsupportedVideoCodecOffer(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + room.publisherCodecs[videoCodecAV1] = struct{}{} + room.publisherSSRCs[videoCodecAV1] = 1234 + + if _, err := room.SetViewerOffer("viewer-vp8", createViewerOfferWithMimeType(t, webrtc.MimeTypeVP8)); err == nil { + t.Fatal("SetViewerOffer accepted VP8-only SDP") + } + if _, err := room.SetViewerOffer("viewer-h265", createViewerOfferWithMimeType(t, webrtc.MimeTypeH265)); err == nil { + t.Fatal("SetViewerOffer accepted H265-only SDP") + } } -func TestRoomForwardRTPRewritesViewerPayloadTypeFromNegotiatedH264Binding(t *testing.T) { +func TestRoomViewerRejectsMixedSupportedAndUnsupportedVideoOffer(t *testing.T) { + server := NewServer(Config{ListenUDP: "127.0.0.1:0"}) + if err := server.startWebRTC(); err != nil { + t.Fatal(err) + } + defer server.Close() + room := NewRoom("2", nil, server.newPeerConnection) + room.publisherCodecs[videoCodecAV1] = struct{}{} + room.publisherSSRCs[videoCodecAV1] = 1234 + offer := appendUnsupportedVideoCodecForTest( + createViewerOfferWithCodec(t, videoCodecAV1), + "116", + "H265", + ) + + _, err := room.SetViewerOffer("viewer", offer) + + if !errors.Is(err, errUnsupportedVideoCodecOffered) { + t.Fatalf("SetViewerOffer error = %v, want unsupported codec", err) + } +} + +func TestRoomForwardRTPRewritesViewerPayloadTypeFromNegotiatedAV1Binding(t *testing.T) { room := newRoomForTest("2", nil) - track, err := webrtc.NewTrackLocalStaticRTP(h264TrackCapability(), "screen", "voiddisplay") + track, err := webrtc.NewTrackLocalStaticRTP(mustTrackCapability(t, videoCodecAV1), "screen", "voiddisplay") if err != nil { t.Fatal(err) } stream := &capturingTrackLocalWriter{} _, err = track.Bind(fakeTrackLocalContext{ codecs: []webrtc.RTPCodecParameters{{ - RTPCodecCapability: h264TrackCapability(), + RTPCodecCapability: mustTrackCapability(t, videoCodecAV1), PayloadType: 124, }}, ssrc: 5678, @@ -365,10 +599,10 @@ func TestRoomForwardRTPRewritesViewerPayloadTypeFromNegotiatedH264Binding(t *tes if err != nil { t.Fatal(err) } - room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", track, nil) + room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", videoCodecAV1, track, nil) defer room.Close() - room.ForwardRTP(&rtp.Packet{ + room.ForwardRTPForCodec(videoCodecAV1, &rtp.Packet{ Header: rtp.Header{ PayloadType: 102, SSRC: 1234, @@ -388,6 +622,28 @@ func TestRoomForwardRTPRewritesViewerPayloadTypeFromNegotiatedH264Binding(t *tes } } +func TestRoomForwardRTPForCodecSendsAV1PacketsToAV1Viewers(t *testing.T) { + room := newRoomForTest("2", nil) + first := &recordingSink{} + second := &recordingSink{} + room.subscribers["first"] = newViewerRTPWriter("2", "first", videoCodecAV1, first, nil) + room.subscribers["second"] = newViewerRTPWriter("2", "second", videoCodecAV1, second, nil) + defer room.Close() + + room.ForwardRTPForCodec(videoCodecAV1, &rtp.Packet{ + Header: rtp.Header{Timestamp: 11}, + Payload: []byte{1}, + }) + + waitFor(t, func() bool { return first.count() == 1 && second.count() == 1 }) + if got := first.onlyPacket(t).Timestamp; got != 11 { + t.Fatalf("first viewer timestamp = %d, want 11", got) + } + if got := second.onlyPacket(t).Timestamp; got != 11 { + t.Fatalf("second viewer timestamp = %d, want 11", got) + } +} + func TestServerListenUDPBindsSocketAndEventsExposeAddress(t *testing.T) { loopback, stopServer := startTestServer(t) defer stopServer() @@ -607,7 +863,7 @@ func startTestServer(t *testing.T) (string, func()) { func createPublisherOffer(t *testing.T) string { t.Helper() - pc, _ := createPublisherPeerWithCodec(t, webrtc.MimeTypeH264) + pc, _ := createPublisherPeerWithCodec(t, webrtc.MimeTypeAV1) defer pc.Close() return pc.LocalDescription().SDP } @@ -619,24 +875,32 @@ func createPublisherOfferWithCodec(t *testing.T, mimeType string) string { return pc.LocalDescription().SDP } -func createPublisherOfferWithH264Fmtp(t *testing.T, fmtp string) string { +func appendUnsupportedVideoCodecForTest(sdp string, payloadType string, codecName string) string { + lines := strings.Split(sdp, "\n") + output := make([]string, 0, len(lines)+1) + insertedRTPMap := false + inFirstVideo := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "m=video ") && !insertedRTPMap { + line = strings.TrimRight(line, "\r") + " " + payloadType + inFirstVideo = true + } else if strings.HasPrefix(trimmed, "m=") { + inFirstVideo = false + } + output = append(output, line) + if inFirstVideo && !insertedRTPMap && strings.HasPrefix(trimmed, "m=video ") { + output = append(output, "a=rtpmap:"+payloadType+" "+codecName+"/90000") + insertedRTPMap = true + } + } + return strings.Join(output, "\n") +} + +func createPublisherOfferWithCodecs(t *testing.T, codecs []videoCodec) string { t.Helper() mediaEngine := &webrtc.MediaEngine{} - videoRTCPFeedback := []webrtc.RTCPFeedback{ - {Type: "goog-remb"}, - {Type: "ccm", Parameter: "fir"}, - {Type: "nack"}, - {Type: "nack", Parameter: "pli"}, - } - if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{ - RTPCodecCapability: webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: fmtp, - RTCPFeedback: videoRTCPFeedback, - }, - PayloadType: 96, - }, webrtc.RTPCodecTypeVideo); err != nil { + if err := registerVideoCodecs(mediaEngine); err != nil { t.Fatal(err) } api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine)) @@ -645,20 +909,24 @@ func createPublisherOfferWithH264Fmtp(t *testing.T, fmtp string) string { t.Fatal(err) } defer pc.Close() - track, err := webrtc.NewTrackLocalStaticRTP( - webrtc.RTPCodecCapability{ - MimeType: webrtc.MimeTypeH264, - ClockRate: 90000, - SDPFmtpLine: fmtp, - }, - "screen", - "voiddisplay", - ) - if err != nil { - t.Fatal(err) - } - if _, err := pc.AddTrack(track); err != nil { - t.Fatal(err) + for _, codec := range codecs { + track, err := webrtc.NewTrackLocalStaticRTP( + mustTrackCapability(t, codec), + "screen-"+string(codec), + "voiddisplay", + ) + if err != nil { + t.Fatal(err) + } + transceiver, err := pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + if err != nil { + t.Fatal(err) + } + if err := transceiver.SetCodecPreferences(mustCodecParameters(t, codec)); err != nil { + t.Fatal(err) + } } offer, err := pc.CreateOffer(nil) if err != nil { @@ -673,7 +941,7 @@ func createPublisherOfferWithH264Fmtp(t *testing.T, fmtp string) string { if localDescription == nil { t.Fatal("publisher local description missing") } - return localDescription.SDP + return filterVideoMediaCodecsForTest(t, localDescription.SDP, codecs) } func createPublisherPeerWithCodec(t *testing.T, mimeType string) (*webrtc.PeerConnection, *webrtc.TrackLocalStaticRTP) { @@ -683,11 +951,17 @@ func createPublisherPeerWithCodec(t *testing.T, mimeType string) (*webrtc.PeerCo var err error switch mimeType { case webrtc.MimeTypeH264: - err = registerH264Codecs(mediaEngine) - codec = h264TrackCapability() + err = registerH264CodecsForRejectedOfferTest(mediaEngine) + codec = webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH264, ClockRate: 90000} + case webrtc.MimeTypeAV1: + err = registerCodecParametersForTest(mediaEngine, av1CodecParameters) + codec = mustTrackCapability(t, videoCodecAV1) case webrtc.MimeTypeVP8: err = registerVP8CodecsForTest(mediaEngine) codec = webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000} + case webrtc.MimeTypeH265: + err = registerH265CodecsForTest(mediaEngine) + codec = webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeH265, ClockRate: 90000} default: t.Fatalf("unsupported test codec: %s", mimeType) } @@ -726,21 +1000,91 @@ func createPublisherPeerWithCodec(t *testing.T, mimeType string) (*webrtc.PeerCo return pc, track } -func createViewerOffer(t *testing.T) string { +func createViewerOfferWithCodec(t *testing.T, codec videoCodec) string { + t.Helper() + return createViewerOfferWithCodecs(t, []videoCodec{codec}) +} + +func createViewerOfferWithCodecs(t *testing.T, codecs []videoCodec) string { t.Helper() mediaEngine := &webrtc.MediaEngine{} - if err := mediaEngine.RegisterDefaultCodecs(); err != nil { - t.Fatal(err) + codecParams := make([]webrtc.RTPCodecParameters, 0) + for _, codec := range codecs { + codecParams = append(codecParams, mustCodecParameters(t, codec)...) + } + for _, codec := range codecParams { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + t.Fatal(err) + } + } + return createViewerOfferWithCodecParameters(t, mediaEngine, codecParams) +} + +func createViewerOfferWithMimeType(t *testing.T, mimeType string) string { + t.Helper() + mediaEngine := &webrtc.MediaEngine{} + var codecParams []webrtc.RTPCodecParameters + switch mimeType { + case webrtc.MimeTypeAV1: + codecParams = mustCodecParameters(t, videoCodecAV1) + case webrtc.MimeTypeH264: + if err := registerH264CodecsForRejectedOfferTest(mediaEngine); err != nil { + t.Fatal(err) + } + codecParams = []webrtc.RTPCodecParameters{{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + }, + PayloadType: 96, + }} + case webrtc.MimeTypeVP8: + if err := registerVP8CodecsForTest(mediaEngine); err != nil { + t.Fatal(err) + } + codecParams = []webrtc.RTPCodecParameters{{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeVP8, + ClockRate: 90000, + }, + PayloadType: 96, + }} + case webrtc.MimeTypeH265: + if err := registerH265CodecsForTest(mediaEngine); err != nil { + t.Fatal(err) + } + codecParams = []webrtc.RTPCodecParameters{{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH265, + ClockRate: 90000, + }, + PayloadType: 116, + }} + default: + t.Fatalf("unsupported viewer mime type: %s", mimeType) } + return createViewerOfferWithCodecParameters(t, mediaEngine, codecParams) +} + +func createViewerOfferWithCodecParameters( + t *testing.T, + mediaEngine *webrtc.MediaEngine, + codecParams []webrtc.RTPCodecParameters, +) string { + t.Helper() api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine)) pc, err := api.NewPeerConnection(webrtc.Configuration{}) if err != nil { t.Fatal(err) } defer pc.Close() - if _, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ + transceiver, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ Direction: webrtc.RTPTransceiverDirectionRecvonly, - }); err != nil { + }) + if err != nil { + t.Fatal(err) + } + if err := transceiver.SetCodecPreferences(codecParams); err != nil { t.Fatal(err) } offer, err := pc.CreateOffer(nil) @@ -759,6 +1103,110 @@ func createViewerOffer(t *testing.T) string { return localDescription.SDP } +func filterVideoMediaCodecsForTest(t *testing.T, sdp string, targetCodecs []videoCodec) string { + t.Helper() + lines := strings.Split(sdp, "\n") + sections := make([][]string, 0) + current := make([]string, 0) + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "m=") && len(current) > 0 { + sections = append(sections, current) + current = nil + } + current = append(current, line) + } + if len(current) > 0 { + sections = append(sections, current) + } + + videoIndex := 0 + filteredSections := make([]string, 0, len(sections)) + for _, section := range sections { + if len(section) == 0 || !strings.HasPrefix(strings.TrimSpace(section[0]), "m=video ") { + filteredSections = append(filteredSections, strings.Join(section, "\n")) + continue + } + if videoIndex >= len(targetCodecs) { + t.Fatalf("SDP has more video media sections than target codecs:\n%s", sdp) + } + filteredSections = append(filteredSections, strings.Join( + filterVideoMediaSectionForTest(section, targetCodecs[videoIndex]), + "\n", + )) + videoIndex++ + } + if videoIndex != len(targetCodecs) { + t.Fatalf("SDP video media sections = %d, want %d:\n%s", videoIndex, len(targetCodecs), sdp) + } + return strings.Join(filteredSections, "\n") +} + +func filterVideoMediaSectionForTest(lines []string, targetCodec videoCodec) []string { + formats := strings.Fields(strings.TrimSpace(lines[0])) + payloadNames := make(map[string]string) + rtxApt := make(map[string]string) + for _, line := range lines[1:] { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "a=rtpmap:") { + parts := strings.Fields(strings.TrimPrefix(trimmed, "a=rtpmap:")) + if len(parts) >= 2 { + payloadNames[parts[0]] = strings.ToLower(strings.SplitN(parts[1], "/", 2)[0]) + } + continue + } + if strings.HasPrefix(trimmed, "a=fmtp:") { + parts := strings.Fields(strings.TrimPrefix(trimmed, "a=fmtp:")) + if len(parts) >= 2 && strings.Contains(parts[1], "apt=") { + payloadType := parts[0] + for _, parameter := range strings.Split(parts[1], ";") { + parameter = strings.TrimSpace(parameter) + if strings.HasPrefix(parameter, "apt=") { + rtxApt[payloadType] = strings.TrimPrefix(parameter, "apt=") + } + } + } + } + } + keptPrimary := make(map[string]struct{}) + keptPayloads := make(map[string]struct{}) + for _, payloadType := range formats[3:] { + if payloadNames[payloadType] == string(targetCodec) { + keptPrimary[payloadType] = struct{}{} + keptPayloads[payloadType] = struct{}{} + } + } + for payloadType, apt := range rtxApt { + if _, ok := keptPrimary[apt]; ok { + keptPayloads[payloadType] = struct{}{} + } + } + + filtered := make([]string, 0, len(lines)) + mline := append([]string(nil), formats[:3]...) + for _, payloadType := range formats[3:] { + if _, ok := keptPayloads[payloadType]; ok { + mline = append(mline, payloadType) + } + } + filtered = append(filtered, strings.Join(mline, " ")) + for _, line := range lines[1:] { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "a=rtpmap:") || + strings.HasPrefix(trimmed, "a=fmtp:") || + strings.HasPrefix(trimmed, "a=rtcp-fb:") { + payloadType := strings.Fields(strings.TrimPrefix(strings.TrimPrefix(strings.TrimPrefix(trimmed, "a=rtpmap:"), "a=fmtp:"), "a=rtcp-fb:")) + if len(payloadType) > 0 { + if _, ok := keptPayloads[payloadType[0]]; !ok { + continue + } + } + } + filtered = append(filtered, line) + } + return filtered +} + func registerVP8CodecsForTest(mediaEngine *webrtc.MediaEngine) error { videoRTCPFeedback := []webrtc.RTCPFeedback{ {Type: "goog-remb"}, @@ -791,40 +1239,179 @@ func registerVP8CodecsForTest(mediaEngine *webrtc.MediaEngine) error { return nil } -func assertVideoSDPOnlyH264(t *testing.T, sdp string) { +func registerCodecParametersForTest(mediaEngine *webrtc.MediaEngine, codecs []webrtc.RTPCodecParameters) error { + for _, codec := range codecs { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + } + return nil +} + +func registerH264CodecsForRejectedOfferTest(mediaEngine *webrtc.MediaEngine) error { + videoRTCPFeedback := []webrtc.RTCPFeedback{ + {Type: "goog-remb"}, + {Type: "ccm", Parameter: "fir"}, + {Type: "nack"}, + {Type: "nack", Parameter: "pli"}, + } + for _, codec := range []webrtc.RTPCodecParameters{ + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + RTCPFeedback: videoRTCPFeedback, + }, + PayloadType: 96, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeRTX, + ClockRate: 90000, + SDPFmtpLine: "apt=96", + }, + PayloadType: 97, + }, + } { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + } + return nil +} + +func registerH265CodecsForTest(mediaEngine *webrtc.MediaEngine) error { + videoRTCPFeedback := []webrtc.RTCPFeedback{ + {Type: "goog-remb"}, + {Type: "ccm", Parameter: "fir"}, + {Type: "nack"}, + {Type: "nack", Parameter: "pli"}, + } + for _, codec := range []webrtc.RTPCodecParameters{ + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH265, + ClockRate: 90000, + RTCPFeedback: videoRTCPFeedback, + }, + PayloadType: 116, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeRTX, + ClockRate: 90000, + SDPFmtpLine: "apt=116", + }, + PayloadType: 117, + }, + } { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + } + return nil +} + +func mustTrackCapability(t *testing.T, codec videoCodec) webrtc.RTPCodecCapability { + t.Helper() + capability, err := trackCapability(codec) + if err != nil { + t.Fatal(err) + } + return capability +} + +func mustCodecParameters(t *testing.T, codec videoCodec) []webrtc.RTPCodecParameters { + t.Helper() + codecs, err := codecParametersForVideoCodec(codec) + if err != nil { + t.Fatal(err) + } + return codecs +} + +func assertVideoSDPOnlyCodec(t *testing.T, sdp string, expected videoCodec) { t.Helper() + assertNoVideoExtmap(t, sdp) codecs := videoCodecNamesFromSDP(sdp) if len(codecs) == 0 { t.Fatalf("SDP video codecs empty:\n%s", sdp) } - hasH264 := false + hasExpected := false for _, codec := range codecs { switch strings.ToLower(codec) { - case "h264": - hasH264 = true + case string(expected): + hasExpected = true case "rtx": default: - t.Fatalf("SDP contains non-H264 video codec %q in codecs %v:\n%s", codec, codecs, sdp) + t.Fatalf("SDP contains unexpected video codec %q in codecs %v:\n%s", codec, codecs, sdp) } } - if !hasH264 { - t.Fatalf("SDP did not contain H264 in video codecs %v:\n%s", codecs, sdp) + if !hasExpected { + t.Fatalf("SDP did not contain %s in video codecs %v:\n%s", expected, codecs, sdp) + } +} + +func assertNoVideoExtmap(t *testing.T, sdp string) { + t.Helper() + inVideo := false + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "m=") { + inVideo = strings.HasPrefix(line, "m=video ") + continue + } + if inVideo && strings.HasPrefix(line, "a=extmap:") { + t.Fatalf("SDP contains video extmap line %q:\n%s", line, sdp) + } } } func videoCodecNamesFromSDP(sdp string) []string { + byMedia := videoCodecNamesByVideoMediaFromSDP(sdp) + if len(byMedia) == 0 { + return nil + } + return byMedia[len(byMedia)-1] +} + +func videoCodecNamesFromAllVideoSDP(sdp string) []string { + byMedia := videoCodecNamesByVideoMediaFromSDP(sdp) + codecs := make([]string, 0) + for _, mediaCodecs := range byMedia { + codecs = append(codecs, mediaCodecs...) + } + return codecs +} + +func videoCodecNamesByVideoMediaFromSDP(sdp string) [][]string { + var byMedia [][]string var payloadTypes []string payloadNames := make(map[string]string) inVideo := false + flush := func() { + if !inVideo { + return + } + codecs := make([]string, 0, len(payloadTypes)) + for _, payloadType := range payloadTypes { + if codecName, ok := payloadNames[payloadType]; ok { + codecs = append(codecs, codecName) + } + } + byMedia = append(byMedia, codecs) + } for _, line := range strings.Split(sdp, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "m=") { + flush() inVideo = strings.HasPrefix(line, "m=video ") if inVideo { parts := strings.Fields(line) if len(parts) > 3 { - payloadTypes = append(payloadTypes[:0], parts[3:]...) + payloadTypes = append([]string(nil), parts[3:]...) } + payloadNames = make(map[string]string) } continue } @@ -840,13 +1427,8 @@ func videoCodecNamesFromSDP(sdp string) []string { codecName := strings.SplitN(parts[1], "/", 2)[0] payloadNames[payloadType] = codecName } - codecs := make([]string, 0, len(payloadTypes)) - for _, payloadType := range payloadTypes { - if codecName, ok := payloadNames[payloadType]; ok { - codecs = append(codecs, codecName) - } - } - return codecs + flush() + return byMedia } type capturingTrackLocalWriter struct {