Skip to content

Commit 5ca4bb2

Browse files
joeblauclaude
andauthored
feat: unlock 1080p60 on capable devices via StreamCapability (#28)
Replace the hard-coded 720px / 30fps encode ceilings with a device- derived StreamCapability (1080p60 on 6GB+ / 6-core hardware, 1080p30 on 4GB, 720p30 on 3GB). encodeSize now takes a maxShortEdge and a new encodeFrameRate(maxFrameRate:) clamps fps, both fed by the capability so a high-end pick never asks a device for more than it can sustain. The thermal governor and adaptive controller continue to pull the live rate down from this static ceiling at runtime. Settings pickers gain 60fps and 10/12 Mbps options, filtered to the device ceiling. Closes #17. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ef7e47d commit 5ca4bb2

7 files changed

Lines changed: 268 additions & 45 deletions

File tree

Stream/ScreenCaptureController.swift

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,10 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
360360

361361
private let publisher: any Publisher
362362
private let settings: StreamSettings
363+
/// Device resolution/fps ceiling. Caps both the encode size (short edge) and
364+
/// the capture pacing so a 1080p60 pick never asks a device for more than it
365+
/// can sustain. The thermal governor tightens this further at runtime.
366+
private let capability = StreamCapability.current
363367
private let onStopped: @Sendable (Error) -> Void
364368
private let micLevelMeter: ScreenCaptureMicrophoneMeter
365369
private let micLevelChannel = MicrophoneLevelChannel()
@@ -384,14 +388,15 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
384388
self.settings = settings
385389
self.onStopped = onStopped
386390
micLevelMeter = ScreenCaptureMicrophoneMeter(gain: settings.micVolume)
387-
targetFrameInterval = CMTime(value: 1, timescale: CMTimeScale(max(1, settings.frameRate)))
391+
targetFrameInterval = CMTime(value: 1,
392+
timescale: CMTimeScale(settings.encodeFrameRate(maxFrameRate: capability.maxFrameRate)))
388393
(videoSamples, videoContinuation) = AsyncStream.makeStream(
389394
of: CMSampleBuffer.self,
390395
bufferingPolicy: .bufferingNewest(1)
391396
)
392397
super.init()
393398
if settings.pipEnabled { facecam.start(with: settings) }
394-
videoConsumer = Task { [videoSamples, publisher, settings] in
399+
videoConsumer = Task { [videoSamples, publisher, settings, capability] in
395400
var targetSize: CGSize?
396401
for await sampleBuffer in videoSamples {
397402
guard sampleBuffer.isValid,
@@ -400,7 +405,8 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
400405
if targetSize == nil {
401406
let width = CVPixelBufferGetWidth(image)
402407
let height = CVPixelBufferGetHeight(image)
403-
let target = settings.encodeSize(forOrientedWidth: width, height: height)
408+
let target = settings.encodeSize(forOrientedWidth: width, height: height,
409+
maxShortEdge: capability.maxShortEdge)
404410
await publisher.setOutputSize(target, nativeShortEdge: min(width, height))
405411
targetSize = target
406412
}
@@ -484,7 +490,9 @@ private final class ScreenCaptureOutput: NSObject, SCStreamOutput, SCStreamDeleg
484490
func applyThermalProfile(frameRateCap: Int, allowPiP: Bool) {
485491
sampleQueue.async { [weak self] in
486492
guard let self else { return }
487-
let fps = max(1, min(self.settings.frameRate, frameRateCap))
493+
// Cap to the device ceiling first, then the thermal frame-rate cap.
494+
let deviceRate = self.settings.encodeFrameRate(maxFrameRate: self.capability.maxFrameRate)
495+
let fps = max(1, min(deviceRate, frameRateCap))
488496
self.targetFrameInterval = CMTime(value: 1, timescale: CMTimeScale(fps))
489497
}
490498
guard settings.pipEnabled else { return }

Stream/SettingsView.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,15 @@ struct SettingsView: View {
9999
// MARK: - Bitrate / fps presets
100100

101101
private static let videoBitrates: [Int] = [
102-
1_000_000, 2_000_000, 3_000_000, 4_500_000, 6_000_000, 8_000_000
102+
1_000_000, 2_000_000, 3_000_000, 4_500_000, 6_000_000, 8_000_000, 10_000_000, 12_000_000
103103
]
104104
private static let audioBitrates: [Int] = [64_000, 96_000, 128_000, 192_000, 256_000]
105-
private static let frameRates: [Int] = [24, 30]
105+
private static let frameRates: [Int] = [24, 30, 60]
106+
107+
/// This device's resolution/fps ceiling. Gates the Quality and Frame Rate
108+
/// menus below so they never offer more than the hardware can actually stream
109+
/// (1080p60 on capable devices, 720p30 on older ones).
110+
private var capability: StreamCapability { .current }
106111

107112
private func bitrateLabel(_ bps: Int) -> String {
108113
String(format: "%.1f Mbps", Double(bps) / 1_000_000)
@@ -451,10 +456,13 @@ struct SettingsView: View {
451456
private var videoSection: some View {
452457
Section {
453458
Picker("Quality", selection: Binding(
454-
get: { settings.videoQuality },
459+
// Show the EFFECTIVE value: a stored pick above this device's
460+
// ceiling displays (and streams) as the capped value rather than
461+
// leaving the picker with no matching selection.
462+
get: { min(settings.videoQuality, capability.maxShortEdge) },
455463
set: { settings.videoQuality = $0; onChange() }
456464
)) {
457-
ForEach(Self.qualities, id: \.self) { q in
465+
ForEach(Self.qualities.filter { $0 <= capability.maxShortEdge }, id: \.self) { q in
458466
Text(qualityLabel(q)).tag(q)
459467
}
460468
}
@@ -469,17 +477,17 @@ struct SettingsView: View {
469477
}
470478

471479
Picker("Frame Rate", selection: Binding(
472-
get: { settings.frameRate },
480+
get: { min(settings.frameRate, capability.maxFrameRate) },
473481
set: { settings.frameRate = $0; onChange() }
474482
)) {
475-
ForEach(Self.frameRates, id: \.self) { fps in
483+
ForEach(Self.frameRates.filter { $0 <= capability.maxFrameRate }, id: \.self) { fps in
476484
Text("\(fps) fps").tag(fps)
477485
}
478486
}
479487
} header: {
480488
Text("Video")
481489
} footer: {
482-
Text("Bitrate is a maximum and automatically drops when the uplink is congested. Frame rate is capped at 30 fps for stable capture and encoding.")
490+
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.")
483491
}
484492
}
485493

StreamBroadcast/RTMPPublisher.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,20 @@ actor RTMPPublisher: Publisher {
3030
requestTimeout: 5_000,
3131
qualityOfService: .userInteractive)
3232
private lazy var stream = RTMPStream(connection: connection)
33+
/// Device resolution/fps ceiling (1080p60 on capable hardware, else 720p30),
34+
/// computed once. The thermal governor + ABR cap the live rate below it.
35+
private let capability = StreamCapability.current
3336
private lazy var networkController = BroadcastAdaptiveBitRateController(
3437
maximumBitRate: settings.videoBitrate,
35-
frameRate: settings.frameRate
38+
frameRate: settings.encodeFrameRate(maxFrameRate: capability.maxFrameRate)
3639
)
3740
private var isRunning = false
3841
private var settings: StreamSettings = .default
42+
43+
/// The frame rate this device can encode: the user's chosen rate clamped to
44+
/// the device capability ceiling. Replaces the old hard `min(frameRate, 30)`
45+
/// scattered across the encode/repeat paths.
46+
private var encodeFrameRate: Int { settings.encodeFrameRate(maxFrameRate: capability.maxFrameRate) }
3947
/// The encode dimensions are locked once, from the first screen frame, so the
4048
/// stream matches the device orientation/aspect. Until set, video is dropped.
4149
private var outputSizeConfigured = false
@@ -89,7 +97,7 @@ actor RTMPPublisher: Publisher {
8997

9098
private func startFrameRepeat() {
9199
frameRepeatTask?.cancel()
92-
let fps = UInt64(max(1, min(settings.frameRate, 30)))
100+
let fps = UInt64(encodeFrameRate)
93101
let interval = 1_000_000_000 / fps
94102
frameRepeatTask = Task { [weak self] in
95103
while !Task.isCancelled {
@@ -728,7 +736,7 @@ actor RTMPPublisher: Publisher {
728736
/// Idempotent — only the first call takes effect.
729737
func setOutputSize(_ size: CGSize, nativeShortEdge _: Int) async {
730738
guard !outputSizeConfigured else { return }
731-
let frameRate = min(max(settings.frameRate, 1), 30)
739+
let frameRate = encodeFrameRate
732740
var v = await stream.videoSettings
733741
v.videoSize = size
734742
v.scalingMode = .letterbox
@@ -772,7 +780,7 @@ actor RTMPPublisher: Publisher {
772780
// never dropped) so latency stays bounded instead of the queue growing.
773781
guard videoAdmission.admit() else { return }
774782
let duration = CMTime(value: 1,
775-
timescale: CMTimeScale(min(max(settings.frameRate, 1), 30)))
783+
timescale: CMTimeScale(encodeFrameRate))
776784
let normalized = timeline.normalize(sb, kind: .video, fallbackDuration: duration)
777785
await mixer.append(enforceMonotonicVideo(normalized, minStep: duration))
778786
}
@@ -1006,7 +1014,11 @@ actor BroadcastAdaptiveBitRateController: StreamBitRateStrategy {
10061014
minimumVideoBitRate = max(300_000, maximumBitRate / 10)
10071015
targetBitRate = maximumBitRate
10081016
pathCeiling = maximumBitRate
1009-
let clampedFrameRate = min(max(frameRate, 1), 30)
1017+
// Clamp only to a sane hardware maximum, NOT 30. The caller already passes
1018+
// a capability-resolved rate (≤60 on capable devices); keeping the real
1019+
// value here is what makes the `configuredFrameRate > 30` congestion tier
1020+
// in `frameInterval` reachable — it was dead code while this pinned to ≤30.
1021+
let clampedFrameRate = min(max(frameRate, 1), 120)
10101022
configuredFrameRate = clampedFrameRate
10111023
preferredFrameInterval = max(0, (1.0 / Double(clampedFrameRate)) - 0.001)
10121024
}

StreamBroadcast/SessionPublisher.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,18 @@ actor SessionPublisher: Publisher {
4343
private let transport: StreamCore.StreamProtocol
4444
private let mixer = MediaMixer(captureSessionMode: .manual,
4545
multiTrackAudioMixingEnabled: true)
46+
/// Device resolution/fps ceiling (1080p60 on capable hardware, else 720p30),
47+
/// computed once. The thermal governor + ABR cap the live rate below it.
48+
private let capability = StreamCapability.current
4649
private lazy var networkController = BroadcastAdaptiveBitRateController(
4750
maximumBitRate: settings.videoBitrate,
48-
frameRate: settings.frameRate)
51+
frameRate: settings.encodeFrameRate(maxFrameRate: capability.maxFrameRate))
4952
private var settings: StreamSettings = .default
5053

54+
/// The frame rate this device can encode: the user's chosen rate clamped to
55+
/// the device capability ceiling. Replaces the old hard `min(frameRate, 30)`.
56+
private var encodeFrameRate: Int { settings.encodeFrameRate(maxFrameRate: capability.maxFrameRate) }
57+
5158
private var session: (any StreamSession)?
5259
private var stream: (any StreamConvertible)?
5360
private var isRunning = false
@@ -92,7 +99,7 @@ actor SessionPublisher: Publisher {
9299

93100
private func startFrameRepeat() {
94101
frameRepeatTask?.cancel()
95-
let fps = UInt64(max(1, min(settings.frameRate, 30)))
102+
let fps = UInt64(encodeFrameRate)
96103
let interval = 1_000_000_000 / fps
97104
frameRepeatTask = Task { [weak self] in
98105
while !Task.isCancelled {
@@ -242,7 +249,7 @@ actor SessionPublisher: Publisher {
242249
}
243250

244251
private func makeVideoSettings(_ current: VideoCodecSettings, size: CGSize? = nil) async -> VideoCodecSettings {
245-
let frameRate = min(max(settings.frameRate, 1), 30)
252+
let frameRate = encodeFrameRate
246253
var v = current
247254
if let size { v.videoSize = size }
248255
v.scalingMode = .letterbox
@@ -264,7 +271,7 @@ actor SessionPublisher: Publisher {
264271
lastVideoAppendAt = DispatchTime.now().uptimeNanoseconds
265272
lastVideoBuffer = sb
266273
guard videoAdmission.admit() else { return }
267-
let duration = CMTime(value: 1, timescale: CMTimeScale(min(max(settings.frameRate, 1), 30)))
274+
let duration = CMTime(value: 1, timescale: CMTimeScale(encodeFrameRate))
268275
let normalized = timeline.normalize(sb, kind: .video, fallbackDuration: duration)
269276
await mixer.append(enforceMonotonicVideo(normalized, minStep: duration))
270277
}

StreamCore/StreamCapability.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import Foundation
2+
3+
/// The resolution and frame-rate ceiling a *device* can sustain for the whole
4+
/// in-app pipeline (ScreenCaptureKit capture → optional facecam compositing →
5+
/// H.264 encode → RTMP/SRT/WHIP publish, all in one process).
6+
///
7+
/// This replaces the old hard `maxStreamShortEdge = 720` / `min(fps, 30)`
8+
/// constants that made the headline 1080p60 target unreachable. The ceiling is
9+
/// derived from real device signals (core count + physical memory) so a capable
10+
/// phone unlocks 1080p60 while an older one stays at a safe 720p30. The
11+
/// *thermal* half of "capability" is layered on top at runtime by
12+
/// `ThermalPowerGovernor` + the adaptive controller, which pull the live rate
13+
/// back below this static ceiling whenever the device runs warm or low on power.
14+
///
15+
/// The derivation is a pure function of injected values (`device(processorCount:
16+
/// physicalMemory:)`) so it is deterministic and unit-testable on CI, which runs
17+
/// the StreamCore suite on whatever simulator the runner ships. Only `current`
18+
/// reads the live `ProcessInfo`, and that is used exclusively by the app.
19+
public struct StreamCapability: Equatable, Sendable {
20+
/// Largest encoded SHORT edge (px) this device may stream. The long edge
21+
/// follows the screen's real aspect ratio. 1080 on capable devices, else 720.
22+
public let maxShortEdge: Int
23+
/// Highest encoded frame rate (fps) this device may stream. 60 on capable
24+
/// devices, else 30. The thermal governor caps this further at runtime.
25+
public let maxFrameRate: Int
26+
27+
public init(maxShortEdge: Int, maxFrameRate: Int) {
28+
self.maxShortEdge = maxShortEdge
29+
self.maxFrameRate = maxFrameRate
30+
}
31+
32+
// MARK: - Device signal thresholds
33+
34+
/// ≈6 GB+ of RAM (reported physical memory sits a little under the nominal
35+
/// spec, so 5.0 GB is a safe divider between 4 GB and 6 GB devices). Devices
36+
/// at or above this — iPhone 14 Pro / 15 / 16 / 17-class and recent iPad
37+
/// Pro/Air — have the memory headroom to run capture + compositing + a real
38+
/// 1080p60 encoder without tripping jetsam.
39+
static let highMemoryFloor: UInt64 = 5_000_000_000
40+
/// ≈4 GB of RAM (3.3 GB divider sits between the ~2.9 GB a 3 GB device
41+
/// reports and the ~3.7 GB a 4 GB device reports). Enough for 1080p30, but
42+
/// not the doubled encoder + capture load of 60 fps.
43+
static let midMemoryFloor: UInt64 = 3_300_000_000
44+
/// 1080p60's encoder throughput needs the full performance-core complement;
45+
/// every 6 GB+ iPhone and iPad reports at least this many logical cores.
46+
static let highCoreFloor = 6
47+
48+
// MARK: - Derivation
49+
50+
/// Derives the ceiling from device signals. Resolution and frame rate are
51+
/// gated independently so a mid-tier device still gets 1080p (at 30 fps)
52+
/// rather than being dropped all the way to 720p:
53+
///
54+
/// - 6 GB+ & ≥6 cores → **1080p60** (the headline target)
55+
/// - 4 GB → **1080p30**
56+
/// - ≤3 GB → **720p30**
57+
///
58+
/// Deliberately conservative: the thermal governor can only pull the rate
59+
/// *down* from here, so the ceiling must be a rate the device can actually
60+
/// hold when cool, not an aspirational peak.
61+
public static func device(
62+
processorCount: Int = ProcessInfo.processInfo.processorCount,
63+
physicalMemory: UInt64 = ProcessInfo.processInfo.physicalMemory
64+
) -> StreamCapability {
65+
let maxShortEdge = physicalMemory >= midMemoryFloor ? 1080 : 720
66+
let maxFrameRate = (physicalMemory >= highMemoryFloor && processorCount >= highCoreFloor)
67+
? 60 : 30
68+
return StreamCapability(maxShortEdge: maxShortEdge, maxFrameRate: maxFrameRate)
69+
}
70+
71+
/// This device's ceiling, computed once from the live `ProcessInfo`. Used by
72+
/// the app (encoder, capture pacing, and the Settings pickers). Tests use
73+
/// `device(processorCount:physicalMemory:)` with injected values instead so
74+
/// they never depend on the CI runner's hardware.
75+
public static let current = device()
76+
}

StreamCore/StreamSettings.swift

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -210,18 +210,15 @@ public struct StreamSettings: Codable, Equatable, Sendable {
210210
}
211211
}
212212

213-
/// Conservative ceiling on the encoded stream's short edge. Screen capture,
214-
/// facecam composition, encoding, chat, and publishing now share the app
215-
/// process, so 720p remains the stable default across supported devices.
216-
public static let maxStreamShortEdge = 720
217-
218213
/// Derives the encode dimensions from the live (already upright-oriented)
219214
/// screen size, preserving the real aspect ratio. The short edge is clamped to
220-
/// `videoQuality` (never upscaled above the source) AND to `maxStreamShortEdge`,
221-
/// and both edges are rounded to even numbers as required by H.264/HEVC. Locking
222-
/// this at broadcast start keeps the RTMP resolution stable for the whole session.
223-
public func encodeSize(forOrientedWidth width: Int, height: Int) -> CGSize {
224-
let targetShortEdge = min(videoQuality, Self.maxStreamShortEdge)
215+
/// `videoQuality` (never upscaled above the source) AND to `maxShortEdge` — the
216+
/// device-capability ceiling from `StreamCapability` (1080 on capable hardware,
217+
/// 720 otherwise) that replaced the old hard-coded 720 cap. Both edges are
218+
/// rounded to even numbers as required by H.264/HEVC. Locking this at broadcast
219+
/// start keeps the stream resolution stable for the whole session.
220+
public func encodeSize(forOrientedWidth width: Int, height: Int, maxShortEdge: Int) -> CGSize {
221+
let targetShortEdge = max(2, min(videoQuality, maxShortEdge))
225222
guard width > 0, height > 0 else {
226223
// Fallback to a portrait 9:16 canvas at the (capped) chosen quality.
227224
return CGSize(width: even(targetShortEdge), height: even(targetShortEdge * 16 / 9))
@@ -233,6 +230,15 @@ public struct StreamSettings: Codable, Equatable, Sendable {
233230
return CGSize(width: max(2, w), height: max(2, h))
234231
}
235232

233+
/// The encode frame rate for a device capability: the user's chosen rate
234+
/// clamped to `[1, maxFrameRate]`. `maxFrameRate` is `StreamCapability`'s
235+
/// device ceiling (60 on capable hardware, else 30); this replaced the hard
236+
/// `min(frameRate, 30)` scattered across the encode/repeat paths. The thermal
237+
/// governor and adaptive controller cap the live rate further at runtime.
238+
public func encodeFrameRate(maxFrameRate: Int) -> Int {
239+
min(max(frameRate, 1), max(1, maxFrameRate))
240+
}
241+
236242
/// Derives the local backup's encode dimensions. `streamSize` is the locked
237243
/// RTMP encode size (already aspect-correct for the screen); `sourceShortEdge`
238244
/// is the short edge of the actual frames the mixer emits (native screen size

0 commit comments

Comments
 (0)