From b4f9dff8e13ab6e7af01edc2743044547fdf0377 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Sun, 13 Sep 2026 20:51:40 -0400 Subject: [PATCH 1/7] Checkpoint paused HDR display-capability work Preserve player-screen EDR diagnostics, eligibility observations, tests and documentation separately from playback PRs. Physical mixed-display and hot-plug acceptance remains open; not a release-readiness claim. --- .../Shared/Player/CustomPlayerView.swift | 49 +++++++++++++++++ .../PlaybackController+Diagnostics.swift | 1 + .../Shared/Player/PlaybackController.swift | 22 +++++++- .../Shared/Player/PlaybackDiagnostics.swift | 41 +++++++++++++- .../Shared/Player/StatsForNerdsView.swift | 1 + ...PlaybackExplanationPresentationTests.swift | 55 +++++++++++++++++++ docs/MACOS.md | 14 +++++ docs/PLAYBACK-ARCHITECTURE.md | 15 +++++ 8 files changed, 194 insertions(+), 4 deletions(-) diff --git a/Labstream/Shared/Player/CustomPlayerView.swift b/Labstream/Shared/Player/CustomPlayerView.swift index 513f2d9a7..304382126 100644 --- a/Labstream/Shared/Player/CustomPlayerView.swift +++ b/Labstream/Shared/Player/CustomPlayerView.swift @@ -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) @@ -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? @@ -430,6 +436,7 @@ 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 @@ -437,6 +444,7 @@ struct PlayerLayerView: NSViewRepresentable { func updateNSView(_ nsView: PlayerLayerHostView, context: Context) { nsView.setPlayer(player) + nsView.displayDiagnostics = diagnostics captionAppearance?.attachPlayerLayer(nsView.playerLayer) context.coordinator.captionAppearance = captionAppearance } @@ -444,6 +452,7 @@ struct PlayerLayerView: NSViewRepresentable { static func dismantleNSView(_ nsView: PlayerLayerHostView, coordinator: Coordinator) { coordinator.captionAppearance?.stopPreview() coordinator.captionAppearance?.attachPlayerLayer(nil) + nsView.stopDisplayObservation() nsView.playerLayer.player = nil } } @@ -451,6 +460,30 @@ struct PlayerLayerView: NSViewRepresentable { 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) @@ -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") } diff --git a/Labstream/Shared/Player/PlaybackController+Diagnostics.swift b/Labstream/Shared/Player/PlaybackController+Diagnostics.swift index b4f35aca5..325f6672e 100644 --- a/Labstream/Shared/Player/PlaybackController+Diagnostics.swift +++ b/Labstream/Shared/Player/PlaybackController+Diagnostics.swift @@ -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) } diff --git a/Labstream/Shared/Player/PlaybackController.swift b/Labstream/Shared/Player/PlaybackController.swift index edf061773..c7e34ebd1 100644 --- a/Labstream/Shared/Player/PlaybackController.swift +++ b/Labstream/Shared/Player/PlaybackController.swift @@ -2880,9 +2880,10 @@ final class PlaybackController { prewarm.outcome.rawValue, prewarm.elapsedSeconds, prewarm.polls) } + diagnostics.applyHDRDisplayEligibility(AVPlayer.eligibleForHDRPlayback) if let copyMaster, let child = PlexHLSMediaPlaylistPolicy.mediaPlaylist(in: copyMaster, - baseURL: streamURL, hdrDisplayEligible: AVPlayer.eligibleForHDRPlayback) { + baseURL: streamURL, hdrDisplayEligible: diagnostics.playerDisplayHDREligible) { streamURL = child recordTranscodeDiagnostic("transcode.media_playlist_selected", fields: [ "reason": .label("single_hdr_variant_sdr_display"), @@ -3718,14 +3719,17 @@ final class PlaybackController { /// Stats. Fired on `.readyToPlay` and retried from the 1s diagnostics tick until the /// probe sees real format descriptions — HLS items expose none until segments load. private func runHDRProbeIfNeeded() { - guard !hdrProbeConclusive, let item = player.currentItem else { return } let eligible = AVPlayer.eligibleForHDRPlayback + diagnostics.applyHDRDisplayEligibility(eligible) + guard !hdrProbeConclusive, let item = player.currentItem else { return } let observedPlaybackGeneration = playbackGeneration Task { @MainActor [weak self] in let result = await PlaybackHDRProbe.probe(playerItem: item, eligibleForHDRPlayback: eligible) guard let self, self.player.currentItem === item, self.isCurrentPlaybackLifecycle(observedPlaybackGeneration) else { return } self.diagnostics.applyRuntimeHDRProbe(result) + // Eligibility may have changed while the asynchronous asset probe suspended. + self.diagnostics.applyHDRDisplayEligibility(AVPlayer.eligibleForHDRPlayback) self.hdrProbeConclusive = result.sawVideoFormatDescriptions } } @@ -3932,6 +3936,20 @@ final class PlaybackController { } }) + // Display eligibility is live state, independent of the conclusive stream probe. + // Owned by the item's observer bag, including paused playback and item teardown. + observers.storeNotification(NotificationCenter.default.addObserver( + forName: AVPlayer.eligibleForHDRPlaybackDidChangeNotification, + object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor in + guard let self, + self.isCurrentObservedItem(playerItem, itemGeneration: itemGeneration, + observedPlaybackGeneration: observedPlaybackGeneration) else { return } + self.diagnostics.applyHDRDisplayEligibility(AVPlayer.eligibleForHDRPlayback) + } + }) + // A start.m3u8 that begins playing but then dies (transcode tears down, segment // 404s) fires this rather than flipping item.status (P3 #8). Treat it the same. observers.storeNotification(NotificationCenter.default.addObserver( diff --git a/Labstream/Shared/Player/PlaybackDiagnostics.swift b/Labstream/Shared/Player/PlaybackDiagnostics.swift index 96bba50ac..afbce8392 100644 --- a/Labstream/Shared/Player/PlaybackDiagnostics.swift +++ b/Labstream/Shared/Player/PlaybackDiagnostics.swift @@ -45,6 +45,39 @@ final class PlaybackDiagnostics { /// stream probe is inconclusive. private(set) var runtimeEligibleForHDR: Bool? + /// The screen containing the Mac player window, not the main/primary screen. + private(set) var displayPotentialEDR: Double? + private(set) var displayCurrentEDR: Double? + + func applyHDRDisplayEligibility(_ eligible: Bool) { + runtimeEligibleForHDR = eligible + } + + func applyHDRDisplayScreen(potentialEDR: Double?, currentEDR: Double?) { + displayPotentialEDR = potentialEDR.flatMap { $0.isFinite && $0 >= 1 ? $0 : nil } + displayCurrentEDR = currentEDR.flatMap { $0.isFinite && $0 >= 1 ? $0 : nil } + } + + /// Prefer the player window's screen over a device-wide positive answer. Before + /// the view attaches, only the AVFoundation capability is available. + var playerDisplayHDREligible: Bool { + runtimeEligibleForHDR == true && (displayPotentialEDR.map { $0 > 1 } ?? true) + } + + /// Capability is not proof that the current stream is being displayed in HDR. + var displayCapabilityLabel: String { + let eligibility = runtimeEligibleForHDR.map { $0 ? "AVPlayer HDR eligible" : "AVPlayer HDR unavailable" } + ?? "AVPlayer HDR eligibility unknown" + #if os(macOS) + guard let potential = displayPotentialEDR else { return "Player display unknown · " + eligibility } + let capability = potential > 1 ? "HDR-capable player display" : "SDR player display" + let headroom = displayCurrentEDR.map { String(format: " · EDR headroom %.1f×", $0) } ?? "" + return capability + " · " + eligibility + headroom + #else + return eligibility + #endif + } + /// GH #196: true when the experimental DV-signalling lane is active for this session /// (playlist injection proxy or dvh1 direct play under the experimental setting). var dvSignallingActive: Bool = false @@ -72,7 +105,8 @@ final class PlaybackDiagnostics { /// passthrough). Nil hides the row. var renderedLabel: String? { guard let sourceHDRFormat, sourceHDRFormat != .sdr else { return nil } - let suffix = runtimeEligibleForHDR == false ? " · display not HDR-eligible" : "" + let displayIsSDROnly = displayPotentialEDR.map { $0 <= 1 } ?? false + let suffix = runtimeEligibleForHDR == false || displayIsSDROnly ? " · display not HDR-eligible" : "" // Runtime truth: segments are loaded and AVFoundation told us what it sees. if let runtimeContainsHDR { if runtimeContainsHDR { @@ -308,7 +342,10 @@ final class PlaybackDiagnostics { /// On a Jellyfin/Emby "transcode" session, a runtime codec matching the source proves /// the server is remuxing (video copy) — upgrade the Mode/Decision rows accordingly. func applyRuntimeHDRProbe(_ result: PlaybackHDRProbeResult) { - runtimeEligibleForHDR = result.eligibleForHDRPlayback + // A suspended asset probe must not overwrite a newer live display sample. + if runtimeEligibleForHDR == nil { + runtimeEligibleForHDR = result.eligibleForHDRPlayback + } guard result.sawVideoFormatDescriptions else { return } runtimeContainsHDR = result.containsHDRVideo runtimeTransferFunction = result.transferFunction diff --git a/Labstream/Shared/Player/StatsForNerdsView.swift b/Labstream/Shared/Player/StatsForNerdsView.swift index 74518619a..29b298b12 100644 --- a/Labstream/Shared/Player/StatsForNerdsView.swift +++ b/Labstream/Shared/Player/StatsForNerdsView.swift @@ -69,6 +69,7 @@ struct StatsForNerdsView: View { if let rendered = diagnostics.renderedLabel { row("Rendered", rendered, wraps: true) } + row("Display", diagnostics.displayCapabilityLabel, wraps: true) row("Audio", diagnostics.audioFormatText) Divider().gridCellUnsizedAxes(.horizontal) row("Target", diagnostics.targetBitrateLabel) diff --git a/LabstreamTests/PlaybackExplanationPresentationTests.swift b/LabstreamTests/PlaybackExplanationPresentationTests.swift index 74259b8ca..ba28eab14 100644 --- a/LabstreamTests/PlaybackExplanationPresentationTests.swift +++ b/LabstreamTests/PlaybackExplanationPresentationTests.swift @@ -4,6 +4,61 @@ import Testing @MainActor struct PlaybackExplanationPresentationTests { + @Test func displayEligibilityUpdatesAfterConclusiveStreamProbeWithoutChangingStreamFacts() { + let diagnostics = PlaybackDiagnostics() + diagnostics.applyRuntimeHDRProbe(.init(containsHDRVideo: true, transferFunction: "PQ", + eligibleForHDRPlayback: true, sawVideoFormatDescriptions: true, videoCodecFourCC: "hvc1")) + diagnostics.applyHDRDisplayEligibility(false) + #expect(diagnostics.runtimeEligibleForHDR == false) + diagnostics.applyRuntimeHDRProbe(.init(containsHDRVideo: true, transferFunction: "PQ", + eligibleForHDRPlayback: true, sawVideoFormatDescriptions: true, videoCodecFourCC: "hvc1")) + #expect(diagnostics.runtimeEligibleForHDR == false) // Older async sample cannot overwrite it. + #expect(diagnostics.runtimeContainsHDR == true) + #expect(diagnostics.runtimeTransferFunction == "PQ") + #expect(diagnostics.displayCapabilityLabel.contains("AVPlayer HDR unavailable")) + diagnostics.applyHDRDisplayEligibility(true) + #expect(diagnostics.runtimeEligibleForHDR == true) + #expect(diagnostics.runtimeVideoCodecFourCC == "hvc1") + } + + @Test func displayUpdatesDoNotInventStreamMetadata() { + let diagnostics = PlaybackDiagnostics() + diagnostics.applyHDRDisplayEligibility(true) + #expect(diagnostics.runtimeContainsHDR == nil) + #expect(diagnostics.runtimeTransferFunction == nil) + } + + #if os(macOS) + @Test func playerScreenCapabilityIsDistinctFromGlobalEligibilityAndCurrentHeadroom() { + let diagnostics = PlaybackDiagnostics() + diagnostics.applyHDRDisplayEligibility(true) + diagnostics.applyHDRDisplayScreen(potentialEDR: 2, currentEDR: 1) + #expect(diagnostics.displayCapabilityLabel.contains("HDR-capable player display")) + #expect(diagnostics.playerDisplayHDREligible) + #expect(diagnostics.displayCurrentEDR == 1) // Low current headroom does not mean SDR-only. + diagnostics.applyHDRDisplayScreen(potentialEDR: 1, currentEDR: 1) + #expect(diagnostics.displayCapabilityLabel.contains("SDR player display")) + #expect(!diagnostics.playerDisplayHDREligible) + diagnostics.applyHDRDisplayScreen(potentialEDR: nil, currentEDR: nil) + #expect(diagnostics.displayCapabilityLabel.contains("Player display unknown")) + diagnostics.applyHDRDisplayScreen(potentialEDR: .nan, currentEDR: .infinity) + #expect(diagnostics.displayPotentialEDR == nil) + #expect(diagnostics.displayCurrentEDR == nil) + } + + @Test func playerDisplayObservationTeardownClearsScreen() async { + let diagnostics = PlaybackDiagnostics() + let host = PlayerLayerHostView(frame: .zero) + host.displayDiagnostics = diagnostics + diagnostics.applyHDRDisplayScreen(potentialEDR: 2, currentEDR: 1.5) + host.stopDisplayObservation() + await Task.yield() + #expect(diagnostics.displayPotentialEDR == nil) + #expect(diagnostics.displayCurrentEDR == nil) + #expect(host.displayDiagnostics == nil) + } + #endif + @Test func statsExplanationKeepsHeadlineAndAtMostTwoUsefulReasons() { let diagnostics = PlaybackDiagnostics() diagnostics.applyStatic(item: MediaItem(ratingKey: "item", title: "Private title", type: "movie"), diff --git a/docs/MACOS.md b/docs/MACOS.md index db0379bc4..dc2b84ea7 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -73,6 +73,20 @@ the normal Keychain policy, but it should be exercised only for intentional iden development: multiple production-identity builds share the same LaunchServices identity, sandbox, Keychain behavior, and logs. +## Live display capability + +Player Stats separates stream metadata from the **Display** capability row. The native video +view tracks its window's screen (not `NSScreen.main`), display moves, and screen-parameter +changes. HDR-capable hardware and current EDR headroom are separate facts: headroom may be +1 while the display still supports HDR. AVPlayer eligibility changes are observed even when +playback is paused or the initial stream probe has finished. Disconnecting the view clears its +screen facts and observers. These are capability diagnostics, not proof of HDR light output. + +Hardware acceptance: move a playing and then paused window between HDR and SDR displays, +toggle the system HDR setting, and disconnect/reconnect the display. Confirm the Display row +updates without a playback restart or encoding-consent bypass. This requires physical display +testing; unit tests and a launch smoke do not close that gate. + ## Cleanup Clean up host-development state after a one-off test or before removing its worktree: diff --git a/docs/PLAYBACK-ARCHITECTURE.md b/docs/PLAYBACK-ARCHITECTURE.md index 93780c5a4..418828778 100644 --- a/docs/PLAYBACK-ARCHITECTURE.md +++ b/docs/PLAYBACK-ARCHITECTURE.md @@ -302,6 +302,21 @@ mechanics remain shared with remote playback. ## HDR and Dolby Vision +Display capability is live state, not a property of the media file. The controller observes +AVFoundation HDR-eligibility changes for the current item's lifetime (including while paused), +refreshes on the diagnostics tick even after stream inspection is conclusive, and re-reads +eligibility after asynchronous inspection so an old sample cannot win a display change. +On macOS the player-layer host samples its own window's screen, observes screen moves and +screen-parameter changes, and removes its subscriptions when detached. Stats shows a separate +**Display** row: AVPlayer eligibility, player-screen capability, and current EDR headroom. +Potential EDR above 1 indicates a capable screen; current headroom of 1 alone does not make +that screen SDR-only. Missing screen information is shown as unknown, not HDR output proof. +The Plex single-variant startup policy uses the known player-screen capability rather than +assuming a device-wide positive answer applies to every monitor. If the view has not attached, +it falls back to AVFoundation's device-wide eligibility. A later display change updates facts +only: it never silently requests video encoding, restarts playback, or promises a dynamic +Dolby Vision/HDR10+ output mode. Physical mixed-display/hot-plug acceptance remains required. + Source classification and runtime observation are deliberately separate. PMSKit maps each backend's available stream metadata into `VideoHDRMetadata`; `PlaybackHDRProbe` later reads AVFoundation tracks and format descriptions after segments load. Stats may describe the From 6d81508ebb6981a77fa2731a1eca9177070eb126 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Sun, 13 Sep 2026 20:51:40 -0400 Subject: [PATCH 2/7] Preserve macOS host Debug launcher identity fix Keep local host network-permission workaround and tooling assertions separate from paused HDR changes. --- docs/DEVELOPMENT.md | 5 +++++ docs/MACOS.md | 16 ++++++++++++++++ scripts/deploy-macos-to-host.sh | 3 +++ scripts/tests/test_tooling_hardening.py | 2 ++ 4 files changed, 26 insertions(+) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5d5d7dccc..215c14ed2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -318,6 +318,11 @@ Use the host helper so builds are staged under a per-worktree development identi scripts/deploy-macos-to-host.sh --launch ``` +The helper disables Xcode's Debug dylib launcher (`ENABLE_DEBUG_DYLIB=NO`): its shared +main-executable UUID caused macOS to prohibit local-network requests across different app +identities even with permission enabled. The signed, non-launcher build restored live access. +See [Mac local-network troubleshooting](MACOS.md#local-network-permission-and-debug-launcher-identity). + For a credential-free semantic agent check, the bounded runner stages an isolated bundle, mounts the real browse/detail UI with synthetic data, presses the stable home-item Accessibility target, asserts the detail tagline, captures only the Labstream window, and stops its exact process: diff --git a/docs/MACOS.md b/docs/MACOS.md index dc2b84ea7..f64ef625c 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -73,6 +73,22 @@ the normal Keychain policy, but it should be exercised only for intentional iden development: multiple production-identity builds share the same LaunchServices identity, sandbox, Keychain behavior, and logs. +## Local network permission and Debug launcher identity + +The host helper sets `ENABLE_DEBUG_DYLIB=NO`. Xcode's small Debug launcher was observed +to have the same executable UUID in isolated-development and production-identity builds. +macOS uses that UUID when enforcing local network privacy; the collision caused local +connections to fail with `Local network prohibited` even while Settings showed access enabled. +Rebuilding the signed production app without the Debug launcher produced a distinct UUID +and restored live server access. This affects local helper builds, not archive settings. + +If this recurs, compare the main executables with `xcrun dwarfdump --uuid`, verify signing, +and check System Settings → Privacy & Security → Local Network. Relaunch and retry after +permission changes. Do not disable network security or relax server policy to mask a local +permission failure. Distinct app identities must not share a main executable UUID; see +[Apple TN3178](https://developer.apple.com/documentation/technotes/tn3178-checking-for-and-resolving-build-uuid-problems) +and [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy). + ## Live display capability Player Stats separates stream metadata from the **Display** capability row. The native video diff --git a/scripts/deploy-macos-to-host.sh b/scripts/deploy-macos-to-host.sh index 7921f6828..9f45d7e27 100755 --- a/scripts/deploy-macos-to-host.sh +++ b/scripts/deploy-macos-to-host.sh @@ -181,11 +181,14 @@ if [ "$BUILD" -eq 1 ]; then # local ad-hoc signing without a provisioning profile for the synthetic bundle identifier. SIGNING_ARGS=("CODE_SIGN_ENTITLEMENTS=Config/LabstreamMacDevelopment.entitlements") fi + # Avoid the shared Debug launcher UUID across dev/production identities: macOS + # local-network privacy uses the main executable UUID (Apple TN3178/TN3179). if ! scripts/xcodebuild-versioned.sh \ -project Labstream.xcodeproj \ -scheme "$SCHEME" \ -destination 'platform=macOS,arch=arm64' \ -configuration Debug \ + ENABLE_DEBUG_DYLIB=NO \ -derivedDataPath "$DERIVED_DATA" \ PRODUCT_BUNDLE_IDENTIFIER="$EFFECTIVE_BUNDLE_ID" \ INFOPLIST_KEY_CFBundleDisplayName="$DISPLAY_NAME" \ diff --git a/scripts/tests/test_tooling_hardening.py b/scripts/tests/test_tooling_hardening.py index c5415bff7..8b634a1e6 100644 --- a/scripts/tests/test_tooling_hardening.py +++ b/scripts/tests/test_tooling_hardening.py @@ -96,6 +96,7 @@ def test_macos_deploy_labels_dev_build_and_deletes_all_staged_apps(self): subprocess.run(["scripts/deploy-macos-to-host.sh"], cwd=root, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) args = args_file.read_text() + self.assertIn("ENABLE_DEBUG_DYLIB=NO", args) branch = subprocess.run(["git", "branch", "--show-current"], cwd=root, check=True, capture_output=True, text=True).stdout.strip() self.assertIn(f"INFOPLIST_KEY_CFBundleDisplayName=Labstream Dev — {branch}", args) @@ -131,6 +132,7 @@ def test_macos_deploy_labels_dev_build_and_deletes_all_staged_apps(self): ], cwd=root, env=env, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) production_args = args_file.read_text() + self.assertIn("ENABLE_DEBUG_DYLIB=NO", production_args) self.assertIn("-allowProvisioningUpdates", production_args) self.assertIn("-allowProvisioningDeviceRegistration", production_args) self.assertIn("DEVELOPMENT_TEAM=TESTTEAM", production_args) From bdfc9c06a7fa90a896672d4a1d399ad285fd0188 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Sun, 13 Sep 2026 23:03:34 -0400 Subject: [PATCH 3/7] Preserve clearer quality labels from superseded copy-lane branch --- .../Shared/Player/PlaybackController.swift | 26 +++++++-------- .../Shared/Player/PlaybackDiagnostics.swift | 6 ++-- .../Playback/PlexVideoTranscodePolicy.swift | 8 ++--- .../Playback/RemoteSeekModePolicy.swift | 2 +- .../Playback/StreamingQualityLadder.swift | 33 ++++++++----------- .../Transcode/AdaptiveBitratePolicy.swift | 9 +++-- .../PMSKit/Transcode/TranscodeRequest.swift | 2 +- .../PMSKitTests/DiagnosticLoggingTests.swift | 2 +- .../PMSKitTests/StreamingQualityTests.swift | 4 +-- README.md | 2 +- docs/PLAYBACK-ARCHITECTURE.md | 14 ++++---- 11 files changed, 49 insertions(+), 59 deletions(-) diff --git a/Labstream/Shared/Player/PlaybackController.swift b/Labstream/Shared/Player/PlaybackController.swift index c7e34ebd1..ebed9b68c 100644 --- a/Labstream/Shared/Player/PlaybackController.swift +++ b/Labstream/Shared/Player/PlaybackController.swift @@ -234,7 +234,7 @@ final class PlaybackController { /// Hard bitrate cap requested of PMS (kbps). 8 Mbps default per spec. /// /// Mutable: the in-player quality menu rebuilds the stream at a new cap via - /// `reload(bitrateKbps:)`. `0` is the sentinel for "Direct Play / Maximum" (no cap). + /// `reload(bitrateKbps:)`. `0` is the sentinel for "Original (Direct Stream)" (no cap). private(set) var maxVideoBitrateKbps: Int /// The user's explicit quality ceiling for this session. Automatic adaptation may move the @@ -2300,7 +2300,7 @@ final class PlaybackController { /// The PMS universal transcoder cannot change its cap mid-session, so we tear the /// HLS stream down and start a fresh `start.m3u8` at `bitrateKbps`. To make it feel /// continuous we snapshot the current playhead, load the new item, then seek back to - /// that position before playing. `0` requests "Direct Play / Maximum" (no cap — we pass + /// that position before playing. `0` requests "Original (Direct Stream)" (no cap — we pass /// a very high ceiling so PMS still produces a compatible HLS rendition). /// /// Only valid for reopenable sessions; a no-op for local files/static streams. @@ -2604,9 +2604,9 @@ final class PlaybackController { } let metadataKey = item.key ?? "/library/metadata/\(item.ratingKey)" - // "Direct Play / Maximum" (the no-cap sentinel) maps to a very high ceiling so PMS still + // "Original (Direct Stream)" (the no-cap sentinel) maps to a very high ceiling so PMS still // emits a playable HLS rendition rather than rejecting an absent cap — the same - // ceiling "Maximum (HLS)" uses, and the transcode fallback when an Original + // ceiling "Maximum (Transcode)" uses, and the transcode fallback when an Original // pick can't be copied. let requestedCap = maxVideoBitrateKbps <= 0 ? StreamingQuality.maxTranscodedKbps : maxVideoBitrateKbps @@ -2616,7 +2616,7 @@ final class PlaybackController { // waiting on a segment the transcoder hasn't reached yet. let resumeMs = resumeOffsetMsOverride ?? item.viewOffset - // GH #196: on the copy lane (Direct Play / Maximum) the `offset=` start param is + // GH #196: on the copy lane (Original (Direct Stream)) the `offset=` start param is // actively harmful — PMS emits `#EXT-X-START:TIME-OFFSET` and AVPlayer was observed // live decoding a single frame at the offset then abandoning the sole variant // (-12880). Start the copy session at 0 and resume via the client-side seek @@ -2858,7 +2858,7 @@ final class PlaybackController { selectedFields.merge(playbackExplanationDiagnosticFields()) { _, new in new } recordPlaybackDiagnostic("playback.stream_selected", fields: selectedFields) - // GH #196 copy-lane startup hardening: on Direct Play / Maximum the PMS session emits + // GH #196 copy-lane startup hardening: on Original (Direct Stream) the PMS session emits // 10s / tens-of-MB fMP4 segments in a single-variant playlist, and on a cold session // the transcoder only starts once the child playlist is fetched — so AVPlayer's hard // startup deadlines (-12889/-16830) can fire before the first segment exists, and with @@ -2893,7 +2893,7 @@ final class PlaybackController { // Plex Universal HLS can rely on the X-Plex identity headers in addition to the // token-bearing query string. In particular the Generic profile path that lets PMS - // remux/copy 10-bit HEVC at Direct Play / Maximum has been observed to 400 on the + // remux/copy 10-bit HEVC at Original (Direct Stream) has been observed to 400 on the // same URL when fetched without the standard X-Plex headers. Thread the headers into // AVFoundation so the media-plane request matches our successful control preflight. let assetOptions: [String: Any] = [ @@ -2943,7 +2943,7 @@ final class PlaybackController { } #if DEBUG - /// DEBUG-only per-title decision logger for the "Direct Play / Maximum" path. When PMS agrees + /// DEBUG-only per-title decision logger for the "Original (Direct Stream)" path. When PMS agrees /// to direct-play a title we commit `directPlayStartM3U8URL()`; this records WHY, so a Debug /// build can distinguish whole-file direct play (`mde=1000`) from Direct Stream (`video=copy`, /// copy video / transcode audio) at a glance, alongside the production verdict we would @@ -4906,7 +4906,7 @@ final class PlaybackController { if isRemoteTranscode { return remoteTranscodeStallTimeoutSeconds } - // Direct Play / Maximum can legally be a very high-bitrate HEVC remux. Initial fMP4 + // Original (Direct Stream) can legally be a very high-bitrate HEVC remux. Initial fMP4 // segments for 4K remuxes can be tens of MB and the simulator/media plane can take a long // time to reach ready/keep-up even when PMS is serving valid video-copy bytes. Do not trip // the generic 15s watchdog or convert this explicit user choice into a capped transcode. @@ -5098,8 +5098,8 @@ final class PlaybackController { message = HLSStartupDeadlinePolicy.failureMessage(errorLogCodes: deadlineCodes) NSLog("PlaybackController: stall watchdog found startup-deadline error log; surfacing deadline failure (#196)") } else if maxVideoBitrateKbps <= 0 { - message = "Direct Play / Maximum stalled before playback could start. The stream may be above this network or player path's capacity. Tap Retry, or choose a transcoded/lower quality." - NSLog("PlaybackController: Direct Play / Maximum stalled with no item error; surfacing capacity hint") + message = "Original (Direct Stream) stalled before playback could start. The stream may be above this network or player path's capacity. Tap Retry, or choose a transcoded/lower quality." + NSLog("PlaybackController: Original (Direct Stream) stalled with no item error; surfacing capacity hint") } else { message = "Playback stalled. The server or network may be unreachable. Tap Retry once your connection is back." NSLog("PlaybackController: stream stalled with no item error; surfacing generic failure") @@ -5219,9 +5219,9 @@ final class PlaybackController { private func attemptAdaptiveBitrateFallback(fields baseFields: [String: DiagnosticFieldValue]) -> Bool { guard adaptiveBitrateEnabled else { return false } guard supportsQualityReload else { return false } - // Do not silently convert an explicit Direct Play / Maximum selection into a capped + // Do not silently convert an explicit Original (Direct Stream) selection into a capped // video transcode. That is worse than the user's chosen direct/remux path on fast links - // and was the source of the apparent ~20 Mbps fallback. If Direct Play / Maximum truly + // and was the source of the apparent ~20 Mbps fallback. If Original (Direct Stream) truly // cannot play, surface Retry rather than automatically abandoning video-copy. guard maxVideoBitrateKbps > 0 else { return false } guard let decision = adaptiveBitratePolicy.recordStall( diff --git a/Labstream/Shared/Player/PlaybackDiagnostics.swift b/Labstream/Shared/Player/PlaybackDiagnostics.swift index afbce8392..9ffc9503a 100644 --- a/Labstream/Shared/Player/PlaybackDiagnostics.swift +++ b/Labstream/Shared/Player/PlaybackDiagnostics.swift @@ -180,7 +180,7 @@ final class PlaybackDiagnostics { // MARK: Dynamic numbers - /// The requested hard cap (kbps). 0 means "Direct Play / Maximum" (no cap). + /// The requested hard cap (kbps). 0 means "Original (Direct Stream)" (no cap). var targetBitrateKbps: Int = 0 /// Last active observed throughput sample (kbps), from the access log. This is empirical /// transfer throughput while AVFoundation is downloading, not encoded stream bitrate. @@ -216,8 +216,8 @@ final class PlaybackDiagnostics { /// choices; numeric caps render as " Mbps". var targetBitrateLabel: String { switch targetBitrateKbps { - case ...0: "Direct Play / Maximum" - case StreamingQuality.maxTranscodedKbps: "Maximum (HLS)" + case ...0: "Original (Direct Stream)" + case StreamingQuality.maxTranscodedKbps: "Maximum (Transcode)" default: "\(targetBitrateKbps / 1000) Mbps" } } diff --git a/PMSKit/Sources/PMSKit/Playback/PlexVideoTranscodePolicy.swift b/PMSKit/Sources/PMSKit/Playback/PlexVideoTranscodePolicy.swift index 5c8186dd3..13bf85553 100644 --- a/PMSKit/Sources/PMSKit/Playback/PlexVideoTranscodePolicy.swift +++ b/PMSKit/Sources/PMSKit/Playback/PlexVideoTranscodePolicy.swift @@ -1,9 +1,5 @@ -/// Decides when Plex's universal HLS request must disable Direct Stream and re-encode video. -/// -/// A high bitrate ceiling alone does not force a transcode: with `directStream=1`, PMS is free -/// to copy the source video into fMP4. That is desirable for the initial Direct Play / Maximum -/// probe, but it defeats both the explicit Maximum (HLS) transcode choice and every production -/// fallback after Direct Play probing or playback rejected the copy lane. +/// Forces video encoding for explicit Maximum intent, an approved fallback, or the DV guard. +/// The caller retains the Original consent gate; an unapproved Original request stays copy-capable. public enum PlexVideoTranscodePolicy { public static func shouldForceVideoTranscode(selectedQualityKbps: Int, directPlayProductionFallback: Bool, diff --git a/PMSKit/Sources/PMSKit/Playback/RemoteSeekModePolicy.swift b/PMSKit/Sources/PMSKit/Playback/RemoteSeekModePolicy.swift index 5b42920e6..c582f03d0 100644 --- a/PMSKit/Sources/PMSKit/Playback/RemoteSeekModePolicy.swift +++ b/PMSKit/Sources/PMSKit/Playback/RemoteSeekModePolicy.swift @@ -12,7 +12,7 @@ public enum RemoteSeekModePolicy { public enum StreamKind: Sendable, Equatable { case localFile case plexStreamingHLS - /// Plex video-copy (Direct Play / Maximum) transcode session. Seeks stay NATIVE even + /// Plex video-copy (Original (Direct Stream)) transcode session. Seeks stay NATIVE even /// out of buffer (GH #196): the session playlist is a full VOD list and PMS jumps its /// remux transcoder to whichever segment the player requests (proven live — the /// client-side resume seek rides exactly this). A reopen actively breaks this lane: diff --git a/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift b/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift index c0b809ac3..571edeab6 100644 --- a/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift +++ b/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift @@ -15,29 +15,24 @@ import Foundation /// previously selectable caps (2/4/8/12/20 Mbps + Maximum) are retained — so an older /// persisted choice still resolves — plus 3/10/40 Mbps for finer steps. /// -/// The top of the ladder splits the old single "Maximum" into two explicit choices that -/// also decide the *path* (this is what replaced the experimental Direct Stream toggle + -/// headroom gate, #31): -/// - **Direct Play / Maximum** (`maximumOriginalKbps`, 0): ask PMS to direct-play/direct-stream -/// the source when compatible. If the probe, literal start, or committed copy rendition fails, -/// fall back once through production HLS with Direct Stream disabled so the rejected copy lane -/// cannot be selected again. -/// - **Maximum (HLS)** (`maxTranscodedKbps`): use HLS at the highest ceiling. On Plex this is -/// an explicit video transcode rather than another copy/remux attempt; Jellyfin/Emby apply the -/// same effectively uncapped quality ceiling through their backend policies. -/// Every numeric rung transcodes at that cap. +/// The two top choices express video-copy versus video-encode intent: +/// - **Original (Direct Stream)** (`maximumOriginalKbps`, 0): retain source video; +/// remuxing and audio conversion are allowed. Video encoding requires explicit consent. +/// - **Maximum (Transcode)** (`maxTranscodedKbps`): explicitly request video encoding +/// at the highest ceiling on Plex, Jellyfin and Emby. +/// Numeric rungs impose bitrate ceilings; compatible video may still be copied. public enum StreamingQuality { - /// The no-cap "Direct Play / Maximum" sentinel: attempt direct play/direct stream first. + /// The no-cap "Original (Direct Stream)" sentinel: attempt direct play/direct stream first. public static let maximumOriginalKbps = 0 - /// The "Maximum (HLS)" sentinel: request the production HLS path at this effectively - /// uncapped ceiling. It skips the literal direct-play probe and forces a Plex video - /// transcode so it cannot collapse back onto the Direct Play / Maximum copy lane. + /// The "Maximum (Transcode)" sentinel: request the production HLS path at this effectively + /// uncapped ceiling. It forces a video + /// transcode so it cannot collapse back onto the Original (Direct Stream) copy lane. public static let maxTranscodedKbps = 200_000 - /// One rung of the ladder. `kbps == maximumOriginalKbps` (0) is the direct-play-or-max - /// sentinel; `kbps == maxTranscodedKbps` is the maximum-HLS sentinel; + /// One rung of the ladder. `kbps == maximumOriginalKbps` (0) is the video-copy + /// sentinel; `kbps == maxTranscodedKbps` is the maximum-transcode sentinel; /// `resolution` is the rough target PMS encodes to at that ceiling (empty for the maxima). public struct Option: Identifiable, Sendable { public let kbps: Int @@ -68,8 +63,8 @@ public enum StreamingQuality { /// current ladder) render without trailing zeros via `%g`. public static func label(kbps: Int) -> String { switch kbps { - case maximumOriginalKbps: return "Direct Play / Maximum" - case maxTranscodedKbps: return "Maximum (HLS)" + case maximumOriginalKbps: return "Original (Direct Stream)" + case maxTranscodedKbps: return "Maximum (Transcode)" default: guard let resolution = ladder.first(where: { $0.kbps == kbps })?.resolution else { return "\(kbps / 1000) Mbps" diff --git a/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift b/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift index 79d848531..ccf0515d7 100644 --- a/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift +++ b/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift @@ -9,9 +9,9 @@ import Foundation /// - sustained stall -> downshift one bounded rung, subject to cooldown/frequency limits /// - sustained healthy playback with enough buffer -> optional upshift one bounded rung /// -/// The policy never raises above the user's selected cap. `Direct Play / Maximum` (0) is not a +/// The policy never raises above the user's selected cap. `Original (Direct Stream)` (0) is not a /// downshiftable transcode rung: callers must surface a visible failure or make an explicit user- -/// initiated quality change rather than silently abandoning video-copy intent. `Maximum (HLS)` +/// initiated quality change rather than silently abandoning video-copy intent. `Maximum (Transcode)` /// (200 Mbps in the app) is treated as permission to climb only to the highest bounded rung. public struct AdaptiveBitratePolicy: Sendable, Equatable { public struct Configuration: Sendable, Equatable { @@ -60,9 +60,8 @@ public struct AdaptiveBitratePolicy: Sendable, Equatable { public let reason: String } - /// Positive transcoded caps, low -> high. Excludes sentinel values such as "Direct Play / - /// Maximum" (`0`) and "Maximum (HLS)" (`200_000`) because automatic adaptation should - /// operate only on bounded rungs. Direct Play / Maximum is explicitly not downshifted. + /// Positive transcoded caps, low -> high. Excludes sentinel values such as "Original (Direct Stream)" (`0`) and "Maximum (Transcode)" (`200_000`) because automatic adaptation should + /// operate only on bounded rungs. Original (Direct Stream) is explicitly not downshifted. public let transcodedRungsKbps: [Int] public var configuration: Configuration diff --git a/PMSKit/Sources/PMSKit/Transcode/TranscodeRequest.swift b/PMSKit/Sources/PMSKit/Transcode/TranscodeRequest.swift index 2e8b9a4e4..918ad7393 100644 --- a/PMSKit/Sources/PMSKit/Transcode/TranscodeRequest.swift +++ b/PMSKit/Sources/PMSKit/Transcode/TranscodeRequest.swift @@ -151,7 +151,7 @@ public struct TranscodeRequest: Sendable, Equatable { // PMS). Use "Generic" plus our explicit `X-Plex-Client-Profile-Extra` so // PMS will direct-stream/remux HEVC video when the requested cap permits it. // The Safari profile hard-limits 10-bit HEVC and caused high-bitrate 4K MKV - // titles to be video-transcoded at ~20 Mbps even on Direct Play / Maximum. + // titles to be video-transcoded at ~20 Mbps even on Original (Direct Stream). .init(name: "X-Plex-Client-Profile-Name", value: "Generic"), .init(name: "X-Plex-Client-Profile-Extra", value: deviceProfile.clientProfileExtra), ] diff --git a/PMSKit/Tests/PMSKitTests/DiagnosticLoggingTests.swift b/PMSKit/Tests/PMSKitTests/DiagnosticLoggingTests.swift index 79ea34375..738014e7e 100644 --- a/PMSKit/Tests/PMSKitTests/DiagnosticLoggingTests.swift +++ b/PMSKit/Tests/PMSKitTests/DiagnosticLoggingTests.swift @@ -214,7 +214,7 @@ final class DiagnosticLoggingTests: XCTestCase { operatingSystem: "visionOS 26.5", deviceName: "Apple Vision Pro", backend: "Plex", - selectedQuality: "Direct Play / Maximum", + selectedQuality: "Original (Direct Stream)", loggingEnabled: true), events: [olderSnapshot, latestSnapshot], maxEvents: 0, diff --git a/PMSKit/Tests/PMSKitTests/StreamingQualityTests.swift b/PMSKit/Tests/PMSKitTests/StreamingQualityTests.swift index 7ca8cae0b..f60e85043 100644 --- a/PMSKit/Tests/PMSKitTests/StreamingQualityTests.swift +++ b/PMSKit/Tests/PMSKitTests/StreamingQualityTests.swift @@ -4,9 +4,9 @@ import XCTest final class StreamingQualityTests: XCTestCase { func testMaximaLabels() { XCTAssertEqual(StreamingQuality.label(kbps: StreamingQuality.maximumOriginalKbps), - "Direct Play / Maximum") + "Original (Direct Stream)") XCTAssertEqual(StreamingQuality.label(kbps: StreamingQuality.maxTranscodedKbps), - "Maximum (HLS)") + "Maximum (Transcode)") } func testNumericRungLabelsIncludeResolution() { diff --git a/README.md b/README.md index ee6cecbcf..501e15a2b 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ and backend. See the platform and backend status tables below for the current su ### Playback - Custom AVFoundation player surface shared across visionOS, iOS/iPadOS, tvOS, and macOS. -- Direct Play / Maximum attempts copy or direct-stream paths where viable. +- Original (Direct Stream) attempts copy or direct-stream paths where viable. - Explicit quality rungs request capped server streams when needed. - Resume, seek, retry, subtitles, chapters, playback speed, buffering state, and Stats for Nerds. - On visionOS, Cinema mode expands playback into an app-owned immersive surface with the same transport controls. diff --git a/docs/PLAYBACK-ARCHITECTURE.md b/docs/PLAYBACK-ARCHITECTURE.md index 418828778..9fffdbf8d 100644 --- a/docs/PLAYBACK-ARCHITECTURE.md +++ b/docs/PLAYBACK-ARCHITECTURE.md @@ -41,7 +41,7 @@ sequenceDiagram UI-->>PC: approve or decline end end - else Plex Direct Play / Maximum copy + else Plex Original (Direct Stream) copy UI->>PC: construct and start with item + session PC->>Server: directPlay=0, directStream=1 decision Server-->>PC: video-copy decision or encoding required @@ -67,7 +67,7 @@ The client-first changes are under active acceptance (see the repository plan `docs/plans/2026-09-06-client-first-playback.md`); control-plane success is not a claim that live rendering or every platform has passed. -1. **Direct Play / Maximum (Original video).** Negotiate with `directPlay=0` and +1. **Original (Direct Stream) (Original video).** Negotiate with `directPlay=0` and `directStream=1`, and require an explicit video `copy`/`directplay` decision before fetching the media start. Container remux and audio conversion are allowed; this HLS route is not byte-for-byte original-file playback. Copy starts omit `offset=` and @@ -79,8 +79,8 @@ control-plane success is not a claim that live rendering or every platform has p the current item and generation; stop and explicit quality changes clear it. Approved fallback disables Direct Stream and primes the saved resume offset. It is not a persistent preference or an automatic retry loop. -3. **Explicit capped / Maximum (HLS).** These quality choices authorize encoding without - a second prompt. Maximum (HLS) disables Direct Stream rather than recreating the copy +3. **Explicit capped / Maximum (Transcode).** These quality choices authorize encoding without + a second prompt. Maximum (Transcode) disables Direct Stream rather than recreating the copy rendition. Capped/approved encoding retains `offset=` priming for deep resume. For a single HDR variant on an SDR display, the bounded media-playlist selector can open @@ -114,7 +114,7 @@ sources may use explicit `VideoCodec=copy` with fMP4 segments while audio is cop converted. The controller selects the validated same-origin primary copy child, not the HDR master whose additional SDR variants can force a video encoder. Unknown decisions, required video transforms, and unhandled alternate renditions stop and ask rather than -silently encoding or discarding a track. Maximum (HLS) explicitly forces video encoding. +silently encoding or discarding a track. Maximum (Transcode) explicitly forces video encoding. An approval travels only through the captured, current-session reopener; stale generations cannot approve another playback request. These changes remain under live acceptance. @@ -177,7 +177,7 @@ a MediaBrowser item may become `.failed`. Current invariants: - `HLSSessionPrewarmer` is lane-specific rather than a universal HLS prerequisite. Plex uses - its full 20-second budget only when the selected quality is Direct Play / Maximum. + its full 20-second budget only when the selected quality is Original (Direct Stream). Outside native full-timeline lanes, Emby gives nonzero transcode resume/reopen targets an 8-second head start. Absent/zero-resume AV1 transcodes instead warm the same session for up to 20 seconds without the proxy ([evidence and limits](research/emby-av1-startup.md)). @@ -212,7 +212,7 @@ stall. Diagnostics distinguish active transfer from a stale/idle observed-bitrat Network loss frequently leaves AVPlayer waiting with an empty buffer without changing the item to `.failed`. Stall deadlines depend on the active lane: Jellyfin/Emby remote transcodes -use 45 seconds, other Direct Play / Maximum-selected paths use 90 seconds, and all remaining +use 45 seconds, other Original (Direct Stream)-selected paths use 90 seconds, and all remaining paths use 15 seconds. For every network-backed stream—Plex, Jellyfin, or Emby—growth in transferred bytes or loaded range at expiry rearms the watchdog instead of failing a slow-but-working prime. Local-file playback does not use this deferral. Once progress stops, the From 5ff8711b87ed2f707486017a5b39ead5b56d86d4 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Mon, 14 Sep 2026 08:40:35 -0400 Subject: [PATCH 4/7] Distinguish observed HDR signal from intent and add native probe evidence --- .../Debug/DebugMacPlaybackSurface.swift | 39 ++++++++++ .../Debug/DebugPlaybackFrameCapture.swift | 22 +++++- .../Shared/Debug/DebugPlaybackScenario.swift | 4 + .../Shared/Debug/DebugPlexHDREvidence.swift | 74 +++++++++++++++++++ .../Shared/Debug/DebugPlexPlaybackProbe.swift | 2 + .../Shared/Player/PlaybackController.swift | 11 ++- .../Shared/Player/PlaybackDiagnostics.swift | 56 ++++---------- .../Shared/Player/StatsForNerdsView.swift | 2 +- ...PlaybackExplanationPresentationTests.swift | 20 +++++ .../Models/DolbyVisionPlaybackPolicy.swift | 5 +- docs/MACOS.md | 3 + docs/PLAYBACK-ARCHITECTURE.md | 6 ++ docs/research/native-hdr-validation.md | 67 +++++++++++++++++ 13 files changed, 263 insertions(+), 48 deletions(-) create mode 100644 Labstream/Shared/Debug/DebugMacPlaybackSurface.swift create mode 100644 Labstream/Shared/Debug/DebugPlexHDREvidence.swift create mode 100644 docs/research/native-hdr-validation.md diff --git a/Labstream/Shared/Debug/DebugMacPlaybackSurface.swift b/Labstream/Shared/Debug/DebugMacPlaybackSurface.swift new file mode 100644 index 000000000..27939c04a --- /dev/null +++ b/Labstream/Shared/Debug/DebugMacPlaybackSurface.swift @@ -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 diff --git a/Labstream/Shared/Debug/DebugPlaybackFrameCapture.swift b/Labstream/Shared/Debug/DebugPlaybackFrameCapture.swift index ac02722e6..4e51c5371 100644 --- a/Labstream/Shared/Debug/DebugPlaybackFrameCapture.swift +++ b/Labstream/Shared/Debug/DebugPlaybackFrameCapture.swift @@ -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) } @@ -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 diff --git a/Labstream/Shared/Debug/DebugPlaybackScenario.swift b/Labstream/Shared/Debug/DebugPlaybackScenario.swift index f84b26462..df2822f9f 100644 --- a/Labstream/Shared/Debug/DebugPlaybackScenario.swift +++ b/Labstream/Shared/Debug/DebugPlaybackScenario.swift @@ -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 diff --git a/Labstream/Shared/Debug/DebugPlexHDREvidence.swift b/Labstream/Shared/Debug/DebugPlexHDREvidence.swift new file mode 100644 index 000000000..45694d63c --- /dev/null +++ b/Labstream/Shared/Debug/DebugPlexHDREvidence.swift @@ -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.. 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 diff --git a/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift b/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift index 80697a801..e8260e822 100644 --- a/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift +++ b/Labstream/Shared/Debug/DebugPlexPlaybackProbe.swift @@ -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] } ]) diff --git a/Labstream/Shared/Player/PlaybackController.swift b/Labstream/Shared/Player/PlaybackController.swift index ebed9b68c..7e7cd94d2 100644 --- a/Labstream/Shared/Player/PlaybackController.swift +++ b/Labstream/Shared/Player/PlaybackController.swift @@ -2880,6 +2880,13 @@ final class PlaybackController { prewarm.outcome.rawValue, prewarm.elapsedSeconds, prewarm.polls) } + #if DEBUG + if let copyMaster { + await DebugPlexHDREvidence.capture(master: copyMaster, baseURL: streamURL, + headers: PlexHeaders.media(identity: identity, token: token)) + guard !Task.isCancelled, generation == playbackGeneration else { return } + } + #endif diagnostics.applyHDRDisplayEligibility(AVPlayer.eligibleForHDRPlayback) if let copyMaster, let child = PlexHLSMediaPlaylistPolicy.mediaPlaylist(in: copyMaster, @@ -2943,8 +2950,8 @@ final class PlaybackController { } #if DEBUG - /// DEBUG-only per-title decision logger for the "Original (Direct Stream)" path. When PMS agrees - /// to direct-play a title we commit `directPlayStartM3U8URL()`; this records WHY, so a Debug + /// DEBUG-only decision logger for the "Original (Direct Stream)" path. When PMS agrees + /// to direct-play, we commit `directPlayStartM3U8URL()`; this records WHY, so a Debug /// build can distinguish whole-file direct play (`mde=1000`) from Direct Stream (`video=copy`, /// copy video / transcode audio) at a glance, alongside the production verdict we would /// otherwise have transcoded on. Logged as `gen= mde= part= video= audio= saves=`. Only the diff --git a/Labstream/Shared/Player/PlaybackDiagnostics.swift b/Labstream/Shared/Player/PlaybackDiagnostics.swift index 9ffc9503a..5db2fc778 100644 --- a/Labstream/Shared/Player/PlaybackDiagnostics.swift +++ b/Labstream/Shared/Player/PlaybackDiagnostics.swift @@ -99,52 +99,24 @@ final class PlaybackDiagnostics { } } - /// GH #196: what is actually reaching the display, distinct from the source - /// classification. Runtime truth wins once segments have loaded; before that, only - /// verdicts we are certain of are shown (guard-forced tone-map, copy-lane - /// passthrough). Nil hides the row. + /// Stream signaling is not proof of HDR light output. Do not promote source metadata, + /// a copy decision, or a tone-map request into an observed rendering claim. var renderedLabel: String? { - guard let sourceHDRFormat, sourceHDRFormat != .sdr else { return nil } + guard sourceHDRFormat != nil || runtimeContainsHDR != nil else { return nil } let displayIsSDROnly = displayPotentialEDR.map { $0 <= 1 } ?? false - let suffix = runtimeEligibleForHDR == false || displayIsSDROnly ? " · display not HDR-eligible" : "" - // Runtime truth: segments are loaded and AVFoundation told us what it sees. + let suffix = runtimeEligibleForHDR == false || displayIsSDROnly + ? " · display not HDR-eligible" : "" if let runtimeContainsHDR { - if runtimeContainsHDR { - let label: String - if sourceHDRFormat == .dolbyVision { - label = dvSignallingActive - ? "Dolby Vision (signalled — unverified)" - : "HDR10 fallback (base layer)" - } else { - switch sourceHDRFormat { - case .hdr10Plus: label = "HDR10+" - case .hlg: label = "HLG" - default: label = "HDR10" - } - } - return label + suffix - } - return "SDR (server tone-map)" + suffix - } - // Pre-runtime predictions — only where the lane makes the outcome certain. - if dvGuardReason != nil { - return "SDR (server tone-map)" - } - if isTranscoding { - // Jellyfin/Emby "transcode" may still remux the video; don't guess. - return nil - } - if sourceHDRFormat == .dolbyVision { - return (dvSignallingActive - ? "Dolby Vision (signalled — unverified)" - : "HDR10 fallback (base layer)") + suffix - } - // Non-DV HDR on a copy lane: the in-bitstream metadata survives the remux. - switch sourceHDRFormat { - case .hdr10Plus: return "HDR10+" + suffix - case .hlg: return "HLG" + suffix - default: return "HDR10" + suffix + guard runtimeContainsHDR else { return "SDR stream (observed)" + suffix } + // containsHDRVideo cannot establish preservation/application of DV or HDR10+ + // dynamic metadata. Report only the transfer function actually observed. + let transfer = runtimeTransferFunction.map { " · " + $0 } ?? "" + return "HDR stream (observed)" + transfer + suffix } + guard sourceHDRFormat != .sdr else { return nil } + return (isTranscoding || dvGuardReason != nil + ? "Unverified (video encoding requested)" + : "Unverified (video copy requested)") + suffix } /// Source media bitrate (kbps), when PMS exposes it on the chosen Media row. var sourceBitrateKbps: Int = 0 diff --git a/Labstream/Shared/Player/StatsForNerdsView.swift b/Labstream/Shared/Player/StatsForNerdsView.swift index 29b298b12..a94896798 100644 --- a/Labstream/Shared/Player/StatsForNerdsView.swift +++ b/Labstream/Shared/Player/StatsForNerdsView.swift @@ -67,7 +67,7 @@ struct StatsForNerdsView: View { // the old Runtime HDR / Output rows restated (and sometimes contradicted) // this — runtime truth now feeds Rendered directly. if let rendered = diagnostics.renderedLabel { - row("Rendered", rendered, wraps: true) + row("Stream signal", rendered, wraps: true) } row("Display", diagnostics.displayCapabilityLabel, wraps: true) row("Audio", diagnostics.audioFormatText) diff --git a/LabstreamTests/PlaybackExplanationPresentationTests.swift b/LabstreamTests/PlaybackExplanationPresentationTests.swift index ba28eab14..1c4147d06 100644 --- a/LabstreamTests/PlaybackExplanationPresentationTests.swift +++ b/LabstreamTests/PlaybackExplanationPresentationTests.swift @@ -4,6 +4,26 @@ import Testing @MainActor struct PlaybackExplanationPresentationTests { + @Test func sourceHDRAndCopyDecisionDoNotClaimObservedRendering() { + let diagnostics = PlaybackDiagnostics() + diagnostics.sourceHDRFormat = .dolbyVision + diagnostics.applyHDRDisplayEligibility(true) + #expect(diagnostics.renderedLabel == "Unverified (video copy requested)") + diagnostics.isTranscoding = true + #expect(diagnostics.renderedLabel == "Unverified (video encoding requested)") + } + + @Test func observedHDRDoesNotProveDynamicMetadataOrToneMapping() { + let diagnostics = PlaybackDiagnostics() + diagnostics.sourceHDRFormat = .hdr10Plus + diagnostics.applyRuntimeHDRProbe(.init(containsHDRVideo: true, transferFunction: "PQ", + eligibleForHDRPlayback: true, sawVideoFormatDescriptions: true, videoCodecFourCC: "hvc1")) + #expect(diagnostics.renderedLabel == "HDR stream (observed) · PQ") + diagnostics.applyRuntimeHDRProbe(.init(containsHDRVideo: false, transferFunction: nil, + eligibleForHDRPlayback: true, sawVideoFormatDescriptions: true, videoCodecFourCC: "avc1")) + #expect(diagnostics.renderedLabel == "SDR stream (observed)") + } + @Test func displayEligibilityUpdatesAfterConclusiveStreamProbeWithoutChangingStreamFacts() { let diagnostics = PlaybackDiagnostics() diagnostics.applyRuntimeHDRProbe(.init(containsHDRVideo: true, transferFunction: "PQ", diff --git a/PMSKit/Sources/PMSKit/Models/DolbyVisionPlaybackPolicy.swift b/PMSKit/Sources/PMSKit/Models/DolbyVisionPlaybackPolicy.swift index 54aed450b..517391a50 100644 --- a/PMSKit/Sources/PMSKit/Models/DolbyVisionPlaybackPolicy.swift +++ b/PMSKit/Sources/PMSKit/Models/DolbyVisionPlaybackPolicy.swift @@ -19,8 +19,9 @@ public enum DolbyVisionPlaybackVerdict: Sendable, Equatable { /// the fallback transcode stalls, Emby → silent black screen. P5 has no /// backwards-compatible base layer, so nothing recoverable reaches the display. /// -/// P7/P8 streams with an HDR10/SDR/HLG-compatible base layer are proven correct on copy -/// lanes (they render the fallback layer) and must stay there. +/// P7/P8 streams with a compatible base layer remain eligible for a copy attempt. +/// This is not a decoder guarantee: retained unsupported DV configuration in a remux +/// can still fail. Such a failure follows the existing explicit-consent path. public enum DolbyVisionPlaybackPolicy { /// - Parameter experimentalDVSignallingEnabled: when the user opted into the /// experimental DV signalling lane, the guard defers — that lane's whole point is diff --git a/docs/MACOS.md b/docs/MACOS.md index f64ef625c..efa063ace 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -103,6 +103,9 @@ toggle the system HDR setting, and disconnect/reconnect the display. Confirm the updates without a playback restart or encoding-consent bypass. This requires physical display testing; unit tests and a launch smoke do not close that gate. +Current bounded results and open platform gates are in the +[native HDR investigation](research/native-hdr-validation.md). + ## Cleanup Clean up host-development state after a one-off test or before removing its worktree: diff --git a/docs/PLAYBACK-ARCHITECTURE.md b/docs/PLAYBACK-ARCHITECTURE.md index 9fffdbf8d..cb84e2735 100644 --- a/docs/PLAYBACK-ARCHITECTURE.md +++ b/docs/PLAYBACK-ARCHITECTURE.md @@ -302,6 +302,12 @@ mechanics remain shared with remote playback. ## HDR and Dolby Vision +The **Stream signal** row distinguishes observed AVFoundation signaling from unverified +copy/encode intent. A source HDR label, compatible base layer, or server decision alone +cannot establish decoded HDR or display light output. See the +[native HDR investigation](research/native-hdr-validation.md) for bounded platform evidence. + + Display capability is live state, not a property of the media file. The controller observes AVFoundation HDR-eligibility changes for the current item's lifetime (including while paused), refreshes on the diagnostics tick even after stream inspection is conclusive, and re-reads diff --git a/docs/research/native-hdr-validation.md b/docs/research/native-hdr-validation.md new file mode 100644 index 000000000..875f49fda --- /dev/null +++ b/docs/research/native-hdr-validation.md @@ -0,0 +1,67 @@ +# Native HDR validation + +Active investigation: [Plex DV P7 decoder failure (#316)](https://github.com/jlipworth/Labstream/issues/316). +This is not a universal platform-support or release-readiness claim. + +## Evidence levels + +Keep these independent: + +1. Source codec, container, HDR transfer and Dolby Vision profile. +2. Server copy/encode decision and delivered initialization/stream signaling. +3. Successful decoding, advancing playback and inspected decoded frames. +4. The actual player screen's eligibility, potential EDR and current headroom. +5. Measured or device-observed HDR presentation. SDR screenshots and tone-mapped probe + PNGs cannot establish luminance, colorimetry, DV processing or HDR10+ preservation. + +Stats **Stream signal** reports observed AVFoundation stream facts when available. Until +then it marks copy/encode intent unverified. It must not predict successful HDR10 fallback +from a DV source label or claim successful server tone-mapping from an encode request. + +## Current bounded results + +| Platform | Evidence | Remaining gate | +| --- | --- | --- | +| Native Mac, macOS 27 beta | HEVC Main10/PQ/BT.2020 copy control decoded with HDR both off and on; inspected frames. Actual player-screen diagnostics distinguish eligible HDR from SDR. | HDR luminance/colorimetry; paused mixed-screen moves/hot-plug; wider source/profile coverage. | +| Physical iPad | Paired device discovered; signed deployment attempted. | Xcode preparation/unlock/connectivity; no candidate hardware playback claim. | +| Physical iPhone | Paired device discovered; deployment attempted. | Device unavailable to Xcode; no candidate hardware playback claim. | +| Physical Vision Pro | Paired device discovered, unavailable. Existing app not removed. | Wake/unlock and physical test. | +| Physical tvOS | No hardware available. | Device/display/link test; simulator is not a substitute. | + +On the tested Mac, a DV P7 MKV source delivered by Plex as `hvc1` fMP4 retained a +profile-7 `dvcC` record alongside PQ/BT.2020 `colr` signaling. VideoToolbox rejected decoder +creation (-12906/-12910), surfaced as AVFoundation -11855. Both HDR-on master playback +and HDR-off single-child playback failed. The ordinary HDR10 control without that DV +configuration decoded successfully. This isolates a profile/packaging gate more narrowly +than display eligibility, but does not yet prove which normalization would be safe. + +The HDR10 control also produced decoded 3840×2160 buffers with PQ transfer and BT.2020 +primaries/matrix attachments. Explicit 8 Mbps encoding of the failing DV source passed +playback/seek with inspected frames and decoded 1920×1080 BT.709 attachments (SDR). +Neither result establishes measured display luminance or native DV output. Original's explicit-consent boundary remained intact. Do not +strip DV configuration or enhancement data blindly, change the P5 guard, or silently encode. + +One tested scaled display mode hid the HDR switch; default scaling exposed it. Temporary +scaling and HDR changes were restored. That host-specific observation is not a universal +macOS scaling rule or a diagnosis of a cable/adapter. + +## Reproducible probes + +Use exact source binding and the existing explicit live/encoding admission flags. Native +Mac probes now create a real isolated player-layer window and close it during cleanup; +headless CLI progress is not substituted for a render surface. Authentication stays in the +app's existing session, never in exported tokens. + +Optional `--vp-probe-hdr-evidence` records bounded technical master attributes and a +same-origin unencrypted initialization segment inside the app container, only with +`--vp-probe-allow-live`. This adds diagnostic requests and is not a performance benchmark. +Private source references, initialization bytes, logs and frames must remain gitignored. + +`--vp-probe-capture-frames --vp-probe-capture-hdr-signaling` additionally requests 10-bit +video-output buffers and records their transfer/primaries/matrix attachments beside PNGs. +Requested 10-bit storage alone does not establish source bit depth or HDR. These are +post-decoder/pre-display observations; the PNGs are not HDR measurement artifacts. + +See [Apple's HLS authoring specification](https://developer.apple.com/documentation/http-live-streaming/hls-authoring-specification-for-apple-devices) +for delivery requirements. Apple-format support does not establish that a particular +server remux, source DV profile, hardware display or Labstream path has passed. From 9d8d8be6450828f052fdcfb94aadf14258825654 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Mon, 14 Sep 2026 11:47:24 -0400 Subject: [PATCH 5/7] Record simulator HDR10 and HDR10+ evidence with bounded live-auth helpers --- .../LabstreamMobileLiveAuthUITests.swift | 33 ++++++++++++++++++- .../LabstreamTVLiveAuthUITests.swift | 29 ++++++++++++++++ docs/research/native-hdr-validation.md | 32 ++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 LabstreamTVUITests/LabstreamTVLiveAuthUITests.swift diff --git a/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift b/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift index 047d24c83..a7ce354f5 100644 --- a/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift +++ b/LabstreamMobileUITests/LabstreamMobileLiveAuthUITests.swift @@ -22,11 +22,42 @@ final class LabstreamMobileLiveAuthUITests: XCTestCase { attachment.name = "private-plex-link-code" attachment.lifetime = .keepAlways add(attachment) - XCTAssertTrue(app.tabBars.buttons["Home"].waitForExistence(timeout: 240), + XCTAssertTrue(app.buttons["Home"].firstMatch.waitForExistence(timeout: 240), "Browser authorization and server selection did not complete.") XCTAssertFalse(prompt.exists) } + @MainActor + func testSelectPlexServer() throws { + let environment = ProcessInfo.processInfo.environment + guard environment["LABSTREAM_LIVE_PLEX_AUTH_ALLOWED"] == "1", + let name = environment["LABSTREAM_LIVE_PLEX_SERVER_NAME"], !name.isEmpty else { + throw XCTSkip("Explicit normal-UI server selection requires a private exact server label.") + } + continueAfterFailure = false + let app = XCUIApplication() + app.launchEnvironment["LABSTREAM_UNIT_TEST_HOST"] = "0" + app.launchArguments = ["--vp-probe-backend", "plex"] + app.launch() + let settings = app.buttons["Settings"].firstMatch + XCTAssertTrue(settings.waitForExistence(timeout: 30)) + settings.tap() + let picker = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Plex Server")).firstMatch + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + picker.tap() + let server = app.buttons[name] + XCTAssertTrue(server.waitForExistence(timeout: 10)) + server.tap() + let selected = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label CONTAINS %@", name), object: picker) + XCTAssertEqual(XCTWaiter.wait(for: [selected], timeout: 45), .completed, + "Server selection must finish before test teardown terminates the app.") + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "private-plex-server-selection" + attachment.lifetime = .keepAlways + add(attachment) + } + @MainActor func testJellyfinQuickConnect() throws { let environment = ProcessInfo.processInfo.environment diff --git a/LabstreamTVUITests/LabstreamTVLiveAuthUITests.swift b/LabstreamTVUITests/LabstreamTVLiveAuthUITests.swift new file mode 100644 index 000000000..4a576e654 --- /dev/null +++ b/LabstreamTVUITests/LabstreamTVLiveAuthUITests.swift @@ -0,0 +1,29 @@ +import XCTest + +/// Explicit normal pairing only; never resets a saved session or supplies credentials. +@MainActor +final class LabstreamTVLiveAuthUITests: XCTestCase { + func testPlexLink() throws { + guard ProcessInfo.processInfo.environment["LABSTREAM_LIVE_PLEX_AUTH_ALLOWED"] == "1" else { + throw XCTSkip("Live authentication requires explicit local opt-in.") + } + continueAfterFailure = false + let app = XCUIApplication() + app.launchEnvironment["LABSTREAM_UNIT_TEST_HOST"] = "0" + app.launchArguments = ["--vp-probe-backend", "plex"] + app.launch() + XCTAssertTrue(app.buttons["Sign in with Plex"].waitForExistence(timeout: 20), + "Expected signed-out app; never sign out automatically.") + XCUIRemote.shared.press(.down) + XCUIRemote.shared.press(.select) + let prompt = app.staticTexts["Waiting for authorization…"] + XCTAssertTrue(prompt.waitForExistence(timeout: 30)) + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "private-plex-link-code" + attachment.lifetime = .keepAlways + add(attachment) + XCTAssertTrue(app.buttons["Home"].waitForExistence(timeout: 240), + "Browser authorization and server selection did not complete.") + XCTAssertFalse(prompt.exists) + } +} diff --git a/docs/research/native-hdr-validation.md b/docs/research/native-hdr-validation.md index 875f49fda..0947aa6ac 100644 --- a/docs/research/native-hdr-validation.md +++ b/docs/research/native-hdr-validation.md @@ -45,6 +45,38 @@ One tested scaled display mode hid the HDR switch; default scaling exposed it. T scaling and HDR changes were restored. That host-specific observation is not a universal macOS scaling rule or a diagnosis of a cable/adapter. +## Simulator evidence boundaries + +| Simulator runtime | HDR10 control | Source-verified HDR10+ file | Remaining gate | +| --- | --- | --- | --- | +| iPhone, iOS 27 | Copy, actual 60-second seek/hold, inspected post-seek frames passed. | Same bounded sequence passed; decoded PQ/BT.2020. | Dynamic-metadata preservation/application and physical output unknown. | +| iPad, iOS 27 | Copy, actual 60-second seek/hold, inspected post-seek frames passed. | Same bounded sequence passed; decoded PQ/BT.2020. | Same limits; normal server selection required. | +| tvOS 27 | Copy, actual 60-second seek/hold, inspected post-seek frames passed. | Same bounded sequence passed; decoded PQ/BT.2020. | Same limits; hardware decoder/display not established. | +| visionOS 27 | Not run. | Not run. | Missing root golden simulator pointer; root checkout not modified. | + +These are individual source/path results, not universal HDR10+ support. No video-encoding +consent was granted in these six copy tests. Retain the initial missing-auth, wrong-server +and UI-harness attempts; they are not decoder failures. Normal tvOS pairing persisted even +though the first UI test expected a phone-only Copy button; the corrected readiness locator +compiled but was not re-run live, to avoid signing out the saved session. + +A normal Plex link can be completed through the explicitly opted-in mobile live-auth +UI test and the Codex in-app browser even when Device Hub accessibility is unavailable. +On iPad, use the actual floating Home button rather than assuming a phone-style tab bar. +Server selection must finish before UI-test teardown; a successful tap followed by +“Selecting…” is not a completed server switch. Confirm the exact source after relaunch. + +Source HDR10+ classification requires evidence beyond a release filename or Plex's HDR10 +label. A bounded source-frame probe found SMPTE2094-40 dynamic metadata in a corpus +candidate; private Plex discovery matched its path suffix and size. PQ/BT.2020 delivered +initialization and decoder-buffer attachments alone cannot establish preservation or +application of that dynamic metadata. Keep those results unknown unless separately tested. + +The `original` scenario verifies progression/hold, **not seek**. A folder named +`plex-postseek` is reused by the capture helper even for a non-seek scenario; use the +report's scenario and position snapshots as evidence. Run `seek` at bitrate zero for an +Original-quality seek test without granting video-encoding consent. + ## Reproducible probes Use exact source binding and the existing explicit live/encoding admission flags. Native From 40ab56e287f906a50f5da1d1bd62b095901c3d82 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Mon, 14 Sep 2026 18:05:58 -0400 Subject: [PATCH 6/7] Finalize scoped HDR validation and quality-label guidance --- .../Sources/PMSKit/Playback/StreamingQualityLadder.swift | 5 ++--- .../Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift | 9 +++++---- docs/DEVELOPMENT.md | 7 +++++++ docs/MACOS.md | 5 +++-- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift b/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift index 571edeab6..50154a14d 100644 --- a/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift +++ b/PMSKit/Sources/PMSKit/Playback/StreamingQualityLadder.swift @@ -26,9 +26,8 @@ public enum StreamingQuality { /// The no-cap "Original (Direct Stream)" sentinel: attempt direct play/direct stream first. public static let maximumOriginalKbps = 0 - /// The "Maximum (Transcode)" sentinel: request the production HLS path at this effectively - /// uncapped ceiling. It forces a video - /// transcode so it cannot collapse back onto the Original (Direct Stream) copy lane. + /// The "Maximum (Transcode)" sentinel requests effectively uncapped production HLS. + /// It forces video encoding rather than returning to the Original copy lane. public static let maxTranscodedKbps = 200_000 /// One rung of the ladder. `kbps == maximumOriginalKbps` (0) is the video-copy diff --git a/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift b/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift index ccf0515d7..c227fb643 100644 --- a/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift +++ b/PMSKit/Sources/PMSKit/Transcode/AdaptiveBitratePolicy.swift @@ -11,8 +11,8 @@ import Foundation /// /// The policy never raises above the user's selected cap. `Original (Direct Stream)` (0) is not a /// downshiftable transcode rung: callers must surface a visible failure or make an explicit user- -/// initiated quality change rather than silently abandoning video-copy intent. `Maximum (Transcode)` -/// (200 Mbps in the app) is treated as permission to climb only to the highest bounded rung. +/// initiated quality change rather than silently abandoning video-copy intent. +/// `Maximum (Transcode)` (200 Mbps in the app) permits climbing only to the highest bounded rung. public struct AdaptiveBitratePolicy: Sendable, Equatable { public struct Configuration: Sendable, Equatable { /// Minimum seconds between any two automatic changes. Protects PMS/Jellyfin from restart @@ -60,8 +60,9 @@ public struct AdaptiveBitratePolicy: Sendable, Equatable { public let reason: String } - /// Positive transcoded caps, low -> high. Excludes sentinel values such as "Original (Direct Stream)" (`0`) and "Maximum (Transcode)" (`200_000`) because automatic adaptation should - /// operate only on bounded rungs. Original (Direct Stream) is explicitly not downshifted. + /// Positive transcoded caps, low -> high. Excludes "Original (Direct Stream)" (`0`) + /// and "Maximum (Transcode)" (`200_000`): automatic adaptation uses bounded rungs. + /// Original is explicitly not downshifted. public let transcodedRungsKbps: [Int] public var configuration: Configuration diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 215c14ed2..44b64191f 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -340,6 +340,13 @@ workflow and validates Apple's accepted dimensions, alpha prohibition, and check [App Store screenshot automation](APP-STORE-SCREENSHOTS.md). On Xcode 27 the visionOS lane is passive Home capture only; richer visionOS marketing states remain a human/headset gate. +A bounded macOS 27 beta display check found that default scaling exposed a stable HDR +switch where a higher scaled mode did not. Enabling HDR made AVPlayer eligible and raised +potential EDR above 1; lowering refresh alone did not. This is host-specific capability +evidence, not proof of rendered HDR, a universal scaling restriction, or a cable diagnosis. +Restore temporary display modes and HDR settings after tests; keep per-player-screen and +physical mixed-display acceptance separate (see [macOS display capability](MACOS.md#live-display-capability)). + The Mac target is a pre-release App Store candidate, not a released compatibility promise. Routine host builds still use per-worktree identities to isolate containers and credentials. See the [macOS target guide](MACOS.md) for identity isolation, cleanup, validation, and release gates. diff --git a/docs/MACOS.md b/docs/MACOS.md index efa063ace..a6174d6f6 100644 --- a/docs/MACOS.md +++ b/docs/MACOS.md @@ -79,8 +79,9 @@ The host helper sets `ENABLE_DEBUG_DYLIB=NO`. Xcode's small Debug launcher was o to have the same executable UUID in isolated-development and production-identity builds. macOS uses that UUID when enforcing local network privacy; the collision caused local connections to fail with `Local network prohibited` even while Settings showed access enabled. -Rebuilding the signed production app without the Debug launcher produced a distinct UUID -and restored live server access. This affects local helper builds, not archive settings. +Rebuilding without the Debug launcher produced distinct main-executable UUIDs. The isolated +helper app regained live server access after normal permission approval. This affects local +helper builds, not archive settings. Production app replacement is not part of this workflow. If this recurs, compare the main executables with `xcrun dwarfdump --uuid`, verify signing, and check System Settings → Privacy & Security → Local Network. Relaunch and retry after From 53bd104211d7348bb6f6633230e564fd209061d5 Mon Sep 17 00:00:00 2001 From: Jonathan Lipworth Date: Mon, 14 Sep 2026 18:13:44 -0400 Subject: [PATCH 7/7] Keep cross-backend HDR acceptance explicitly open --- docs/research/native-hdr-validation.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/research/native-hdr-validation.md b/docs/research/native-hdr-validation.md index 0947aa6ac..e32701fca 100644 --- a/docs/research/native-hdr-validation.md +++ b/docs/research/native-hdr-validation.md @@ -2,6 +2,8 @@ Active investigation: [Plex DV P7 decoder failure (#316)](https://github.com/jlipworth/Labstream/issues/316). This is not a universal platform-support or release-readiness claim. +The live results below cover Plex only. Equivalent exact-source Jellyfin and Emby +validation is untested here; cross-backend acceptance remains open. ## Evidence levels