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
+}