From 0a5c7ae1ede56ab495e71f9e9b8fb890b97ecb5f Mon Sep 17 00:00:00 2001 From: Chen Date: Fri, 8 May 2026 02:42:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(webrtc):=20=E5=BC=BA=E5=88=B6=E5=8D=8F?= =?UTF-8?q?=E5=95=86=20H.264=20=E8=A7=86=E9=A2=91=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swift 发布端只允许 H.264 并输出协商日志 - relay 只注册 H.264/RTX 并拒绝 VP8 offer - 浏览器端校验 H.264 answer 和 stats,补充测试覆盖 --- .../Resources/Localizable.xcstrings | 14 +- .../Resources/displayPage.html | 150 ++++++- .../Views/SharePerformanceModePicker.swift | 2 +- .../Web/WebRTCPublisherSession.swift | 38 +- .../Web/WebRTCSessionSupport.swift | 166 ++++++-- .../WebServerSocketIntegrationTests.swift | 6 + .../Web/WebRTCSessionSupportTests.swift | 115 ++++-- .../VoidDisplayRelay/internal/relay/server.go | 190 ++++++++- .../internal/relay/server_test.go | 385 +++++++++++++++++- 9 files changed, 987 insertions(+), 79 deletions(-) diff --git a/Apps/VoidDisplay/Resources/Localizable.xcstrings b/Apps/VoidDisplay/Resources/Localizable.xcstrings index 797df6b..127aa76 100644 --- a/Apps/VoidDisplay/Resources/Localizable.xcstrings +++ b/Apps/VoidDisplay/Resources/Localizable.xcstrings @@ -323,13 +323,13 @@ } } }, - "Automatic balances 60 fps clarity and encoder load using a shared pixel budget." : { + "Automatic balances H.264 compatibility, clarity, and encoder load." : { "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "自动模式会按 60fps 像素预算平衡清晰度与编码负载。" + "value" : "自动模式会平衡 H.264 兼容性、清晰度与编码负载。" } } } @@ -2916,6 +2916,16 @@ } } }, + "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" : { diff --git a/Sources/VoidDisplaySharing/Resources/displayPage.html b/Sources/VoidDisplaySharing/Resources/displayPage.html index 4d038ba..3a71ca9 100644 --- a/Sources/VoidDisplaySharing/Resources/displayPage.html +++ b/Sources/VoidDisplaySharing/Resources/displayPage.html @@ -251,6 +251,10 @@

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.", overlayNegotiationFailedTitle: "Negotiation failed", overlayNegotiationFailedFallback: "Failed to create WebRTC offer.", overlaySharingStoppedTitle: "Sharing stopped", @@ -291,6 +295,10 @@

Connecting…

overlayWebSocketRequiredBody: "当前浏览器无法建立信令传输通道。", overlayWebRTCRequiredTitle: "需要 WebRTC", overlayWebRTCRequiredBody: "当前浏览器不支持 RTCPeerConnection。", + overlayH264RequiredTitle: "需要 H.264", + overlayH264RequiredBody: "当前浏览器或设备未暴露 WebRTC H.264 播放能力。", + overlayH264AnswerRequiredBody: "WebRTC answer 未协商到 H.264 视频。", + overlayH264StatsRequiredBody: "浏览器 WebRTC stats 未确认 H.264 解码器。", overlayNegotiationFailedTitle: "协商失败", overlayNegotiationFailedFallback: "创建 WebRTC offer 失败。", overlaySharingStoppedTitle: "共享已停止", @@ -342,6 +350,9 @@

Connecting…

return { iceServers: [] }; } })(); + const localOfferIceTimeoutMs = 2000; + const h264StatsConfirmationTimeoutMs = 10000; + const h264StatsPollIntervalMs = 100; function applyStaticCopy() { document.title = t("pageTitle"); @@ -481,7 +492,7 @@

Connecting…

async function waitForLocalOfferSDP() { const startedAt = performance.now(); - while (performance.now() - startedAt < 1000) { + while (performance.now() - startedAt < localOfferIceTimeoutMs) { const sdp = localOfferSDPWithIceCredentials(); if (sdp) { return sdp; @@ -491,21 +502,133 @@

Connecting…

throw new Error("Local WebRTC offer is missing ICE credentials."); } + function isH264Codec(codec) { + return String(codec?.mimeType || "").toLowerCase() === "video/h264"; + } + + function h264ReceiverCodecPreferences() { + if (!window.RTCRtpReceiver || typeof RTCRtpReceiver.getCapabilities !== "function") { + return null; + } + const capabilities = RTCRtpReceiver.getCapabilities("video"); + const codecs = Array.isArray(capabilities?.codecs) + ? capabilities.codecs.filter(isH264Codec) + : []; + if (codecs.length === 0) { + throw new Error(t("overlayH264RequiredBody")); + } + return codecs; + } + + function videoCodecNamesFromSDP(sdp) { + const lines = String(sdp || "").split(/\r?\n/u); + const payloadTypes = []; + const namesByPayloadType = new Map(); + let inVideo = false; + + for (const line of lines) { + if (line.startsWith("m=")) { + inVideo = line.startsWith("m=video "); + if (inVideo) { + payloadTypes.length = 0; + const parts = line.trim().split(/\s+/u); + payloadTypes.push(...parts.slice(3)); + } + continue; + } + if (!inVideo || !line.startsWith("a=rtpmap:")) continue; + const match = /^a=rtpmap:(\d+)\s+([^/\s]+)/iu.exec(line); + if (match) { + namesByPayloadType.set(match[1], match[2].toLowerCase()); + } + } + + return payloadTypes + .map((payloadType) => namesByPayloadType.get(payloadType)) + .filter(Boolean); + } + + function assertH264AnswerSDP(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")); + } + } + + function failH264Requirement(error) { + terminalStop = true; + setStatus(t("statusNegotiationFailed")); + setOverlay( + t("overlayH264RequiredTitle"), + error?.message || t("overlayH264RequiredBody"), + true + ); + clearReconnectTimer(); + closePeer(); + closeSocketAndClearReference(); + transition("closed"); + } + + async function verifySelectedH264Codec() { + if (!peer || typeof peer.getStats !== "function") { + throw new Error(t("overlayH264StatsRequiredBody")); + } + + const startedAt = performance.now(); + while (performance.now() - startedAt < h264StatsConfirmationTimeoutMs) { + const stats = await peer.getStats(); + 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; + if (String(codec.mimeType).toLowerCase() === "video/h264") { + return; + } + throw new Error(t("overlayH264StatsRequiredBody")); + } + await new Promise((resolve) => window.setTimeout(resolve, h264StatsPollIntervalMs)); + } + throw new Error(t("overlayH264StatsRequiredBody")); + } + async function startPeerConnection() { closePeer(); peer = new RTCPeerConnection({ iceServers: bootstrap.iceServers ?? [] }); let usesTransceiverOffer = false; + const h264Preferences = h264ReceiverCodecPreferences(); if (typeof peer.addTransceiver === "function") { - peer.addTransceiver("video", { direction: "recvonly" }); + const transceiver = peer.addTransceiver("video", { direction: "recvonly" }); + if ( + h264Preferences && + typeof transceiver.setCodecPreferences === "function" + ) { + transceiver.setCodecPreferences(h264Preferences); + } usesTransceiverOffer = true; } - peer.ontrack = (event) => { + peer.ontrack = async (event) => { if (event.streams && event.streams[0]) { player.srcObject = event.streams[0]; - setStatus(t("statusLive")); - setOverlay(t("overlayLiveTitle"), t("overlayLiveBody"), false); - transition("streaming"); + try { + await verifySelectedH264Codec(); + setStatus(t("statusLive")); + setOverlay(t("overlayLiveTitle"), t("overlayLiveBody"), false); + transition("streaming"); + } catch (error) { + failH264Requirement(error); + } } }; @@ -614,11 +737,16 @@

Connecting…

switch (payload.type) { case "answer": if (!peer || typeof payload.sdp !== "string") return; - await peer.setRemoteDescription({ - type: "answer", - sdp: payload.sdp - }); - setStatus(t("statusConnected")); + try { + assertH264AnswerSDP(payload.sdp); + await peer.setRemoteDescription({ + type: "answer", + sdp: payload.sdp + }); + setStatus(t("statusConnected")); + } catch (error) { + failH264Requirement(error); + } break; case "ice_candidate": if (!peer || typeof payload.candidate !== "string") return; diff --git a/Sources/VoidDisplaySharing/Views/SharePerformanceModePicker.swift b/Sources/VoidDisplaySharing/Views/SharePerformanceModePicker.swift index 3264cdc..f355235 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 60 fps clarity and encoder load using a shared pixel budget.") + Text("Automatic balances H.264 compatibility, clarity, and encoder load.") .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) diff --git a/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift b/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift index 30b2b8b..41f01a1 100644 --- a/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift +++ b/Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift @@ -118,8 +118,10 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { } private nonisolated func startInternal() async throws { - guard let vp8Codecs = mediaPipeline.preferredVP8Codecs() else { - throw PublisherSessionError.vp8Unavailable + 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 @@ -128,19 +130,22 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { throw PublisherSessionError.videoTransceiverUnavailable } do { - try transceiver.setCodecPreferences(vp8Codecs, error: ()) + try transceiver.setCodecPreferences(h264Codecs, error: ()) } catch { throw PublisherSessionError.codecPreferencesFailed(String(describing: error)) } videoTransceiver = transceiver configureDesktopVideoSender(transceiver.sender, profile: profileState.withLock { $0 }) - AppLog.web.info("WebRTC publisher transceiver configured codecs=\(vp8Codecs.count, privacy: .public).") + AppLog.web.info("WebRTC publisher transceiver configured H264 codecs=\(h264Codecs.count, privacy: .public).") let constraints = RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil) let offer = try await createOffer(constraints: constraints) try await setLocalDescription(offer) + await waitForInitialIceGathering() try throwIfClosed() let finalOffer = peerConnection.localDescription ?? offer + let localCodecSummary = WebRTCCodecPreference.sdpVideoCodecSummary(from: finalOffer.sdp) + AppLog.web.info("WebRTC publisher local SDP video codecs \(localCodecSummary, privacy: .public).") let response = try await relayClient.publisherOffer(roomID: roomID, sdp: finalOffer.sdp) publisherIDState.withLock { $0 = response.publisherID } flushPendingPublisherCandidates(publisherID: response.publisherID) @@ -210,6 +215,18 @@ package final class WebRTCPublisherSession: NSObject, @unchecked Sendable { } } + private nonisolated func waitForInitialIceGathering() async { + let deadline = Date().addingTimeInterval(1) + while peerConnection.iceGatheringState != .complete, !isClosed, Date() < deadline { + try? await Task.sleep(nanoseconds: 25_000_000) + } + if peerConnection.iceGatheringState != .complete { + AppLog.web.info( + "WebRTC publisher continuing before ICE gathering complete state=\(String(describing: self.peerConnection.iceGatheringState), privacy: .public)." + ) + } + } + private nonisolated var isClosed: Bool { stateLock.withLock { state in if case .closed = state { return true } @@ -299,8 +316,13 @@ extension WebRTCPublisherSession: RTCPeerConnectionDelegate { package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) {} package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {} package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCPeerConnectionState) { - if newState == .failed || newState == .closed || newState == .disconnected { + switch newState { + case .connected: + AppLog.web.info("WebRTC publisher state changed to \(String(describing: newState), privacy: .public).") + case .failed, .closed, .disconnected: AppLog.web.warning("WebRTC publisher state changed to \(String(describing: newState), privacy: .public).") + default: + break } } package nonisolated func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) { @@ -320,7 +342,7 @@ extension WebRTCPublisherSession: RTCPeerConnectionDelegate { package enum PublisherSessionError: Error, LocalizedError, Equatable { case closed - case vp8Unavailable + case h264Unavailable case videoTransceiverUnavailable case codecPreferencesFailed(String) case offerMissing @@ -329,8 +351,8 @@ package enum PublisherSessionError: Error, LocalizedError, Equatable { switch self { case .closed: "Publisher session is closed." - case .vp8Unavailable: - "WebRTC VP8 codec preference is unavailable." + 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 .codecPreferencesFailed(let reason): diff --git a/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift b/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift index a5d1fdc..2cd0dbb 100644 --- a/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift +++ b/Sources/VoidDisplaySharing/Web/WebRTCSessionSupport.swift @@ -30,6 +30,12 @@ package enum SignalSessionClientAddResult: Sendable, Equatable { } 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 + package let framesPerSecond: Int package let minBitrateBps: Int package let maxBitrateBps: Int @@ -52,24 +58,24 @@ package struct WebRTCStreamingProfile: Sendable, Equatable { switch performanceMode { case .automatic: self.init( - framesPerSecond: budget.framesPerSecond, - minBitrateBps: 2_000_000, - maxBitrateBps: 24_000_000, - pixelBudgetPerSecond: budget.pixelBudgetPerSecond + framesPerSecond: 30, + minBitrateBps: 1_500_000, + maxBitrateBps: 8_000_000, + pixelBudgetPerSecond: Self.h264AutomaticPixelBudgetPerSecond ) case .smooth: self.init( framesPerSecond: budget.framesPerSecond, - minBitrateBps: 3_000_000, - maxBitrateBps: 32_000_000, - pixelBudgetPerSecond: budget.pixelBudgetPerSecond + minBitrateBps: 1_500_000, + maxBitrateBps: 8_000_000, + pixelBudgetPerSecond: Self.h264SmoothPixelBudgetPerSecond ) case .powerEfficient: self.init( framesPerSecond: budget.framesPerSecond, - minBitrateBps: 1_000_000, - maxBitrateBps: 12_000_000, - pixelBudgetPerSecond: budget.pixelBudgetPerSecond + minBitrateBps: 800_000, + maxBitrateBps: 5_000_000, + pixelBudgetPerSecond: Self.h264PowerEfficientPixelBudgetPerSecond ) } } @@ -86,10 +92,53 @@ 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(dimensions.width), - height: Int32(dimensions.height) + width: Int32(h264Dimensions.width), + height: Int32(h264Dimensions.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 + ) + } + } + return constrained + } + + private static func macroblockCount(for dimensions: CapturePixelDimensions) -> Int { + let macroblockWidth = (dimensions.width + 15) / 16 + let macroblockHeight = (dimensions.height + 15) / 16 + return macroblockWidth * macroblockHeight } } @@ -271,27 +320,27 @@ package struct WebRTCCodecPreferenceDescriptor: Sendable, Equatable { } package enum WebRTCCodecPreference { - package nonisolated static func preferredVP8DescriptorIndexes( + package nonisolated static func requiredH264DescriptorIndexes( from descriptors: [WebRTCCodecPreferenceDescriptor] ) -> [Int]? { - let vp8Indexes = descriptors.indices.filter { - descriptors[$0].name.caseInsensitiveCompare(kRTCVp8CodecName) == .orderedSame + let h264Indexes = descriptors.indices.filter { + descriptors[$0].name.caseInsensitiveCompare(kRTCH264CodecName) == .orderedSame } - guard !vp8Indexes.isEmpty else { return nil } + guard !h264Indexes.isEmpty else { return nil } - let vp8PayloadTypes = Set(vp8Indexes.compactMap { descriptors[$0].payloadType }) + let h264PayloadTypes = Set(h264Indexes.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 vp8PayloadTypes.contains(apt) + return h264PayloadTypes.contains(apt) } - return vp8Indexes + rtxIndexes + return h264Indexes + rtxIndexes } - package nonisolated static func preferredVP8Codecs( + package nonisolated static func requiredH264Codecs( from codecs: [RTCRtpCodecCapability] ) -> [RTCRtpCodecCapability]? { let descriptors = codecs.map { @@ -301,11 +350,77 @@ package enum WebRTCCodecPreference { parameters: $0.parameters ) } - guard let indexes = preferredVP8DescriptorIndexes(from: descriptors) else { + guard let indexes = requiredH264DescriptorIndexes(from: descriptors) else { return nil } return indexes.map { codecs[$0] } } + + package nonisolated static func capabilitySummary( + from codecs: [RTCRtpCodecCapability] + ) -> String { + let descriptors = codecs.map { + WebRTCCodecPreferenceDescriptor( + name: $0.name, + payloadType: $0.preferredPayloadType?.intValue, + parameters: $0.parameters + ) + } + return capabilitySummary(from: descriptors) + } + + package nonisolated static func capabilitySummary( + from descriptors: [WebRTCCodecPreferenceDescriptor] + ) -> String { + let h264Descriptors = descriptors.filter { + $0.name.caseInsensitiveCompare(kRTCH264CodecName) == .orderedSame + } + let h264Summary = h264Descriptors.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: ",") + 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 isVideoMedia = false + + for line in lines { + if line.hasPrefix("m=") { + isVideoMedia = line.hasPrefix("m=video ") + if isVideoMedia { + let parts = line.split(separator: " ").map(String.init) + videoPayloadTypes = parts.count > 3 ? Array(parts.dropFirst(3)) : [] + } + continue + } + + guard isVideoMedia, line.hasPrefix("a=rtpmap:") else { continue } + let mapping = line.dropFirst("a=rtpmap:".count) + guard let separator = mapping.firstIndex(of: " ") else { continue } + let payloadType = String(mapping[.. String? in + guard let name = payloadNames[payloadType] else { return nil } + return "\(payloadType):\(name)" + } + return summary.isEmpty ? "none" : summary.joined(separator: ",") + } } package final class WebRTCMediaPipeline: @unchecked Sendable { @@ -373,9 +488,14 @@ package final class WebRTCMediaPipeline: @unchecked Sendable { return factory.peerConnection(with: configuration, constraints: constraints, delegate: nil) } - nonisolated package func preferredVP8Codecs() -> [RTCRtpCodecCapability]? { + nonisolated package func requiredH264Codecs() -> [RTCRtpCodecCapability]? { + let capabilities = factory.rtpSenderCapabilities(forKind: kRTCMediaStreamTrackKindVideo) + return WebRTCCodecPreference.requiredH264Codecs(from: capabilities.codecs) + } + + nonisolated package func senderVideoCodecCapabilitySummary() -> String { let capabilities = factory.rtpSenderCapabilities(forKind: kRTCMediaStreamTrackKindVideo) - return WebRTCCodecPreference.preferredVP8Codecs(from: capabilities.codecs) + return WebRTCCodecPreference.capabilitySummary(from: capabilities.codecs) } nonisolated package func updateEncodingProfile(_ profile: WebRTCStreamingProfile) { diff --git a/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift b/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift index 8fab4d7..42c555d 100644 --- a/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift +++ b/Tests/VoidDisplaySharingTests/Integration/WebServerSocketIntegrationTests.swift @@ -120,6 +120,10 @@ struct WebServerSocketIntegrationTests { #expect(responseText.contains(#"function syncFullscreenButtonLabel() {"#)) #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(#"peer.addTransceiver("video", { direction: "recvonly" });"#)) #expect(responseText.contains(#"sdp: await waitForLocalOfferSDP()"#)) #expect(!responseText.contains(#"sdp: offer.sdp"#)) @@ -132,6 +136,8 @@ struct WebServerSocketIntegrationTests { #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(#"fullscreenEnter: "全屏""#)) #expect(responseText.contains(#"pageTitle: "Screen Share""#)) #expect(responseText.contains("hero-eyebrow")) diff --git a/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift b/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift index 63ba8b2..2e5d22a 100644 --- a/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift +++ b/Tests/VoidDisplaySharingTests/Web/WebRTCSessionSupportTests.swift @@ -37,21 +37,21 @@ struct WebRTCSessionSupportTests { @Test func streamingProfilesMatchPerformanceModes() { #expect(WebRTCStreamingProfile(performanceMode: .smooth) == WebRTCStreamingProfile( framesPerSecond: 60, - minBitrateBps: 3_000_000, - maxBitrateBps: 32_000_000, - pixelBudgetPerSecond: nil + minBitrateBps: 1_500_000, + maxBitrateBps: 8_000_000, + pixelBudgetPerSecond: WebRTCStreamingProfile.h264SmoothPixelBudgetPerSecond )) #expect(WebRTCStreamingProfile(performanceMode: .automatic) == WebRTCStreamingProfile( - framesPerSecond: 60, - minBitrateBps: 2_000_000, - maxBitrateBps: 24_000_000, - pixelBudgetPerSecond: SharedCapturePerformanceBudget.automaticPixelBudgetPerSecond + framesPerSecond: 30, + minBitrateBps: 1_500_000, + maxBitrateBps: 8_000_000, + pixelBudgetPerSecond: WebRTCStreamingProfile.h264AutomaticPixelBudgetPerSecond )) #expect(WebRTCStreamingProfile(performanceMode: .powerEfficient) == WebRTCStreamingProfile( framesPerSecond: 30, - minBitrateBps: 1_000_000, - maxBitrateBps: 12_000_000, - pixelBudgetPerSecond: SharedCapturePerformanceBudget.powerEfficientPixelBudgetPerSecond + minBitrateBps: 800_000, + maxBitrateBps: 5_000_000, + pixelBudgetPerSecond: WebRTCStreamingProfile.h264PowerEfficientPixelBudgetPerSecond )) } @@ -67,16 +67,35 @@ struct WebRTCSessionSupportTests { let portraitDimensions = WebRTCStreamingProfile(performanceMode: .automatic) .outputDimensions(forWidth: 2_160, height: 3_840) - #expect(smoothDimensions.width == 3_840) - #expect(smoothDimensions.height == 2_160) - #expect(automaticDimensions.width == 2_560) - #expect(automaticDimensions.height == 1_440) - #expect(powerEfficientDimensions.width == 1_920) - #expect(powerEfficientDimensions.height == 1_080) - #expect(wideDimensions.width == 2_968) - #expect(wideDimensions.height == 1_242) - #expect(portraitDimensions.width == 1_440) - #expect(portraitDimensions.height == 2_560) + #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 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), + ] + + for (profile, width, height) in samples { + let dimensions = profile.outputDimensions(forWidth: width, height: height) + let macroblocks = macroblockCount(width: dimensions.width, height: dimensions.height) + + #expect(macroblocks <= 3_600) + #expect(macroblocks * profile.framesPerSecond <= 108_000) + } } #if canImport(WebRTC) @@ -95,8 +114,18 @@ struct WebRTCSessionSupportTests { #expect(sequencer.nextTimestampNs(ptsUs: 0, framesPerSecond: 30) == 49_999_999) } - @Test func vp8CodecPreferenceKeepsVp8AndMatchingRtx() { + @Test func h264CodecPreferenceKeepsOnlyH264AndMatchingRtx() { let descriptors = [ + WebRTCCodecPreferenceDescriptor( + name: kRTCVp8CodecName, + payloadType: 96, + parameters: [:] + ), + WebRTCCodecPreferenceDescriptor( + name: kRTCRtxCodecName, + payloadType: 97, + parameters: ["apt": "96"] + ), WebRTCCodecPreferenceDescriptor( name: kRTCH264CodecName, payloadType: 102, @@ -107,20 +136,44 @@ struct WebRTCSessionSupportTests { payloadType: 103, parameters: ["apt": "102"] ), + ] + + #expect(WebRTCCodecPreference.requiredH264DescriptorIndexes(from: descriptors) == [2, 3]) + #expect(WebRTCCodecPreference.requiredH264DescriptorIndexes(from: Array(descriptors.prefix(2))) == nil) + } + + @Test func sdpVideoCodecSummaryListsVideoPayloadNames() { + let sdp = """ + v=0 + m=audio 9 UDP/TLS/RTP/SAVPF 111 + a=rtpmap:111 opus/48000/2 + m=video 9 UDP/TLS/RTP/SAVPF 102 103 + a=rtpmap:102 H264/90000 + a=rtpmap:103 rtx/90000 + """ + + #expect(WebRTCCodecPreference.sdpVideoCodecSummary(from: sdp) == "102:H264,103:rtx") + } + + @Test func capabilitySummaryDoesNotPrintNonH264CodecNames() { + let descriptors = [ + WebRTCCodecPreferenceDescriptor( + name: kRTCH264CodecName, + payloadType: 102, + parameters: ["profile-level-id": "42e01f"] + ), WebRTCCodecPreferenceDescriptor( name: kRTCVp8CodecName, payloadType: 96, parameters: [:] ), - WebRTCCodecPreferenceDescriptor( - name: kRTCRtxCodecName, - payloadType: 97, - parameters: ["apt": "96"] - ), ] - #expect(WebRTCCodecPreference.preferredVP8DescriptorIndexes(from: descriptors) == [2, 3]) - #expect(WebRTCCodecPreference.preferredVP8DescriptorIndexes(from: Array(descriptors.prefix(2))) == nil) + let summary = WebRTCCodecPreference.capabilitySummary(from: descriptors) + + #expect(summary.contains("H264")) + #expect(summary.contains("nonH264CodecCount=1")) + #expect(!summary.contains("VP8")) } #endif @@ -165,3 +218,9 @@ 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/Tools/VoidDisplayRelay/internal/relay/server.go b/Tools/VoidDisplayRelay/internal/relay/server.go index 5454765..5b092b7 100644 --- a/Tools/VoidDisplayRelay/internal/relay/server.go +++ b/Tools/VoidDisplayRelay/internal/relay/server.go @@ -9,6 +9,7 @@ import ( "log/slog" "net" "net/http" + "strconv" "strings" "sync" "sync/atomic" @@ -17,6 +18,7 @@ import ( "github.com/pion/ice/v4" "github.com/pion/rtcp" "github.com/pion/rtp" + pionsdp "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" ) @@ -88,6 +90,184 @@ type RoomSnapshot struct { FIRForwardCount uint64 `json:"firForwardCount"` } +var errH264VideoCodecMissing = errors.New("h264_video_codec_missing") + +var h264RTCPFeedback = []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, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeRTX, + ClockRate: 90000, + SDPFmtpLine: "apt=39", + }, + PayloadType: 40, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + SDPFmtpLine: "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f", + RTCPFeedback: h264RTCPFeedback, + }, + PayloadType: 112, + }, + { + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeRTX, + ClockRate: 90000, + SDPFmtpLine: "apt=112", + }, + PayloadType: 113, + }, +} + +func registerH264Codecs(mediaEngine *webrtc.MediaEngine) error { + for _, codec := range h264CodecParameters { + if err := mediaEngine.RegisterCodec(codec, webrtc.RTPCodecTypeVideo); err != nil { + return err + } + } + return nil +} + +func h264TrackCapability() webrtc.RTPCodecCapability { + return webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeH264, + ClockRate: 90000, + RTCPFeedback: h264RTCPFeedback, + } +} + +func requireH264VideoCodec(sdp string) 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 + } + payloadTypes := make([]uint8, 0, len(media.MediaName.Formats)) + for _, format := range media.MediaName.Formats { + payloadType, err := strconv.ParseUint(format, 10, 8) + if err != nil { + continue + } + payloadTypes = append(payloadTypes, uint8(payloadType)) + } + codecs, err := description.GetCodecsForPayloadTypes(payloadTypes) + if err != nil { + return err + } + for _, codec := range codecs { + if strings.EqualFold(codec.Name, "H264") { + return nil + } + } + } + return errH264VideoCodecMissing +} + func NewServer(config Config) *Server { logger := config.Logger if logger == nil { @@ -228,7 +408,7 @@ func (s *Server) startWebRTC() error { } udpMux := ice.NewUDPMuxDefault(ice.UDPMuxParams{UDPConn: udpConn}) mediaEngine := &webrtc.MediaEngine{} - if err := mediaEngine.RegisterDefaultCodecs(); err != nil { + if err := registerH264Codecs(mediaEngine); err != nil { _ = udpMux.Close() _ = udpConn.Close() return err @@ -636,6 +816,9 @@ 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 { + return publisherOfferResult{}, err + } pc, err := r.newPeerConnection() if err != nil { return publisherOfferResult{}, err @@ -725,12 +908,15 @@ func (r *Room) SetViewerOffer(clientID string, sdp string) (string, error) { if r.newPeerConnection == nil { return "", errors.New("room_peer_connection_factory_missing") } + if err := requireH264VideoCodec(sdp); err != nil { + return "", err + } pc, err := r.newPeerConnection() if err != nil { return "", err } track, err := webrtc.NewTrackLocalStaticRTP( - webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000}, + h264TrackCapability(), "screen", "voiddisplay", ) diff --git a/Tools/VoidDisplayRelay/internal/relay/server_test.go b/Tools/VoidDisplayRelay/internal/relay/server_test.go index 19f42da..b334157 100644 --- a/Tools/VoidDisplayRelay/internal/relay/server_test.go +++ b/Tools/VoidDisplayRelay/internal/relay/server_test.go @@ -5,10 +5,12 @@ import ( "context" "encoding/json" "net/http" + "strings" "sync" "testing" "time" + "github.com/pion/interceptor" "github.com/pion/rtcp" "github.com/pion/rtp" "github.com/pion/webrtc/v4" @@ -281,6 +283,111 @@ func TestRoomInvalidPublisherOfferPreservesCurrentPublisher(t *testing.T) { } } +func TestRoomPublisherRejectsVP8OnlyOffer(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) + + if _, err := room.SetPublisherOffer(createPublisherOfferWithCodec(t, webrtc.MimeTypeVP8)); err == nil { + t.Fatal("SetPublisherOffer accepted VP8-only SDP") + } +} + +func TestRoomPublisherAcceptsH264Offer(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.MimeTypeH264)) + if err != nil { + t.Fatalf("SetPublisherOffer returned error: %v", err) + } + assertVideoSDPOnlyH264(t, result.SDP) +} + +func TestRoomPublisherAcceptsSwiftH264HighProfileOffer(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(createPublisherOfferWithH264Fmtp( + t, + "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640c1f", + )) + if err != nil { + t.Fatalf("SetPublisherOffer returned error: %v", 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) + } +} + +func TestRoomViewerAnswerUsesOnlyH264(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) + + answer, err := room.SetViewerOffer("viewer", createViewerOffer(t)) + if err != nil { + t.Fatalf("SetViewerOffer returned error: %v", err) + } + assertVideoSDPOnlyH264(t, answer) +} + +func TestRoomForwardRTPRewritesViewerPayloadTypeFromNegotiatedH264Binding(t *testing.T) { + room := newRoomForTest("2", nil) + track, err := webrtc.NewTrackLocalStaticRTP(h264TrackCapability(), "screen", "voiddisplay") + if err != nil { + t.Fatal(err) + } + stream := &capturingTrackLocalWriter{} + _, err = track.Bind(fakeTrackLocalContext{ + codecs: []webrtc.RTPCodecParameters{{ + RTPCodecCapability: h264TrackCapability(), + PayloadType: 124, + }}, + ssrc: 5678, + writeStream: stream, + }) + if err != nil { + t.Fatal(err) + } + room.subscribers["viewer"] = newViewerRTPWriter("2", "viewer", track, nil) + defer room.Close() + + room.ForwardRTP(&rtp.Packet{ + Header: rtp.Header{ + PayloadType: 102, + SSRC: 1234, + Timestamp: 42, + SequenceNumber: 7, + }, + Payload: []byte{1, 2, 3}, + }) + + waitFor(t, func() bool { return stream.count() == 1 }) + header := stream.onlyHeader(t) + if header.PayloadType != 124 { + t.Fatalf("viewer RTP payload type = %d, want 124", header.PayloadType) + } + if header.SSRC != 5678 { + t.Fatalf("viewer RTP SSRC = %d, want 5678", header.SSRC) + } +} + func TestServerListenUDPBindsSocketAndEventsExposeAddress(t *testing.T) { loopback, stopServer := startTestServer(t) defer stopServer() @@ -500,15 +607,36 @@ func startTestServer(t *testing.T) (string, func()) { func createPublisherOffer(t *testing.T) string { t.Helper() - pc, _ := createPublisherPeer(t) + pc, _ := createPublisherPeerWithCodec(t, webrtc.MimeTypeH264) defer pc.Close() return pc.LocalDescription().SDP } -func createPublisherPeer(t *testing.T) (*webrtc.PeerConnection, *webrtc.TrackLocalStaticRTP) { +func createPublisherOfferWithCodec(t *testing.T, mimeType string) string { + t.Helper() + pc, _ := createPublisherPeerWithCodec(t, mimeType) + defer pc.Close() + return pc.LocalDescription().SDP +} + +func createPublisherOfferWithH264Fmtp(t *testing.T, fmtp string) string { t.Helper() mediaEngine := &webrtc.MediaEngine{} - if err := mediaEngine.RegisterDefaultCodecs(); err != nil { + 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 { t.Fatal(err) } api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine)) @@ -516,8 +644,63 @@ func createPublisherPeer(t *testing.T) (*webrtc.PeerConnection, *webrtc.TrackLoc if err != nil { t.Fatal(err) } + defer pc.Close() track, err := webrtc.NewTrackLocalStaticRTP( - webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000}, + 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) + } + offer, err := pc.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + gatherComplete := webrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(offer); err != nil { + t.Fatal(err) + } + <-gatherComplete + localDescription := pc.LocalDescription() + if localDescription == nil { + t.Fatal("publisher local description missing") + } + return localDescription.SDP +} + +func createPublisherPeerWithCodec(t *testing.T, mimeType string) (*webrtc.PeerConnection, *webrtc.TrackLocalStaticRTP) { + t.Helper() + mediaEngine := &webrtc.MediaEngine{} + var codec webrtc.RTPCodecCapability + var err error + switch mimeType { + case webrtc.MimeTypeH264: + err = registerH264Codecs(mediaEngine) + codec = h264TrackCapability() + case webrtc.MimeTypeVP8: + err = registerVP8CodecsForTest(mediaEngine) + codec = webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8, ClockRate: 90000} + default: + t.Fatalf("unsupported test codec: %s", mimeType) + } + if err != nil { + t.Fatal(err) + } + api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine)) + pc, err := api.NewPeerConnection(webrtc.Configuration{}) + if err != nil { + t.Fatal(err) + } + track, err := webrtc.NewTrackLocalStaticRTP( + codec, "screen", "voiddisplay", ) @@ -542,3 +725,197 @@ func createPublisherPeer(t *testing.T) (*webrtc.PeerConnection, *webrtc.TrackLoc } return pc, track } + +func createViewerOffer(t *testing.T) string { + t.Helper() + mediaEngine := &webrtc.MediaEngine{} + if err := mediaEngine.RegisterDefaultCodecs(); err != nil { + t.Fatal(err) + } + 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{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }); err != nil { + t.Fatal(err) + } + offer, err := pc.CreateOffer(nil) + if err != nil { + t.Fatal(err) + } + gatherComplete := webrtc.GatheringCompletePromise(pc) + if err := pc.SetLocalDescription(offer); err != nil { + t.Fatal(err) + } + <-gatherComplete + localDescription := pc.LocalDescription() + if localDescription == nil { + t.Fatal("viewer local description missing") + } + return localDescription.SDP +} + +func registerVP8CodecsForTest(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.MimeTypeVP8, + 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 assertVideoSDPOnlyH264(t *testing.T, sdp string) { + t.Helper() + codecs := videoCodecNamesFromSDP(sdp) + if len(codecs) == 0 { + t.Fatalf("SDP video codecs empty:\n%s", sdp) + } + hasH264 := false + for _, codec := range codecs { + switch strings.ToLower(codec) { + case "h264": + hasH264 = true + case "rtx": + default: + t.Fatalf("SDP contains non-H264 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) + } +} + +func videoCodecNamesFromSDP(sdp string) []string { + var payloadTypes []string + payloadNames := make(map[string]string) + inVideo := false + for _, line := range strings.Split(sdp, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "m=") { + inVideo = strings.HasPrefix(line, "m=video ") + if inVideo { + parts := strings.Fields(line) + if len(parts) > 3 { + payloadTypes = append(payloadTypes[:0], parts[3:]...) + } + } + continue + } + if !inVideo || !strings.HasPrefix(line, "a=rtpmap:") { + continue + } + mapping := strings.TrimPrefix(line, "a=rtpmap:") + parts := strings.Fields(mapping) + if len(parts) < 2 { + continue + } + payloadType := parts[0] + 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 +} + +type capturingTrackLocalWriter struct { + mu sync.Mutex + headers []rtp.Header +} + +func (w *capturingTrackLocalWriter) WriteRTP(header *rtp.Header, payload []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + headerCopy := *header + w.headers = append(w.headers, headerCopy) + return len(payload), nil +} + +func (w *capturingTrackLocalWriter) Write(payload []byte) (int, error) { + return len(payload), nil +} + +func (w *capturingTrackLocalWriter) count() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.headers) +} + +func (w *capturingTrackLocalWriter) onlyHeader(t *testing.T) rtp.Header { + t.Helper() + w.mu.Lock() + defer w.mu.Unlock() + if len(w.headers) != 1 { + t.Fatalf("captured header count = %d, want 1", len(w.headers)) + } + return w.headers[0] +} + +type fakeTrackLocalContext struct { + codecs []webrtc.RTPCodecParameters + ssrc webrtc.SSRC + writeStream webrtc.TrackLocalWriter +} + +func (c fakeTrackLocalContext) CodecParameters() []webrtc.RTPCodecParameters { + return c.codecs +} + +func (c fakeTrackLocalContext) HeaderExtensions() []webrtc.RTPHeaderExtensionParameter { + return nil +} + +func (c fakeTrackLocalContext) SSRC() webrtc.SSRC { + return c.ssrc +} + +func (c fakeTrackLocalContext) SSRCRetransmission() webrtc.SSRC { + return 0 +} + +func (c fakeTrackLocalContext) SSRCForwardErrorCorrection() webrtc.SSRC { + return 0 +} + +func (c fakeTrackLocalContext) WriteStream() webrtc.TrackLocalWriter { + return c.writeStream +} + +func (c fakeTrackLocalContext) ID() string { + return "fake-track-local-context" +} + +func (c fakeTrackLocalContext) RTCPReader() interceptor.RTCPReader { + return nil +}