diff --git a/README.md b/README.md index 8d5cbec..3dcc204 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ used. - System screen selection through `SCContentSharingPicker`. - Screen, app-audio, and microphone samples through `SCStream`. - RTMP/RTMPS publishing with HaishinKit, plus SRT and experimental WHIP support. -- Configurable resolution, bitrate, frame rate, audio mix, and microphone gain. +- Configurable resolution, bitrate, frame rate, codec, audio mix, and microphone gain. +- H.264 or HEVC encoding with VideoToolbox low-latency rate control. - Optional facecam composited into the outgoing video. - Restream unified chat. - Connection credentials stored in the Keychain. @@ -82,6 +83,16 @@ Stream captures the full display, so it retains its own low-resolution camera in and composites that frame into the selected corner before encoding. Devices that cannot keep a camera session active during multitasking fall back to screen-only. +## Codec selection + +The Video settings offer H.264 (default) or HEVC. HEVC gives roughly a 40% +quality-per-bit gain on text-heavy screen content in the 2–8 Mbps band, but needs +a compatible ingest: it rides SRT (MPEG-TS), WHIP, and *enhanced*-RTMP (the +publisher advertises the `hvc1` FourCC in the E-RTMP connect command). Traditional +RTMP services such as Restream speak H.264 only, so leave the codec on H.264 for +them — HEVC over RTMP should be verified against the specific endpoint on a real +device. Low-latency VideoToolbox rate control is enabled for both codecs. + ## Notes - ScreenCaptureKit is device-only in the current Xcode 27 beta SDK. diff --git a/Stream/SettingsView.swift b/Stream/SettingsView.swift index bfb212f..c715e30 100644 --- a/Stream/SettingsView.swift +++ b/Stream/SettingsView.swift @@ -346,7 +346,9 @@ struct SettingsView: View { // restored from a more capable phone). let quality = min(settings.videoQuality, capability.maxShortEdge) let fps = min(settings.frameRate, capability.maxFrameRate) - return "\(quality)p · \(bitrateLabel(settings.videoBitrate)) · \(fps) fps" + // Surface HEVC (the opt-in) in the collapsed row; H.264 stays implicit. + let codec = settings.videoCodec == .hevc ? " · HEVC" : "" + return "\(quality)p · \(bitrateLabel(settings.videoBitrate)) · \(fps) fps\(codec)" case .backup: return "Off" case .audio: @@ -505,10 +507,31 @@ struct SettingsView: View { Text("\(fps) fps").tag(fps) } } + + Picker("Codec", selection: Binding( + get: { settings.videoCodec }, + set: { settings.videoCodec = $0; onChange() } + )) { + ForEach(VideoCodec.allCases, id: \.self) { codec in + Text(codec.displayName).tag(codec) + } + } } header: { Text("Video") } footer: { + videoFooter + } + } + + @ViewBuilder + private var videoFooter: some View { + VStack(alignment: .leading, spacing: 6) { Text("Bitrate is a maximum and drops automatically when the uplink is congested. Resolution and frame rate are limited to what this device can sustain, and are reduced automatically when it runs warm or low on power.") + if settings.videoCodec == .hevc { + Label("HEVC saves roughly 40% bitrate on screen content, but needs a compatible ingest — SRT, WHIP, or an enhanced-RTMP server. Traditional RTMP services (e.g. Restream) require H.264.", + systemImage: "info.circle") + .foregroundStyle(.secondary) + } } } diff --git a/StreamBroadcast/RTMPPublisher.swift b/StreamBroadcast/RTMPPublisher.swift index 81ad4f9..00b4093 100644 --- a/StreamBroadcast/RTMPPublisher.swift +++ b/StreamBroadcast/RTMPPublisher.swift @@ -20,15 +20,10 @@ private let streamLog = Logger(subsystem: "com.joeblau.Stream", category: "rtmp" actor RTMPPublisher: Publisher { private let mixer = MediaMixer(captureSessionMode: .manual, multiTrackAudioMixingEnabled: true) - /// Use the conservative RTMP connect payload understood by traditional - /// ingests such as Restream. The enhanced-codec fields are unnecessary for - /// this H.264/AAC publisher and some RTMP frontends reject them. - private let connection = RTMPConnection(fourCcList: nil, - videoFourCcInfoMap: nil, - audioFourCcInfoMap: nil, - capsEx: 0, - requestTimeout: 5_000, - qualityOfService: .userInteractive) + /// Built lazily on the first `stream` access — which happens inside `start`, + /// AFTER `settings` is assigned — so its connect payload can depend on the + /// chosen codec. See `makeConnection`. + private lazy var connection = makeConnection() private lazy var stream = RTMPStream(connection: connection) /// Device resolution/fps ceiling (1080p60 on capable hardware, else 720p30), /// computed once. The thermal governor + ABR cap the live rate below it. @@ -95,6 +90,27 @@ actor RTMPPublisher: Publisher { nonisolated func enqueueMic(_ sb: CMSampleBuffer) { micCont.yield(sb) } nonisolated func enqueueApp(_ sb: CMSampleBuffer) { appCont.yield(sb) } + /// Builds the RTMP connection for the current `settings`. + /// + /// HEVC over RTMP requires *enhanced* RTMP (E-RTMP): the connect command must + /// advertise the `hvc1` FourCC so the ingest accepts the HEVC bitstream (the + /// RTMP video tags are then framed with the E-RTMP exHeader automatically). We + /// send that enhanced payload ONLY when the user selected HEVC — traditional + /// ingests such as Restream speak H.264 and some reject the enhanced connect + /// fields, so the H.264 path keeps the conservative nil payload. AAC audio is + /// still what's published in both cases (set via `setAudioSettings`); the audio + /// FourCc advert is a capability, not an obligation. + private func makeConnection() -> RTMPConnection { + let enhanced = settings.videoCodec == .hevc + return RTMPConnection( + fourCcList: enhanced ? RTMPConnection.supportedFourCcList : nil, + videoFourCcInfoMap: enhanced ? RTMPConnection.supportedVideoFourCcInfoMap : nil, + audioFourCcInfoMap: enhanced ? RTMPConnection.supportedAudioFourCcInfoMap : nil, + capsEx: 0, + requestTimeout: 5_000, + qualityOfService: .userInteractive) + } + private func startAudioConsumers() { guard audioConsumers.isEmpty else { return } let mic = micStream, app = appStream @@ -738,7 +754,14 @@ actor RTMPPublisher: Publisher { // AverageBitRate changes are reliably applied so the ABR actually takes // effect. HaishinKit auto-derives a ~1.5x burst cap (dataRateLimits). v.bitRateMode = .average - v.profileLevel = kVTProfileLevel_H264_Main_AutoLevel as String + // HEVC (enhanced-RTMP) when the user opted in, else H.264 Main. The + // profileLevel string also flips the encoder's codec — VideoCodecSettings + // switches to HEVC on the "HEVC" substring — which drives the hvc1 RTMP + // packet framing. makeConnection negotiated the matching E-RTMP payload. + v.profileLevel = settings.videoCodec.videoToolboxProfileLevel + // Low-latency VideoToolbox rate control: tightens encoder queuing latency + // (makeEncoderSpecification enables EnableLowLatencyRateControl). + v.isLowLatencyRateControlEnabled = true v.allowFrameReordering = false do { try await stream.setVideoSettings(v) @@ -906,6 +929,20 @@ actor RTMPPublisher: Publisher { } } +extension VideoCodec { + /// The VideoToolbox profile-level string for this codec, shared by both + /// publishers. Assigning it to `VideoCodecSettings.profileLevel` also flips the + /// encoder's internal `format` to HEVC (its `didSet` keys off the "HEVC" + /// substring), so the encoded bitstream, the RTMP `hvc1` exHeader framing, and + /// the SRT/WHIP payload all follow from this one property. + var videoToolboxProfileLevel: String { + switch self { + case .h264: return kVTProfileLevel_H264_Main_AutoLevel as String + case .hevc: return kVTProfileLevel_HEVC_Main_AutoLevel as String + } + } +} + /// Re-timestamps a video sample buffer to an explicit PTS. func restampVideoBuffer(_ sb: CMSampleBuffer, pts: CMTime) -> CMSampleBuffer? { var timing = CMSampleTimingInfo(duration: .invalid, diff --git a/StreamBroadcast/SessionPublisher.swift b/StreamBroadcast/SessionPublisher.swift index ab58f83..d7a045f 100644 --- a/StreamBroadcast/SessionPublisher.swift +++ b/StreamBroadcast/SessionPublisher.swift @@ -639,7 +639,13 @@ actor SessionPublisher: Publisher { v.frameInterval = await networkController.currentFrameInterval() v.maxKeyFrameIntervalDuration = 2 v.bitRateMode = .average - v.profileLevel = kVTProfileLevel_H264_Main_AutoLevel as String + // HEVC when the user opted in — SRT (MPEG-TS) and WHIP both carry it — else + // H.264 Main. The profileLevel string also switches the encoder's codec + // (VideoCodecSettings flips to HEVC on the "HEVC" substring). + v.profileLevel = settings.videoCodec.videoToolboxProfileLevel + // Low-latency VideoToolbox rate control: tightens encoder queuing latency + // (makeEncoderSpecification enables EnableLowLatencyRateControl). + v.isLowLatencyRateControlEnabled = true v.allowFrameReordering = false return v } diff --git a/StreamCore/StreamSettings.swift b/StreamCore/StreamSettings.swift index e522dd6..10804fb 100644 --- a/StreamCore/StreamSettings.swift +++ b/StreamCore/StreamSettings.swift @@ -36,6 +36,25 @@ public enum BackupQuality: String, Codable, CaseIterable, Sendable { case native } +/// The video codec the encoder targets. HEVC (H.265) yields roughly a 40% +/// quality-per-bit gain over H.264 on text-heavy screen content in the 2–8 Mbps +/// band, at the cost of ingest compatibility: it rides SRT (MPEG-TS), WHIP, and +/// *enhanced*-RTMP (E-RTMP `hvc1` negotiation) but NOT traditional RTMP ingests +/// such as Restream, which speak H.264 only. H.264 Main is therefore the safe, +/// universally-decodable default; HEVC is an opt-in the user validates against +/// their real endpoint (see the encoder wiring in RTMPPublisher/SessionPublisher). +public enum VideoCodec: String, Codable, CaseIterable, Sendable { + case h264 + case hevc + + public var displayName: String { + switch self { + case .h264: return "H.264" + case .hevc: return "HEVC" + } + } +} + /// The transport protocol the user is publishing with. Each protocol's URL and /// key are stored SEPARATELY in the Keychain, so switching the segmented control /// recalls that protocol's own credentials. @@ -107,6 +126,9 @@ public struct StreamSettings: Codable, Equatable, Sendable { public var videoBitrate: Int // bits per second public var audioBitrate: Int // bits per second public var frameRate: Int // fps hint + /// The encoder's target video codec. HEVC is honored on SRT/WHIP and + /// enhanced-RTMP; traditional RTMP ingests fall back to H.264 (see `VideoCodec`). + public var videoCodec: VideoCodec public var pipEnabled: Bool public var pipCorner: PIPCorner public var pipScale: Double // fraction of frame width, 0.10...0.40 @@ -135,6 +157,9 @@ public struct StreamSettings: Codable, Equatable, Sendable { // 24 fps: lighter encode and thermal load than 30 while remaining smooth // for screencast content. frameRate: Int = 24, + // H.264 Main by default: universally decodable, and the only codec + // traditional RTMP ingests (Restream) accept. HEVC is an explicit opt-in. + videoCodec: VideoCodec = .h264, pipEnabled: Bool = false, pipCorner: PIPCorner = .bottomRight, pipScale: Double = 0.28, @@ -152,6 +177,7 @@ public struct StreamSettings: Codable, Equatable, Sendable { self.videoBitrate = videoBitrate self.audioBitrate = audioBitrate self.frameRate = frameRate + self.videoCodec = videoCodec self.pipEnabled = pipEnabled self.pipCorner = pipCorner self.pipScale = pipScale @@ -177,6 +203,7 @@ public struct StreamSettings: Codable, Equatable, Sendable { videoBitrate = try c.decodeIfPresent(Int.self, forKey: .videoBitrate) ?? d.videoBitrate audioBitrate = try c.decodeIfPresent(Int.self, forKey: .audioBitrate) ?? d.audioBitrate frameRate = try c.decodeIfPresent(Int.self, forKey: .frameRate) ?? d.frameRate + videoCodec = try c.decodeIfPresent(VideoCodec.self, forKey: .videoCodec) ?? d.videoCodec pipEnabled = try c.decodeIfPresent(Bool.self, forKey: .pipEnabled) ?? d.pipEnabled pipCorner = try c.decodeIfPresent(PIPCorner.self, forKey: .pipCorner) ?? d.pipCorner pipScale = try c.decodeIfPresent(Double.self, forKey: .pipScale) ?? d.pipScale diff --git a/StreamCoreTests/StreamSettingsCodableTests.swift b/StreamCoreTests/StreamSettingsCodableTests.swift index b0e524f..b327e7b 100644 --- a/StreamCoreTests/StreamSettingsCodableTests.swift +++ b/StreamCoreTests/StreamSettingsCodableTests.swift @@ -28,6 +28,22 @@ import StreamCore #expect(s.audioBitrate == 128_000) } + @Test("A blob written before the codec setting existed defaults to H.264") + func missingVideoCodecDefaultsToH264() throws { + // The upgrade path that matters: a snapshot from a build that predates + // `videoCodec` must decode to the safe, universally-decodable H.264 rather + // than throw and wipe the user's other saved settings. + let s = try decode(#"{"videoBitrate": 6000000}"#) + #expect(s.videoCodec == .h264) + #expect(s.videoBitrate == 6_000_000) + } + + @Test("An explicit HEVC codec decodes back to HEVC") + func explicitHEVCDecodes() throws { + #expect(try decode(#"{"videoCodec": "hevc"}"#).videoCodec == .hevc) + #expect(try decode(#"{"videoCodec": "h264"}"#).videoCodec == .h264) + } + @Test("Explicit JSON null falls back to the default (not a decode failure)") func nullFallsBackToDefault() throws { let s = try decode(#"{"selectedProtocol": null, "videoQuality": null}"#) @@ -51,6 +67,7 @@ import StreamCore videoBitrate: 6_000_000, audioBitrate: 192_000, frameRate: 30, + videoCodec: .hevc, pipEnabled: true, pipCorner: .topLeft, pipScale: 0.33, diff --git a/StreamCoreTests/VideoCodecTests.swift b/StreamCoreTests/VideoCodecTests.swift new file mode 100644 index 0000000..9112e8d --- /dev/null +++ b/StreamCoreTests/VideoCodecTests.swift @@ -0,0 +1,44 @@ +import Testing +import Foundation +import StreamCore + +/// Unit tests for the `VideoCodec` encoder setting. The raw values are the +/// persisted, cross-process contract (they land in the JSON settings blob and are +/// read by both publishers), so a rename here is a breaking change these pins +/// catch. See `StreamSettingsCodableTests` for the settings-level round-trip. +@Suite struct VideoCodecTests { + + @Test("Exactly the two shipping codecs are offered") + func casesAreH264AndHEVC() { + #expect(VideoCodec.allCases == [.h264, .hevc]) + } + + @Test("Raw values are the stable persisted keys") + func rawValuesAreStable() { + // A rename would silently reset every user's saved codec to the default on + // upgrade, so pin the wire strings explicitly. + #expect(VideoCodec.h264.rawValue == "h264") + #expect(VideoCodec.hevc.rawValue == "hevc") + } + + @Test("Display names are the user-facing codec labels") + func displayNames() { + #expect(VideoCodec.h264.displayName == "H.264") + #expect(VideoCodec.hevc.displayName == "HEVC") + } + + @Test("The default settings target H.264 (universally decodable)") + func defaultSettingsUseH264() { + // H.264 is the only codec traditional RTMP ingests accept, and the app's + // default protocol is RTMPS → Restream, so the out-of-box codec must be H.264. + #expect(StreamSettings.default.videoCodec == .h264) + } + + @Test("Each codec round-trips through Codable") + func codableRoundTrip() throws { + for codec in VideoCodec.allCases { + let data = try JSONEncoder().encode(codec) + #expect(try JSONDecoder().decode(VideoCodec.self, from: data) == codec) + } + } +}