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
39 changes: 39 additions & 0 deletions Labstream/Shared/Debug/DebugMacPlaybackSurface.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#if DEBUG && os(macOS)
import AppKit

/// A real, isolated render surface for explicitly admitted native playback probes.
/// The normal player host supplies screen diagnostics; no display capability is invented.
@MainActor
final class DebugMacPlaybackSurface {
private let window: NSWindow
private let host: PlayerLayerHostView
private weak var controller: PlaybackController?

init(controller: PlaybackController) {
self.controller = controller
host = PlayerLayerHostView(frame: NSRect(x: 0, y: 0, width: 960, height: 540))
window = NSWindow(contentRect: host.frame, styleMask: [.titled, .closable, .resizable],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.title = "Labstream Dev — Playback evidence"
window.contentView = host
host.setPlayer(controller.player)
host.displayDiagnostics = controller.diagnostics
window.center()
window.makeKeyAndOrderFront(nil)
controller.debugVisibleAttachmentCount += 1
controller.debugVisibleWindowIsVisible = { [weak window] in window?.isVisible == true }
}

func close() {
guard let controller else { return }
self.controller = nil
controller.debugVisibleAttachmentCount = max(0, controller.debugVisibleAttachmentCount - 1)
controller.debugVisibleWindowIsVisible = nil
host.stopDisplayObservation()
host.setPlayer(nil)
window.orderOut(nil)
window.close()
}
}
#endif
22 changes: 21 additions & 1 deletion Labstream/Shared/Debug/DebugPlaybackFrameCapture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,11 @@ enum DebugPlaybackFrameCapture {
return
}

let inspectHDR = ProcessInfo.processInfo.arguments.contains("--vp-probe-capture-hdr-signaling")
let pixelFormat = inspectHDR ? kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange
: kCVPixelFormatType_32BGRA
let output = AVPlayerItemVideoOutput(pixelBufferAttributes: [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferPixelFormatTypeKey as String: pixelFormat,
])
item.add(output)
defer { item.remove(output) }
Expand All @@ -80,6 +83,23 @@ enum DebugPlaybackFrameCapture {
continue
}

if inspectHDR {
// Decoder-output signaling, not measured display luminance. Requested 10-bit
// storage alone does not prove the source was 10-bit or HDR.
var signal: [String: Any] = ["pixelFormat": CVPixelBufferGetPixelFormatType(pixelBuffer),
"width": CVPixelBufferGetWidth(pixelBuffer), "height": CVPixelBufferGetHeight(pixelBuffer)]
for (name, key) in [("transfer", kCVImageBufferTransferFunctionKey),
("primaries", kCVImageBufferColorPrimariesKey),
("matrix", kCVImageBufferYCbCrMatrixKey)] {
if let value = CVBufferCopyAttachment(pixelBuffer, key, nil) as? String {
signal[name] = value
}
}
if let data = try? JSONSerialization.data(withJSONObject: signal, options: .sortedKeys) {
try? data.write(to: directory.appendingPathComponent(String(format: "signal-%02d.json", index)),
options: .atomic)
}
}
let image = CIImage(cvPixelBuffer: pixelBuffer)
if let average = averageRGB(of: image, context: context) {
let luma = 0.2126 * average.r + 0.7152 * average.g + 0.0722 * average.b
Expand Down
4 changes: 4 additions & 0 deletions Labstream/Shared/Debug/DebugPlaybackScenario.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ enum DebugPlaybackScenario {
guard let scenario = name(arguments), admitted(arguments, bitrateKbps: options.bitrateKbps) else {
throw Blocked(reason: .missingAdmission)
}
#if os(macOS)
let surface = DebugMacPlaybackSurface(controller: controller)
defer { surface.close() }
#endif
var snapshots: [DebugPlaybackEvidence.Snapshot] = []
var status: Status = .blocked
var reason: Reason = .deadline
Expand Down
74 changes: 74 additions & 0 deletions Labstream/Shared/Debug/DebugPlexHDREvidence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#if DEBUG
import Foundation
import PMSKit

/// Opt-in, bounded packaging evidence. Credentials and playlist URLs are never exported.
@MainActor
enum DebugPlexHDREvidence {
static var requested: Bool {
let args = ProcessInfo.processInfo.arguments
return args.contains("--vp-probe-allow-live") && args.contains("--vp-probe-hdr-evidence")
}

static func capture(master: String, baseURL: URL, headers: [String: String]) async {
guard requested else { return }
let directory = URL.documentsDirectory.appendingPathComponent("HDRPackaging", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let initFile = directory.appendingPathComponent("init.mp4")
if FileManager.default.fileExists(atPath: initFile.path) {
try FileManager.default.removeItem(at: initFile)
}
// Only bounded technical attributes; never dump server-authored URLs or comments.
let patterns = ["CODECS=\"[^\"]{1,160}\"", "VIDEO-RANGE=[A-Z0-9]+",
"RESOLUTION=[0-9]+x[0-9]+", "FRAME-RATE=[0-9.]+"]
let tags = master.split(whereSeparator: \.isNewline)
.filter { $0.hasPrefix("#EXT-X-STREAM-INF:") }.prefix(8).map(String.init)
let attributes = tags.flatMap { tag in patterns.flatMap { pattern in
let regex = try! NSRegularExpression(pattern: pattern)
return regex.matches(in: tag, range: NSRange(tag.startIndex..., in: tag)).compactMap {
Range($0.range, in: tag).map { String(tag[$0]) }
}
} }
let summary: [String: Any] = ["capturedAt": Date().timeIntervalSince1970,
"variantCount": tags.count, "attributes": attributes]
try JSONSerialization.data(withJSONObject: summary, options: [.prettyPrinted, .sortedKeys])
.write(to: directory.appendingPathComponent("master-summary.json"), options: .atomic)
guard let child = PlexHLSMediaPlaylistPolicy.mediaPlaylist(in: master, baseURL: baseURL,
hdrDisplayEligible: false),
let playlistData = try await read(child, headers: headers, limit: 256_000),
let playlist = String(data: playlistData, encoding: .utf8),
!playlist.contains("#EXT-X-KEY:"),
let map = playlist.split(whereSeparator: \.isNewline).first(where: {
$0.hasPrefix("#EXT-X-MAP:")
}), let start = map.range(of: "URI=\""),
let end = map[start.upperBound...].firstIndex(of: "\""),
let url = URL(string: String(map[start.upperBound..<end]), relativeTo: child)?.absoluteURL,
sameOrigin(url, child),
let data = try await read(url, headers: headers, limit: 1_048_576) else { return }
try data.write(to: initFile, options: .atomic)
} catch {
// Failure leaves no old initialization segment and must not affect playback.
}
}

private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
lhs.scheme == rhs.scheme && lhs.host == rhs.host && lhs.port == rhs.port &&
lhs.user == nil && lhs.password == nil
}

private static func read(_ url: URL, headers: [String: String], limit: Int) async throws -> Data? {
var request = URLRequest(url: url, timeoutInterval: 10)
request.allHTTPHeaderFields = headers
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200,
let finalURL = response.url, sameOrigin(finalURL, url) else { return nil }
var data = Data()
for try await byte in bytes {
guard data.count < limit else { return nil }
data.append(byte)
}
return data
}
}
#endif
2 changes: 2 additions & 0 deletions Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ enum DebugPlexPlaybackProbe {
"videoCodec": media.videoCodec ?? "unknown",
"audioCodec": media.audioCodec ?? "unknown",
"width": media.width ?? 0, "height": media.height ?? 0,
"sourceHDR": media.part.flatMap { $0.streams ?? [] }.compactMap(\.hdrMetadata)
.map(\.displayLabel),
"parts": media.part.map { ["partID": $0.id, "file": $0.file ?? "",
"size": $0.size ?? 0] as [String: Any] }
])
Expand Down
49 changes: 49 additions & 0 deletions Labstream/Shared/Player/CustomPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ struct CustomPlayerView: View {
// hit-testing so the player-surface tap catcher and chrome controls have
// deterministic priority on iPhone/iPad.
.allowsHitTesting(false)
#elseif os(macOS)
PlayerLayerView(player: controller?.player,
captionAppearance: controller?.captionAppearance,
diagnostics: controller?.diagnostics)
.ignoresSafeArea()
#else
PlayerLayerView(player: controller?.player,
captionAppearance: controller?.captionAppearance)
Expand Down Expand Up @@ -419,6 +424,7 @@ func maintainWatchTogetherAttachment(coordinator: WatchTogetherCoordinator,
struct PlayerLayerView: NSViewRepresentable {
let player: AVPlayer?
var captionAppearance: CaptionAppearanceController? = nil
var diagnostics: PlaybackDiagnostics? = nil

final class Coordinator {
weak var captionAppearance: CaptionAppearanceController?
Expand All @@ -430,27 +436,54 @@ struct PlayerLayerView: NSViewRepresentable {
let view = PlayerLayerHostView()
view.playerLayer.videoGravity = .resizeAspect
view.setPlayer(player)
view.displayDiagnostics = diagnostics
captionAppearance?.attachPlayerLayer(view.playerLayer)
context.coordinator.captionAppearance = captionAppearance
return view
}

func updateNSView(_ nsView: PlayerLayerHostView, context: Context) {
nsView.setPlayer(player)
nsView.displayDiagnostics = diagnostics
captionAppearance?.attachPlayerLayer(nsView.playerLayer)
context.coordinator.captionAppearance = captionAppearance
}

static func dismantleNSView(_ nsView: PlayerLayerHostView, coordinator: Coordinator) {
coordinator.captionAppearance?.stopPreview()
coordinator.captionAppearance?.attachPlayerLayer(nil)
nsView.stopDisplayObservation()
nsView.playerLayer.player = nil
}
}

final class PlayerLayerHostView: NSView {
let playerLayer = AVPlayerLayer()
private var loggedZeroSizedLayer = false
weak var displayDiagnostics: PlaybackDiagnostics? {
didSet { scheduleDisplayRefresh() }
}
private var displayObservers: [NSObjectProtocol] = []

func stopDisplayObservation() {
displayObservers.forEach(NotificationCenter.default.removeObserver)
displayObservers.removeAll()
displayDiagnostics?.applyHDRDisplayScreen(potentialEDR: nil, currentEDR: nil)
displayDiagnostics = nil
}

private func scheduleDisplayRefresh() {
// Avoid publishing Observable changes synchronously inside SwiftUI's view update.
Task { @MainActor [weak self] in
guard let self else { return }
let screen = self.window?.screen
self.displayDiagnostics?.applyHDRDisplayEligibility(AVPlayer.eligibleForHDRPlayback)
self.displayDiagnostics?.applyHDRDisplayScreen(
potentialEDR: screen.map { Double($0.maximumPotentialExtendedDynamicRangeColorComponentValue) },
currentEDR: screen.map { Double($0.maximumExtendedDynamicRangeColorComponentValue) })
}
}


override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
Expand Down Expand Up @@ -488,6 +521,22 @@ final class PlayerLayerHostView: NSView {

override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
displayObservers.forEach(NotificationCenter.default.removeObserver)
displayObservers.removeAll()
if let window {
for (name, object) in [
(NSWindow.didChangeScreenNotification, window as AnyObject?),
(NSApplication.didChangeScreenParametersNotification, nil),
(AVPlayer.eligibleForHDRPlaybackDidChangeNotification, nil)
] {
displayObservers.append(NotificationCenter.default.addObserver(
forName: name, object: object, queue: .main
) { [weak self] _ in
Task { @MainActor [weak self] in self?.scheduleDisplayRefresh() }
})
}
}
scheduleDisplayRefresh()
NSLog("LabstreamMacPlayerLayer: view %@", window == nil ? "detached from window" : "attached to window")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ extension PlaybackController {
if let eligible = diagnostics.runtimeEligibleForHDR {
fields["hdr_eligible"] = .bool(eligible)
}
fields["display_capability"] = .label(diagnostics.displayCapabilityLabel)
if let rendered = diagnostics.renderedLabel {
fields["rendered"] = .label(rendered)
}
Expand Down
Loading