diff --git a/README.md b/README.md index 7878f5a..7a781c1 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ It also keeps the established multi-Mac workspace features: - Sender can stream either a mirrored desktop or an extended virtual display. See [Display Modes](docs/Features.md#display-modes). - One sender can drive multiple receiver Macs over separate cables. See [Multi-Receiver Workflows](docs/Features.md#multi-receiver-workflows). -- Stream profiles range from `2560 x 1440` to `5120 x 2880` with H.264/HEVC selection based on capability. `5K 60` is an experimental profile for recent Apple Silicon; `5K 48` remains recommended for reliable daily work. See [Display Modes](docs/Features.md#display-modes). +- Stream profiles range from `1920 x 1080` to `5120 x 2880` with H.264/HEVC selection based on capability. The `Full HD` profiles match the native panel of 21.5-inch non-Retina iMacs, so those receivers decode at panel resolution instead of downscaling. `5K 60` is an experimental profile for recent Apple Silicon; `5K 48` remains recommended for reliable daily work. See [Display Modes](docs/Features.md#display-modes). - Receiver discovery is automatic over Bonjour. Extended-display arrangement is remembered per receiver when possible. See [Display Modes](docs/Features.md#display-modes). - Thunderbolt Bridge remains the primary low-latency path, with `Network Link` available as an experimental addon-gated transport. See [Network Link](docs/Features.md#network-link-experimental). - The Sender menu can control a connected receiver's brightness, volume, Night Shift, and True Tone when supported. See [Receiver Device Controls](docs/Features.md#receiver-device-controls). diff --git a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift index d956492..e58e55b 100644 --- a/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift +++ b/TargetBridge-Sender/TBDisplaySender/ReceiverBackedVirtualDisplaySession.swift @@ -8,12 +8,24 @@ extension CGVirtualDisplaySettings: @unchecked @retroactive Sendable {} /// Pixel size of the mode handed to CGVirtualDisplay. With `settings.hiDPI = true` /// macOS synthesises a strictly 2x backing store, so a mode of (w, h) renders the /// desktop into a (2w, 2h) framebuffer and reports "looks like w x h" in Displays. +/// +/// `isHiDPI == false` asks for a 1x mode instead: the framebuffer equals the mode, +/// and the desktop reports its true size. That is the right choice when the stream +/// already matches a non-Retina receiver panel pixel for pixel, where a HiDPI mode +/// would render every control at 2x on a panel that has no extra pixels to show it. struct TBVirtualDisplayModeSize: Equatable { let width: Int let height: Int + let isHiDPI: Bool - var backingWidth: Int { width * 2 } - var backingHeight: Int { height * 2 } + init(width: Int, height: Int, isHiDPI: Bool = true) { + self.width = width + self.height = height + self.isHiDPI = isHiDPI + } + + var backingWidth: Int { isHiDPI ? width * 2 : width } + var backingHeight: Int { isHiDPI ? height * 2 : height } } struct TBVirtualDisplayIdentity { @@ -84,7 +96,8 @@ final class ReceiverBackedVirtualDisplaySession { // stream exactly, so capture is 1:1 and only the panel-side scale remains. var resolvedMode = modeOverride ?? TBVirtualDisplayModeSize( width: profile.modeWidth, - height: profile.modeHeight + height: profile.modeHeight, + isHiDPI: profile.hiDPI ) // macOS refuses a HiDPI mode whose backing store exceeds the advertised panel. @@ -95,7 +108,11 @@ final class ReceiverBackedVirtualDisplaySession { resolvedMode.backingWidth, resolvedMode.backingHeight, profile.panelWidth, profile.panelHeight ) - resolvedMode = TBVirtualDisplayModeSize(width: profile.modeWidth, height: profile.modeHeight) + resolvedMode = TBVirtualDisplayModeSize( + width: profile.modeWidth, + height: profile.modeHeight, + isHiDPI: profile.hiDPI + ) } let descriptor = CGVirtualDisplayDescriptor() @@ -128,7 +145,7 @@ final class ReceiverBackedVirtualDisplaySession { } let settings = CGVirtualDisplaySettings() - settings.hiDPI = profile.hiDPI + settings.hiDPI = resolvedMode.isHiDPI guard let mode = CGVirtualDisplayMode( width: UInt(resolvedMode.width), height: UInt(resolvedMode.height), diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift index 2b2eab7..14bdd8b 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderAutomation.swift @@ -460,6 +460,8 @@ enum TBSenderAutomation { static func parsePreset(_ value: String) -> TBDisplayCapturePreset? { if let preset = TBDisplayCapturePreset(rawValue: value) { return preset } switch value.lowercased() { + case "1080p", "1080", "fullhd", "1920x1080": return .standard1080p + case "1080p60", "fullhd60", "smooth1080": return .smooth1080p60 case "1440p", "1440", "standard": return .standard1440p case "1440p60", "smooth", "smooth1440": return .smooth1440p60 case "1800p", "1800p60", "smooth1800": return .smooth1800p60 diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift index de97d34..6566f9c 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderContentView.swift @@ -615,7 +615,11 @@ private struct TBDisplaySenderSessionSettingsSheet: View { .disabled(session.isConnected || session.isStreaming) } - if session.captureSource == .extendedDesktop { + // A native-scale profile already renders the desktop 1:1 with the + // stream, so render matching has nothing left to do and its HiDPI + // wording would be wrong. Hide the row rather than show a toggle + // that silently does nothing. + if session.captureSource == .extendedDesktop, !session.capturePreset.rendersAtNativeScale { settingRow(renderMatchingTitle, details: renderMatchingDetails) { Toggle("", isOn: $session.matchRenderToStream) .labelsHidden() diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift index 2f5fe16..99f82b7 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderLocalization.swift @@ -559,6 +559,10 @@ enum TBDisplaySenderL10n { extension TBDisplayCapturePreset { func title(_ language: TBDisplaySenderLanguage) -> String { switch self { + case .standard1080p: + return TBDisplaySenderL10n.text("sender.profile.full_hd", language) + case .smooth1080p60: + return TBDisplaySenderL10n.text("sender.profile.full_hd_60", language) case .standard1440p: return TBDisplaySenderL10n.text("sender.profile.standard", language) case .smooth1440p60: diff --git a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift index 4d1b83d..03398bf 100644 --- a/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift +++ b/TargetBridge-Sender/TBDisplaySender/TBDisplaySenderService.swift @@ -12,6 +12,8 @@ import Network import VideoToolbox enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { + case standard1080p + case smooth1080p60 case standard1440p case smooth1440p60 case smooth1800p60 @@ -24,6 +26,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var title: String { switch self { + case .standard1080p: + return "Full HD" + case .smooth1080p60: + return "Full HD 60" case .standard1440p: return "Standard" case .smooth1440p60: @@ -43,6 +49,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var description: String { switch self { + case .standard1080p: + return "1920 × 1080" + case .smooth1080p60: + return "1920 × 1080 @ 60" case .standard1440p: return "2560 × 1440" case .smooth1440p60: @@ -62,6 +72,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var width: Int { switch self { + case .standard1080p, .smooth1080p60: + return 1920 case .standard1440p, .smooth1440p60: return 2560 case .smooth1800p60: @@ -77,6 +89,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var height: Int { switch self { + case .standard1080p, .smooth1080p60: + return 1080 case .standard1440p, .smooth1440p60: return 1440 case .smooth1800p60: @@ -92,6 +106,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var averageBitRate: Int { switch self { + case .standard1080p: + return 20_000_000 + case .smooth1080p60: + return 30_000_000 case .standard1440p: return 36_000_000 case .smooth1440p60: @@ -111,7 +129,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var codecName: String { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return "H.264" case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return "HEVC" @@ -120,7 +138,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var codecType: CMVideoCodecType { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return kCMVideoCodecType_H264 case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return kCMVideoCodecType_HEVC @@ -132,9 +150,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { return parsed } switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return 3 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: // Five surfaces protect WindowServer from starvation during // high-frame-rate 4K capture while the serial pipeline prevents an @@ -145,6 +163,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var expectedFrameRate: Int { switch self { + case .standard1080p: + return 30 + case .smooth1080p60: + return 60 case .standard1440p: return 30 case .smooth1440p60: @@ -169,6 +191,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxKeyFrameInterval: Int { switch self { + case .standard1080p, .smooth1080p60: + return 60 case .standard1440p: return 60 case .smooth1440p60: @@ -186,6 +210,10 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxKeyFrameIntervalDuration: Int { switch self { + case .standard1080p: + return 2 + case .smooth1080p60: + return 1 case .standard1440p: return 2 case .smooth1440p60: @@ -199,9 +227,9 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var prioritizeSpeed: Bool { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -215,18 +243,18 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var maxFrameDelayCount: Int { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return 1 - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return 0 } } var dropsBeforeEncodeWhenBacklogged: Bool { switch self { - case .standard1440p: + case .standard1080p, .standard1440p: return false - case .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: + case .smooth1080p60, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return true } } @@ -240,7 +268,7 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var captureResolution: SCCaptureResolutionType { switch self { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: return .nominal case .crisp2160p60, .retina4k60, .native5k, .native5k60Experimental: return .best @@ -249,6 +277,8 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var virtualDisplayRefreshRate: Double { switch self { + case .standard1080p, .smooth1080p60: + return 60 case .standard1440p: return 60 case .smooth1440p60, .smooth1800p60: @@ -276,6 +306,33 @@ enum TBDisplayCapturePreset: String, CaseIterable, Identifiable { var renderMatchedDesktopDescription: String { "\(width / 2) × \(height / 2)" } + + /// Virtual display mode for a receiver whose panel already matches the stream + /// pixel for pixel. Rendering 1x keeps the desktop at its true size and makes + /// capture 1:1; the HiDPI variant would halve the logical desktop and draw every + /// control at 2x on a panel with no extra pixels to resolve it. + var nativeScaleDisplayMode: TBVirtualDisplayModeSize { + TBVirtualDisplayModeSize(width: width, height: height, isHiDPI: false) + } + + /// True for profiles that target a receiver panel of exactly this resolution, + /// with no extra pixels for a HiDPI backing store. + /// + /// The receiver advertises a fixed 5120x2880 panel and a 2560x1440 HiDPI mode + /// no matter what it is actually plugged into, so its profile cannot be used to + /// detect a non-Retina panel; the capture profile is the signal instead. A + /// 21.5-inch non-Retina iMac running the 1080p profiles wants a 1920x1080 1x + /// desktop: HiDPI would render the desktop at 960x540 and draw every control at + /// 2x, and the receiver's own 1440p default would rescale twice on the way out. + var rendersAtNativeScale: Bool { + switch self { + case .standard1080p, .smooth1080p60: + return true + case .standard1440p, .smooth1440p60, .smooth1800p60, .crisp2160p60, .retina4k60, + .native5k, .native5k60Experimental: + return false + } + } } /// A zero-buffer frame-rate ceiling for capture callbacks. Deadlines advance on @@ -1346,7 +1403,7 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u private func resolvedCodecType(for preset: TBDisplayCapturePreset, profile: TBMonitorDisplayProfile?) -> CMVideoCodecType { switch preset { - case .standard1440p, .smooth1440p60, .smooth1800p60: + case .standard1080p, .smooth1080p60, .standard1440p, .smooth1440p60, .smooth1800p60: let receiverSupportsHEVC = profile?.supportsHEVCDecode ?? receiverSupportsHEVCDecodeHint ?? false if receiverSupportsHEVC, Self.probeHEVCHardwareEncoderSupport() { return kCMVideoCodecType_HEVC @@ -2430,15 +2487,27 @@ final class TBDisplaySenderSession: NSObject, ObservableObject, Identifiable, @u ? await self.fetchShareableDisplayIDs() : [] let receiverKey = self.extendedDisplayIdentityKey(for: profile) - let modeOverride: TBVirtualDisplayModeSize? = (self.matchRenderToStream && self.captureSource == .extendedDesktop) - ? self.capturePreset.renderMatchedDisplayMode - : nil + // A native-scale profile targets a panel with exactly this many pixels, so + // render the desktop 1x at the stream size: capture is 1:1 and the panel + // shows it at its true size. This takes priority over render matching, + // whose 2x mode would halve the logical desktop for no gain on a panel with + // no extra pixels to resolve it. + let modeOverride: TBVirtualDisplayModeSize? + if self.capturePreset.rendersAtNativeScale { + modeOverride = self.capturePreset.nativeScaleDisplayMode + } else if self.matchRenderToStream && self.captureSource == .extendedDesktop { + modeOverride = self.capturePreset.renderMatchedDisplayMode + } else { + modeOverride = nil + } if let modeOverride { NSLog( - "TargetBridge: render matching on, virtual display mode %dx%d (backing %dx%d) for %dx%d stream", + "TargetBridge: virtual display mode %dx%d %@ (backing %dx%d) for %dx%d stream, panel %dx%d", modeOverride.width, modeOverride.height, + modeOverride.isHiDPI ? "HiDPI" : "1x", modeOverride.backingWidth, modeOverride.backingHeight, - self.capturePreset.width, self.capturePreset.height + self.capturePreset.width, self.capturePreset.height, + profile.panelWidth, profile.panelHeight ) } guard self.session.create( diff --git a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift index de161cf..87a43b2 100644 --- a/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift +++ b/TargetBridge-Sender/TBDisplaySenderTests/TBSenderAutomationParsingTests.swift @@ -8,6 +8,39 @@ import XCTest /// silently reroute automation traffic. @MainActor final class TBSenderAutomationParsingTests: XCTestCase { + func testNativeScaleAppliesOnlyToPanelMatchedProfiles() { + XCTAssertTrue(TBDisplayCapturePreset.standard1080p.rendersAtNativeScale) + XCTAssertTrue(TBDisplayCapturePreset.smooth1080p60.rendersAtNativeScale) + for preset in TBDisplayCapturePreset.allCases where preset.width > 1920 { + XCTAssertFalse( + preset.rendersAtNativeScale, + "\(preset.rawValue) targets a Retina panel and must keep its HiDPI mode" + ) + } + } + + /// A 1x mode must hand CGVirtualDisplay the full stream size with a framebuffer + /// to match, not the halved HiDPI point size. Getting this backwards renders the + /// desktop at 960x540 and draws every control at 2x on a 1080p panel. + func testNativeScaleModeRendersFullSizeWithoutHiDPIBacking() { + let mode = TBDisplayCapturePreset.standard1080p.nativeScaleDisplayMode + XCTAssertEqual(mode.width, 1920) + XCTAssertEqual(mode.height, 1080) + XCTAssertFalse(mode.isHiDPI) + XCTAssertEqual(mode.backingWidth, 1920) + XCTAssertEqual(mode.backingHeight, 1080) + } + + /// Render matching keeps its 2x contract for every non-native-scale profile. + func testRenderMatchedModeKeepsHiDPIBacking() { + let mode = TBDisplayCapturePreset.standard1440p.renderMatchedDisplayMode + XCTAssertEqual(mode.width, 1280) + XCTAssertEqual(mode.height, 720) + XCTAssertTrue(mode.isHiDPI) + XCTAssertEqual(mode.backingWidth, 2560) + XCTAssertEqual(mode.backingHeight, 1440) + } + func testHighFrameRatePresetsUseFiveCaptureSurfaces() { XCTAssertEqual(TBDisplayCapturePreset.standard1440p.queueDepth, 3) XCTAssertEqual(TBDisplayCapturePreset.smooth1440p60.queueDepth, 5) @@ -154,6 +187,8 @@ final class TBSenderAutomationParsingTests: XCTestCase { // MARK: - parsePreset func testParsePresetAcceptsExactRawValues() { + XCTAssertEqual(TBSenderAutomation.parsePreset("standard1080p"), .standard1080p) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1080p60"), .smooth1080p60) XCTAssertEqual(TBSenderAutomation.parsePreset("standard1440p"), .standard1440p) XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1440p60"), .smooth1440p60) XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1800p60"), .smooth1800p60) @@ -164,6 +199,13 @@ final class TBSenderAutomationParsingTests: XCTestCase { } func testParsePresetAliases() { + XCTAssertEqual(TBSenderAutomation.parsePreset("1080p"), .standard1080p) + XCTAssertEqual(TBSenderAutomation.parsePreset("1080"), .standard1080p) + XCTAssertEqual(TBSenderAutomation.parsePreset("fullhd"), .standard1080p) + XCTAssertEqual(TBSenderAutomation.parsePreset("1920x1080"), .standard1080p) + XCTAssertEqual(TBSenderAutomation.parsePreset("1080p60"), .smooth1080p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("FullHD60"), .smooth1080p60) + XCTAssertEqual(TBSenderAutomation.parsePreset("smooth1080"), .smooth1080p60) XCTAssertEqual(TBSenderAutomation.parsePreset("1440p"), .standard1440p) XCTAssertEqual(TBSenderAutomation.parsePreset("standard"), .standard1440p) XCTAssertEqual(TBSenderAutomation.parsePreset("1440p60"), .smooth1440p60) diff --git a/TargetBridge-Shared/Languages/de.json b/TargetBridge-Shared/Languages/de.json index 0075cd5..6a5c8b6 100644 --- a/TargetBridge-Shared/Languages/de.json +++ b/TargetBridge-Shared/Languages/de.json @@ -132,6 +132,8 @@ "sender.status_chip.idle": "Bereit", "sender.source.desktop_mirror": "Desktop duplizieren", "sender.source.extended_desktop": "Erweiterter Desktop", + "sender.profile.full_hd": "Full HD", + "sender.profile.full_hd_60": "Full HD 60", "sender.profile.standard": "Standard", "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", diff --git a/TargetBridge-Shared/Languages/en.json b/TargetBridge-Shared/Languages/en.json index e38314e..836a077 100644 --- a/TargetBridge-Shared/Languages/en.json +++ b/TargetBridge-Shared/Languages/en.json @@ -132,6 +132,8 @@ "sender.status_chip.idle": "Idle", "sender.source.desktop_mirror": "Duplicate Desktop", "sender.source.extended_desktop": "Extended Desktop", + "sender.profile.full_hd": "Full HD", + "sender.profile.full_hd_60": "Full HD 60", "sender.profile.standard": "Standard", "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", diff --git a/TargetBridge-Shared/Languages/fr.json b/TargetBridge-Shared/Languages/fr.json index e18d886..1f62c42 100644 --- a/TargetBridge-Shared/Languages/fr.json +++ b/TargetBridge-Shared/Languages/fr.json @@ -132,6 +132,8 @@ "sender.status_chip.idle": "Inactif", "sender.source.desktop_mirror": "Dupliquer le bureau", "sender.source.extended_desktop": "Bureau étendu", + "sender.profile.full_hd": "Full HD", + "sender.profile.full_hd_60": "Full HD 60", "sender.profile.standard": "Standard", "sender.profile.smooth": "Fluide", "sender.profile.smooth_plus": "Fluide+", diff --git a/TargetBridge-Shared/Languages/it.json b/TargetBridge-Shared/Languages/it.json index 37bc70f..119dcff 100644 --- a/TargetBridge-Shared/Languages/it.json +++ b/TargetBridge-Shared/Languages/it.json @@ -132,6 +132,8 @@ "sender.status_chip.idle": "In attesa", "sender.source.desktop_mirror": "Duplica Desktop", "sender.source.extended_desktop": "Desktop Esteso", + "sender.profile.full_hd": "Full HD", + "sender.profile.full_hd_60": "Full HD 60", "sender.profile.standard": "Standard", "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", diff --git a/TargetBridge-Shared/Languages/zh.json b/TargetBridge-Shared/Languages/zh.json index 6cc9928..21c9134 100644 --- a/TargetBridge-Shared/Languages/zh.json +++ b/TargetBridge-Shared/Languages/zh.json @@ -132,6 +132,8 @@ "sender.status_chip.idle": "空闲", "sender.source.desktop_mirror": "镜像桌面", "sender.source.extended_desktop": "扩展桌面", + "sender.profile.full_hd": "Full HD", + "sender.profile.full_hd_60": "Full HD 60", "sender.profile.standard": "Standard", "sender.profile.smooth": "Smooth", "sender.profile.smooth_plus": "Smooth+", diff --git a/cli/targetbridge b/cli/targetbridge index 794a64c..407338a 100755 --- a/cli/targetbridge +++ b/cli/targetbridge @@ -19,8 +19,9 @@ set -euo pipefail # targetbridge connect --receiver 169.254.0.2 --mode extended --preset 5k # targetbridge disconnect # -# Presets: standard1440p | smooth1440p60 | smooth1800p60 | crisp2160p60 | retina4k60 | native5k -# (aliases: 1440p, 1440p60, 1800p, 4k, 5k) +# Presets: standard1080p | smooth1080p60 | standard1440p | smooth1440p60 | smooth1800p60 +# | crisp2160p60 | retina4k60 | native5k +# (aliases: 1080p, 1080p60, 1440p, 1440p60, 1800p, 4k, 5k) # # Remote / SSH: run it on the sender over SSH (the sender must be at a logged-in # desktop). Because the URL must open in that GUI session, wrap it with launchctl: diff --git a/docs/Automation.md b/docs/Automation.md index 6f6bf22..09c4e5b 100644 --- a/docs/Automation.md +++ b/docs/Automation.md @@ -25,9 +25,14 @@ targetbridge disconnect Options: `--receiver auto|`, `--mode mirror|extended`, `--preset `, `--path auto|wired|thunderbolt|usb|ethernet|wifi`, `--transport tb|net`, `--session N`, `--local-ip `, `--retry`. -Presets: `standard1440p`, `smooth1440p60`, `smooth1800p60`, `crisp2160p60`, `retina4k60`, -`native5k`, `native5k60Experimental` (aliases: `1440p`, `1440p60`, `1800p`, `4k`, -`5k`, `5k60`). The `retina4k60` aliases `retina4k`, `4096x2304` and `imac4k` match +Presets: `standard1080p`, `smooth1080p60`, `standard1440p`, `smooth1440p60`, +`smooth1800p60`, `crisp2160p60`, `retina4k60`, `native5k`, `native5k60Experimental` +(aliases: `1080p`, `1080p60`, `1440p`, `1440p60`, `1800p`, `4k`, `5k`, `5k60`). +The `standard1080p` and `smooth1080p60` presets stream at the 1920 × 1080 native +resolution of every 21.5-inch non-Retina iMac, so the receiver decodes at panel +resolution instead of downscaling a 1440p stream. `standard1080p` also aliases +`1080`, `fullhd` and `1920x1080`; `smooth1080p60` aliases `fullhd60` and +`smooth1080`. The `retina4k60` aliases `retina4k`, `4096x2304` and `imac4k` match the 4096 × 2304 panel in the 21.5-inch Retina 4K iMac without changing the existing `4k` alias for the 3840 × 2160 preset. `native5k60Experimental` is an opt-in HEVC test profile for 5K at 60 FPS; it does not diff --git a/docs/Features.md b/docs/Features.md index 9fbb35d..d240098 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -13,6 +13,13 @@ Extended layouts can be arranged in macOS **System Settings -> Displays -> Arran If the receiver panel does not look correct, set the matching resolution on the TargetBridge display in macOS Display Settings. For 27-inch 5K iMac workflows, the `Crisp` or `5K` stream profiles usually pair best with the matching HiDPI arrangement. +The `Full HD` profiles are for non-Retina 1080p receivers, such as every 21.5-inch +iMac that is not a Retina 4K model. They render the virtual display at 1920 × 1080 +1x rather than as a HiDPI mode, so the desktop reaches the panel at its true size +and capture stays 1:1. Because those profiles already render the desktop to match +the stream, the **Match render to stream** toggle does not apply and is hidden when +one is selected. + Related reading: - [docs/QuickStart-EN.md](docs/QuickStart-EN.md)