Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Apps/VoidDisplay/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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 兼容性、清晰度与编码负载。"
}
}
}
Expand Down Expand Up @@ -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" : {
Expand Down
150 changes: 139 additions & 11 deletions Sources/VoidDisplaySharing/Resources/displayPage.html
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,10 @@ <h2 id="message-title">Connecting…</h2>
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",
Expand Down Expand Up @@ -291,6 +295,10 @@ <h2 id="message-title">Connecting…</h2>
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: "共享已停止",
Expand Down Expand Up @@ -342,6 +350,9 @@ <h2 id="message-title">Connecting…</h2>
return { iceServers: [] };
}
})();
const localOfferIceTimeoutMs = 2000;
const h264StatsConfirmationTimeoutMs = 10000;
const h264StatsPollIntervalMs = 100;

function applyStaticCopy() {
document.title = t("pageTitle");
Expand Down Expand Up @@ -481,7 +492,7 @@ <h2 id="message-title">Connecting…</h2>

async function waitForLocalOfferSDP() {
const startedAt = performance.now();
while (performance.now() - startedAt < 1000) {
while (performance.now() - startedAt < localOfferIceTimeoutMs) {
const sdp = localOfferSDPWithIceCredentials();
if (sdp) {
return sdp;
Expand All @@ -491,21 +502,133 @@ <h2 id="message-title">Connecting…</h2>
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);
}
}
};

Expand Down Expand Up @@ -614,11 +737,16 @@ <h2 id="message-title">Connecting…</h2>
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 30 additions & 8 deletions Sources/VoidDisplaySharing/Web/WebRTCPublisherSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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):
Expand Down
Loading
Loading