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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ blanks the source.
- **Up to 4K at 60 fps**, in **H.264 or HEVC** (HEVC looks the same at
~40% less data). The app only offers resolution/frame-rate combinations
your specific camera supports.
- **HDR streaming (beta).** On cameras that support it, an **HDR (HLG)**
toggle captures and streams 10-bit HLG over HEVC; OBS renders it
correctly on both SDR and HDR canvases. Off by default — SDR remains
the standard path.
- **Pick any lens** — Main, Ultra Wide, Telephoto, or Front — switchable
live while you stream.
- **Hold it however.** The app's UI follows the phone's rotation (unless
Expand Down
9 changes: 6 additions & 3 deletions docs/PERFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,12 @@ Deliberate divergences (don't "fix" these without reading this):
- **Video stabilization stays off** (the AVCaptureVideoDataOutput
default). Every stabilization mode adds frames of latency; this is a
latency-first product. Revisit only as an opt-in.
- **The wire is 8-bit 4:2:0 video-range** (`420v`); HDR formats are not
selected. The whole chain — encoder profile, wire protocol, plugin
decode, OBS frame — assumes 8-bit; HDR would need end-to-end work.
- **The wire defaults to 8-bit 4:2:0 video-range** (`420v`). With the
opt-in HDR toggle the camera path switches to 10-bit HLG (`x420`
capture, HEVC Main10) end-to-end — but 10-bit only ever enters the
pipeline through that toggle. SDR capture, the screen mirror, and the
H.264 path stay 8-bit; keep the 8-bit paths free of 10-bit branches
(the decoder maps formats per-frame, so SDR costs nothing extra).
- **Rotation is sensor-native** (`.landscapeRight`, an effective 0°).
The AVCaptureConnection docs warn per-frame rotation costs; we never
rotate the stream, only the on-phone preview.
10 changes: 10 additions & 0 deletions docs/PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ change mid-stream resets the decoder (the next keyframe re-initializes it).
`kind` mirrors the HELLO field. Dimensions/fps are informational; the
authoritative values come from the bitstream parameter sets.

An optional `"color"` field names the stream's colour mode: `"hlg"`
(10-bit HEVC, BT.2020 primaries, HLG transfer) today, with `"log"`
(Apple Log) reserved for a future mode. Absent = 8-bit SDR BT.709, which
every stream was before the field existed. Like dimensions, it is
**informational** — the decoder takes the authoritative colour
description from the bitstream's VUI, so a receiver that ignores the
field still renders correctly; it exists so UIs can label the stream
without decoding it. HDR streams are HEVC-only (the iPhone encoder has
no 10-bit H.264) and camera-only (screen broadcast stays 8-bit).

### 3 — VIDEO
Payload: one H.264 or HEVC **access unit in Annex B format** (start-code
delimited NAL units), per the codec announced in VIDEO_CONFIG. Keyframe
Expand Down
109 changes: 95 additions & 14 deletions ios-app/Sources/CameraManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,23 @@ final class CameraManager: NSObject {
}
}

func bitrate(for codec: VideoCodec) -> Int {
func bitrate(for codec: VideoCodec,
color: StreamColor = .sdr) -> Int {
let h264: Int
switch self {
case .hd720: h264 = 4_000_000
case .hd1080: h264 = 8_000_000
case .uhd4k: h264 = 30_000_000
}
// HEVC reaches comparable quality at roughly 60% of the bits.
return codec == .hevc ? h264 * 6 / 10 : h264
let base = codec == .hevc ? h264 * 6 / 10 : h264
switch color {
case .sdr:
return base
case .hlg:
// 10-bit carries more data per pixel; give it headroom.
return base * 5 / 4
}
}
}

Expand Down Expand Up @@ -159,15 +167,29 @@ final class CameraManager: NSObject {

private static func format(for device: AVCaptureDevice,
resolution: Resolution,
fps: Int32) -> AVCaptureDevice.Format? {
fps: Int32,
color: StreamColor) -> AVCaptureDevice.Format? {
let target = resolution.size
// Require exact dimensions and a frame-rate range covering the
// requested rate; earlier formats (unbinned, video-range) win ties.
// HLG additionally requires a 10-bit (x420) format that can
// capture BT.2020 HLG — nil (unsupported) if none exists. SDR
// considers every format, exactly as before colour existed.
let candidates = device.formats.filter { format in
let dims = CMVideoFormatDescriptionGetDimensions(format.formatDescription)
guard dims.width == target.width, dims.height == target.height else {
return false
}
switch color {
case .sdr:
break
case .hlg:
guard CMFormatDescriptionGetMediaSubType(format.formatDescription)
== kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange,
format.supportedColorSpaces.contains(.HLG_BT2020) else {
return false
}
}
return format.videoSupportedFrameRateRanges
.contains { $0.maxFrameRate >= Double(fps) }
}
Expand Down Expand Up @@ -218,7 +240,7 @@ final class CameraManager: NSObject {
static func formatReport() -> String {
var out = ["\(UIDevice.current.model) — iOS "
+ UIDevice.current.systemVersion,
"flags: binned / CS / P / SL / R", ""]
"flags: binned / CS / P / SL / R / colour", ""]
for lens in availableLenses() {
guard let device = device(for: lens) else { continue }
out.append("== \(lens.label) ==")
Expand All @@ -240,6 +262,7 @@ final class CameraManager: NSObject {
} else {
flags.append("?")
}
flags.append(colourFlags(format))
out.append(String(format: "%5dx%-5d fps<=%-3.0f %@",
dims.width, dims.height, maxFps,
flags.joined(separator: " ")))
Expand All @@ -249,6 +272,26 @@ final class CameraManager: NSObject {
return out.joined(separator: "\n")
}

/// Colour column of the diagnostics table: the format's bit depth
/// plus the HDR colour spaces it can capture. This is the per-device
/// truth behind the HDR toggle — and, once Apple Log lands, behind
/// that too — the same way the effect flags are for Video Effects.
private static func colourFlags(_ format: AVCaptureDevice.Format) -> String {
let subtype = CMFormatDescriptionGetMediaSubType(
format.formatDescription)
let tenBit = subtype == kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
|| subtype == kCVPixelFormatType_420YpCbCr10BiPlanarFullRange
var flags = [tenBit ? "10bit" : "8bit "]
if format.supportedColorSpaces.contains(.HLG_BT2020) {
flags.append("HLG")
}
if #available(iOS 17.0, *),
format.supportedColorSpaces.contains(.appleLog) {
flags.append("Log")
}
return flags.joined(separator: " ")
}

/// How many of the system video effects this format can run. The
/// effects themselves are user-toggled in Control Center — apps can't
/// switch them on, only pick a format that permits them.
Expand All @@ -265,12 +308,21 @@ final class CameraManager: NSObject {
return score
}

/// Whether this lens can capture the resolution at the frame rate.
/// Drives the app's pickers so unsupported combos are never offered.
/// Whether this lens can capture the resolution at the frame rate
/// (in the given colour pipeline). Drives the app's pickers so
/// unsupported combos are never offered.
static func supports(resolution: Resolution, fps: Int32,
lens: Lens) -> Bool {
lens: Lens, color: StreamColor = .sdr) -> Bool {
guard let device = device(for: lens) else { return false }
return format(for: device, resolution: resolution, fps: fps) != nil
return format(for: device, resolution: resolution, fps: fps,
color: color) != nil
}

/// Whether this combo has a 10-bit HLG capture format — the UI's
/// gate for what the HDR toggle can actually deliver.
static func hdrAvailable(lens: Lens, resolution: Resolution,
fps: Int32) -> Bool {
supports(resolution: resolution, fps: fps, lens: lens, color: .hlg)
}

/// Fires on system-pressure (thermal/power) level changes, on the
Expand Down Expand Up @@ -328,17 +380,20 @@ final class CameraManager: NSObject {
func configure(lens: Lens,
resolution: Resolution,
fps: Int32,
lockFrameRate: Bool = true) throws {
lockFrameRate: Bool = true,
color: StreamColor = .sdr) throws {
try sessionQueue.sync {
try configureOnQueue(lens: lens, resolution: resolution,
fps: fps, lockFrameRate: lockFrameRate)
fps: fps, lockFrameRate: lockFrameRate,
color: color)
}
}

private func configureOnQueue(lens: Lens,
resolution: Resolution,
fps: Int32,
lockFrameRate: Bool) throws {
lockFrameRate: Bool,
color: StreamColor) throws {
let position = lens.position
session.beginConfiguration()
defer { session.commitConfiguration() }
Expand All @@ -349,11 +404,23 @@ final class CameraManager: NSObject {
// Format is chosen manually below; presets can't express 4K60.
session.sessionPreset = .inputPriority

// For HLG we set the device's colour space ourselves below, so
// the session must not manage it (it would override our choice
// on input changes). For SDR, automatic is the iOS default —
// keeping it preserves the pre-HDR behaviour exactly.
switch color {
case .sdr:
session.automaticallyConfiguresCaptureDeviceForWideColor = true
case .hlg:
session.automaticallyConfiguresCaptureDeviceForWideColor = false
}

guard let device = Self.device(for: lens) else {
throw NSError(domain: "CameraManager", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Camera not available"])
}
guard let format = Self.format(for: device, resolution: resolution, fps: fps) else {
guard let format = Self.format(for: device, resolution: resolution,
fps: fps, color: color) else {
throw NSError(domain: "CameraManager", code: 4,
userInfo: [NSLocalizedDescriptionKey:
"\(resolution.rawValue) at \(fps) fps is not supported by the \(lens.label) camera"])
Expand All @@ -368,6 +435,13 @@ final class CameraManager: NSObject {

try device.lockForConfiguration()
device.activeFormat = format
switch color {
case .sdr:
// The session manages colour (automatic wide colour, above).
break
case .hlg:
device.activeColorSpace = .HLG_BT2020
}
// Min duration always caps the rate at the user's choice. The max
// (the cadence lock, see PERFORMANCE.md) is normally pinned too —
// but the "Allow system video effects" experiment leaves it at the
Expand Down Expand Up @@ -398,9 +472,16 @@ final class CameraManager: NSObject {
}

let output = AVCaptureVideoDataOutput()
// Video-range in both pipelines; only the bit depth differs.
let pixelFormat: OSType
switch color {
case .sdr:
pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
case .hlg:
pixelFormat = kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
}
output.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
kCVPixelBufferPixelFormatTypeKey as String: pixelFormat
]
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: videoQueue)
Expand Down
6 changes: 5 additions & 1 deletion ios-app/Sources/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,11 @@ struct ContentView: View {
}

Picker("Codec", selection: $streamer.codec) {
Text(VideoCodec.h264.label).tag(VideoCodec.h264)
// HDR is HEVC-only; offering H.264 would let the picker
// pick a value the didSet immediately reverts.
if !streamer.hdrEnabled {
Text(VideoCodec.h264.label).tag(VideoCodec.h264)
}
if VideoEncoder.isSupported(.hevc) {
Text(VideoCodec.hevc.label).tag(VideoCodec.hevc)
}
Expand Down
11 changes: 11 additions & 0 deletions ios-app/Sources/OptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ struct OptionsView: View {
Text("Experimental: lets iOS lower the frame rate on its own, which the Control Center video effects (Portrait, Studio Light) may require. Takes effect when the camera next starts.")
}

// Hidden entirely on devices that can't encode Main10 —
// a toggle that can never work is worse than none.
if VideoEncoder.hdrSupported {
Section {
Toggle("HDR (10-bit HEVC)",
isOn: $streamer.hdrEnabled)
} footer: {
Text("Streams 10-bit HLG color — OBS tone-maps it for SDR scenes, and HDR canvases get the real thing. HEVC only; takes effect when the camera next starts.")
}
}

Section {
NavigationLink(destination: TallyLightOptionsView()) {
Text("Tally light")
Expand Down
10 changes: 8 additions & 2 deletions ios-app/Sources/StreamClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -485,14 +485,20 @@ final class StreamClient {
send(OBSCProtocol.packet(type: .hello, payload: payload))
}

func sendVideoConfig(codec: VideoCodec, width: Int32, height: Int32, fps: Int32) {
let config: [String: Any] = [
func sendVideoConfig(codec: VideoCodec, width: Int32, height: Int32, fps: Int32,
color: StreamColor = .sdr) {
var config: [String: Any] = [
"codec": codec.rawValue,
"width": Int(width),
"height": Int(height),
"fps": Int(fps),
"kind": sourceKind.rawValue,
]
// Absent = SDR: older plugins ignore unknown keys, and remote
// UIs key off the key's absence.
if color != .sdr {
config["color"] = color.rawValue
}
guard let payload = try? JSONSerialization.data(withJSONObject: config) else { return }
send(OBSCProtocol.packet(type: .videoConfig, payload: payload))
}
Expand Down
Loading
Loading