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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
25 changes: 24 additions & 1 deletion Stream/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
}
}
}

Expand Down
57 changes: 47 additions & 10 deletions StreamBroadcast/RTMPPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion StreamBroadcast/SessionPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
27 changes: 27 additions & 0 deletions StreamCore/StreamSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions StreamCoreTests/StreamSettingsCodableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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}"#)
Expand All @@ -51,6 +67,7 @@ import StreamCore
videoBitrate: 6_000_000,
audioBitrate: 192_000,
frameRate: 30,
videoCodec: .hevc,
pipEnabled: true,
pipCorner: .topLeft,
pipScale: 0.33,
Expand Down
44 changes: 44 additions & 0 deletions StreamCoreTests/VideoCodecTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading