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
49 changes: 36 additions & 13 deletions ios-app/Sources/CameraManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -377,11 +377,17 @@ final class CameraManager: NSObject {
/// AVCaptureSession requires serialized access; start/stop run on
/// `sessionQueue`, so configuration hops there too (synchronously, to
/// keep the throwing API).
/// Returns the colour pipeline actually configured: the capture
/// output can refuse 10-bit delivery even when the device format
/// supports HLG (see the degrade comment below), so callers must
/// build the encoder and announce the stream from this value, never
/// from the colour they asked for.
@discardableResult
func configure(lens: Lens,
resolution: Resolution,
fps: Int32,
lockFrameRate: Bool = true,
color: StreamColor = .sdr) throws {
color: StreamColor = .sdr) throws -> StreamColor {
try sessionQueue.sync {
try configureOnQueue(lens: lens, resolution: resolution,
fps: fps, lockFrameRate: lockFrameRate,
Expand All @@ -393,7 +399,7 @@ final class CameraManager: NSObject {
resolution: Resolution,
fps: Int32,
lockFrameRate: Bool,
color: StreamColor) throws {
color: StreamColor) throws -> StreamColor {
let position = lens.position
session.beginConfiguration()
defer { session.commitConfiguration() }
Expand Down Expand Up @@ -472,17 +478,6 @@ 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: pixelFormat
]
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: videoQueue)

Expand All @@ -493,6 +488,33 @@ final class CameraManager: NSObject {
session.addOutput(output)
videoOutput = output

// The pixel format is chosen AFTER the output joins the session:
// a detached output only advertises the generic 8-bit formats —
// x420 enters availableVideoPixelFormatTypes once the output is
// connected to a device whose active format is 10-bit — and
// assigning a format missing from that list raises an NSException
// Swift cannot catch (the issue #81 TestFlight crash).
if color == .hlg,
!output.availableVideoPixelFormatTypes.contains(
kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange) {
// Degrade to SDR rather than crash — and rebuild the whole
// graph (begin/commit pairs nest) so the format choice,
// colour space, and wide-colour management all match: 8-bit
// capture behind an HLG-tagged Main10 encode would reach OBS
// with the wrong colours.
return try configureOnQueue(lens: lens, resolution: resolution,
fps: fps,
lockFrameRate: lockFrameRate,
color: .sdr)
}
// Video-range in both pipelines; only the bit depth differs.
output.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String:
color == .hlg
? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
: kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
]

if let connection = output.connection(with: .video) {
if connection.isVideoOrientationSupported {
// Sensor-native landscape — the wire format LensLink has
Expand All @@ -509,6 +531,7 @@ final class CameraManager: NSObject {
connection.isVideoMirrored = true
}
}
return color
}

// MARK: - Live camera controls
Expand Down
41 changes: 25 additions & 16 deletions ios-app/Sources/Streamer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,16 @@ final class Streamer: ObservableObject {
}

private func reconfigureLiveCapture(formatChanged: Bool) {
let color = activeColor
// The colour capture actually delivered — the output can refuse
// 10-bit and degrade to SDR, so everything downstream (encoder,
// VIDEO_CONFIG, STATE) keys off configure's return, not the ask.
let color: StreamColor
do {
try camera.configure(lens: selectedLens,
resolution: resolution,
fps: Int32(fps),
lockFrameRate: !allowVideoEffects,
color: color)
color = try camera.configure(lens: selectedLens,
resolution: resolution,
fps: Int32(fps),
lockFrameRate: !allowVideoEffects,
color: activeColor)
} catch {
// Never swallow this: a failed reconfigure leaves the session
// without input/output — a black stream labelled "Live".
Expand Down Expand Up @@ -399,7 +402,10 @@ final class Streamer: ObservableObject {

private func controlStateSnapshot() -> [String: Any] {
let (resolutions, frameRates) = formatCapabilities()
let color = activeColor
// The running encoder is ground truth for what's on the wire —
// capture may have degraded HLG to SDR (see configure) — and the
// settings' intent covers the idle case.
let color = encoder?.color ?? activeColor
// While HLG is active the only valid codec is HEVC; advertising
// just it makes remote UIs refuse h264 switches through the
// existing set_format validation machinery.
Expand Down Expand Up @@ -963,21 +969,24 @@ final class Streamer: ObservableObject {
// Fall back to H.264 automatically if this device can't encode HEVC.
let activeCodec = (codec == .hevc && !VideoEncoder.isSupported(.hevc))
? VideoCodec.h264 : codec
let color = activeColor

// Camera first: the encoder must be built for the colour capture
// actually delivers (the output can refuse 10-bit and degrade to
// SDR), so configure's return decides the profile and bitrate.
let size = resolution.size
let encoder = VideoEncoder(codec: activeCodec,
let encoder: VideoEncoder
do {
let color = try camera.configure(lens: selectedLens,
resolution: resolution,
fps: Int32(fps),
lockFrameRate: !allowVideoEffects,
color: activeColor)
encoder = VideoEncoder(codec: activeCodec,
width: size.width, height: size.height,
fps: Int32(fps),
bitrate: resolution.bitrate(for: activeCodec,
color: color),
color: color)
do {
try camera.configure(lens: selectedLens,
resolution: resolution,
fps: Int32(fps),
lockFrameRate: !allowVideoEffects,
color: color)
try encoder.start()
} catch {
status = .error(error.localizedDescription)
Expand All @@ -1000,7 +1009,7 @@ final class Streamer: ObservableObject {
resetCameraControls()
healthDroppedBaseline = client.statsSnapshot().framesDropped
startAdaptiveBitrate(target: resolution.bitrate(for: activeCodec,
color: color))
color: encoder.color))
client.setStandby(false)
if standbyActive {
// Remote start: reuse the standby transport. If OBS is already
Expand Down
Loading