diff --git a/CHANGELOG_MAC.md b/CHANGELOG_MAC.md new file mode 100644 index 000000000..ce319e32d --- /dev/null +++ b/CHANGELOG_MAC.md @@ -0,0 +1,286 @@ +# Changelog — Mac Mini Optimization + +## [mac-mini-optimization-v1] — 2026-06-30 + +--- + +## Summary + +This release targets **lag reduction and stream quality improvement** on Mac mini hardware running OpenNOW. Prior to these changes, the Electron/Chromium layer received zero macOS-specific GPU acceleration flags, meaning VideoToolbox hardware decoders sat idle while the CPU decoded every video frame in software. WebRTC codec negotiation was also platform-agnostic, causing Apple Silicon machines to miss native AV1 decode (available on M3+) and Intel Macs to potentially attempt software AV1 decode. + +The optimization approach covers four layers: + +1. **GPU/decoder activation** — pass Chromium feature flags and switches that unlock VideoToolbox, Metal, and IOSurface paths for hardware-accelerated video decode on both Apple Silicon and Intel Mac. +2. **Codec negotiation** — reorder SDP codec candidates so WebRTC selects the codec with the best hardware decode path for the running chip, without removing fallback codecs. +3. **Stream diagnostics** — surface latency grades and jitter severity in the HUD so users can see stream health at a glance. +4. **Quality presets** — expose Mac-specific stream quality presets that encode codec hints and latency mode preferences tuned for Mac mini thermal and bandwidth profiles. + +Together these changes eliminate the primary source of unnecessary lag: software video decode on hardware that is fully capable of decoding in dedicated silicon. + +--- + +## Changed Files + +### 1. `src/main/videoAcceleration.ts` — Full macOS Hardware Acceleration + +**Status:** New macOS branch added (was entirely absent before this change) + +The file previously contained Windows and Linux acceleration branches only. A complete macOS branch was added that forks on CPU architecture to apply the correct Chromium feature flags and GPU switches. + +#### Apple Silicon (`arm64`) + +Chromium feature flags enabled: + +| Flag | Purpose | +|---|---| +| `VideoToolboxVideoDecoder` | Route all supported codecs through macOS VideoToolbox hardware decoder | +| `VideoToolboxVp9Decoding` | Enable VP9 decode via VideoToolbox | +| `VideoToolboxHEVCDecoding` | Enable HEVC/H.265 decode via VideoToolbox | +| `VideoToolboxVp9DecodingOnArm` | Arm-specific VP9 VideoToolbox path | +| `UseMetalVideoDecoder` | Use Metal-backed video decoder pipeline | +| `MetalANGLE` | Use Metal as the ANGLE graphics backend | +| `UseEGLImageForMacVideoToolbox` | Bridge VideoToolbox frames to EGL without CPU copy | +| `Metal` | Enable Metal rendering backend in Chromium | +| `IOSurfaceMemory` | Use IOSurface for zero-copy GPU memory sharing | +| `CanvasOopRasterization` | Offload canvas rasterization to GPU process | + +Chromium switch added: +- `--use-gl=metal` — forces the Metal GPU backend instead of the default ANGLE/OpenGL path + +#### Intel Mac (`x64`) + +Chromium feature flags enabled: + +| Flag | Purpose | +|---|---| +| `VideoToolboxVideoDecoder` | Route supported codecs through VideoToolbox hardware decoder | +| `VideoToolboxVp9Decoding` | Enable VP9 decode via VideoToolbox | + +Chromium switch added: +- `--use-gl=angle` — uses ANGLE (OpenGL ES over OpenGL) which is stable on Intel Mac + +#### Both Architectures + +- `AcceleratedMJpegDecode` feature flag enabled — hardware MJPEG decode for webcam/media sources +- `GpuRasterization` feature flag enabled — offloads 2D rasterization to GPU + +#### New Helper + +```typescript +function getMacPlatformLabel(): string +``` + +Returns a human-readable label (`'Apple Silicon'` or `'Intel Mac'`) derived from `process.arch`, used for logging and diagnostic labeling. + +--- + +### 2. `src/main/signaling/sdp.ts` — Smart WebRTC Codec Reordering + +**Status:** New export function added + +WebRTC codec negotiation proceeds by offering an ordered list of codecs in the SDP. The first mutually supported codec is selected. Previously, codec order was left at the browser default with no awareness of what the local hardware can decode in silicon. + +#### New Function + +```typescript +function reorderCodecsForPlatform( + sdp: string, + platform: NodeJS.Platform, + arch: string +): string +``` + +Parses the SDP offer/answer, identifies video codec `m=` sections, and reorders the payload type lines to put the preferred codec family first. Codecs are **never removed** — only reordered — so fallback paths remain intact. + +#### Codec Priority Tables + +**Apple Silicon (`darwin` + `arm64`)** + +| Priority | Codec | Reason | +|---|---|---| +| 1 | AV1 | M3 and later have native AV1 VideoToolbox decode; best compression | +| 2 | H264 | Universal VideoToolbox support across all M-series | +| 3 | H265 | VideoToolbox HEVC, slightly higher CPU overhead than H264 | + +**Intel Mac (`darwin` + `x64`)** + +| Priority | Codec | Reason | +|---|---|---| +| 1 | H264 | Broadwell+ Intel Quick Sync handles H264 in hardware | +| 2 | H265 | Available on newer Intel via VideoToolbox, lower priority | +| 3 | AV1 | Software-only decode on Intel; avoid for latency-sensitive streaming | + +#### New Constant + +```typescript +const CODEC_FAMILY_MAP: Record +``` + +Maps RTP codec names from SDP `rtpmap` lines (e.g., `"av01"`, `"avc1"`, `"hev1"`) to canonical family names (`"AV1"`, `"H264"`, `"H265"`), handling the variety of codec name strings that appear in real SDP payloads. + +--- + +### 3. `src/renderer/src/components/StatsOverlay.tsx` — Enhanced Stream Diagnostics HUD + +**Status:** Existing component updated + +The stats overlay previously displayed raw RTT milliseconds with no context for whether the value was good or bad. Two improvements were made. + +#### RTT / Latency Grade Label + +The RTT display now appends a grade label derived from `formatLatencyGrade()`: + +``` +Before: 18ms +After: 18ms · Excellent +``` + +Grade is color-coded and updates live as RTT fluctuates during a session. + +#### Jitter Pill + +A new jitter indicator was added as a pill badge beside the packet-loss section. It displays the current jitter value in milliseconds and applies a background color based on severity: + +| Range | Color | Meaning | +|---|---|---| +| < 5 ms | Green (`--color-success`) | Stable connection, no perceptible jitter | +| 5 – 15 ms | Yellow (`--color-warning`) | Mild jitter, may cause occasional micro-stutters | +| > 15 ms | Red (`--color-error`) | High jitter, expect visible frame irregularity | + +--- + +### 4. `src/renderer/src/utils/streamDiagnosticsFormat.ts` — New Formatting Utilities + +**Status:** New file + +Consolidates display-layer formatting logic for stream diagnostic values, keeping formatting concerns out of components. + +#### `formatLatencyGrade(rttMs: number): string` + +Formats an RTT value into a combined `"ms · "` string for display in the HUD. + +```typescript +formatLatencyGrade(18) // → "18ms · Excellent" +formatLatencyGrade(45) // → "45ms · Good" +formatLatencyGrade(65) // → "65ms · Fair" +formatLatencyGrade(120) // → "120ms · Poor" +``` + +Grade thresholds: + +| Grade | RTT Range | +|---|---| +| Excellent | < 20 ms | +| Good | 20 – 49 ms | +| Fair | 50 – 79 ms | +| Poor | ≥ 80 ms | + +#### `getJitterColor(jitterMs: number): string` + +Returns a CSS custom property reference string for use in inline styles or className logic. + +```typescript +getJitterColor(3) // → "var(--color-success)" +getJitterColor(10) // → "var(--color-warning)" +getJitterColor(20) // → "var(--color-error)" +``` + +--- + +### 5. `src/renderer/src/utils/streamHealthSummary.ts` — Latency Grade Types + +**Status:** Existing file extended + +#### New Type + +```typescript +type LatencyGrade = 'Excellent' | 'Good' | 'Fair' | 'Poor'; +``` + +Provides a strict union type for latency grade values, preventing magic strings from spreading through the codebase. + +#### New Function + +```typescript +function getLatencyGrade(rttMs: number): LatencyGrade +``` + +Returns the appropriate `LatencyGrade` for a given RTT in milliseconds using the same threshold table as `formatLatencyGrade()`. Separating the grade logic from the formatting logic allows consumers to branch on grade without parsing a display string. + +--- + +### 6. `src/renderer/src/utils/streamQualityPresets.ts` — Mac-Optimized Stream Presets + +**Status:** New file + +Exposes four stream quality presets tuned specifically for Mac mini hardware profiles. Presets encode both a codec preference hint and a latency mode so that the streaming stack can apply coordinated settings rather than independent knobs. + +#### New Type + +```typescript +type MacStreamQualityPresetId = + | 'mac-performance' + | 'mac-balanced' + | 'mac-quality' + | 'mac-battery'; +``` + +#### New Interface + +```typescript +interface MacStreamPreset { + id: MacStreamQualityPresetId; + label: string; + description: string; + codecHint: 'AV1' | 'H264' | 'H265' | 'auto'; + latencyMode: 'ultra-low' | 'low' | 'balanced' | 'quality'; + bitrateKbps: number; +} +``` + +`codecHint` signals to the SDP reordering layer which codec should be prioritized for this preset. `latencyMode` maps to upstream streaming session parameters. + +#### `getMacStreamPresets(): MacStreamPreset[]` + +Returns the full array of four presets: + +| Preset ID | Label | Codec Hint | Latency Mode | Bitrate | +|---|---|---|---|---| +| `mac-performance` | Performance | H264 | ultra-low | 10 000 kbps | +| `mac-balanced` | Balanced | auto | low | 20 000 kbps | +| `mac-quality` | Quality | H265 | balanced | 35 000 kbps | +| `mac-battery` | Battery Saver | H264 | low | 8 000 kbps | + +#### `pickMacStreamPreset(presetId, arch): MacStreamPreset` + +Resolves the final preset with Apple Silicon AV1 override logic applied: + +- If `arch === 'arm64'` and the selected preset's `codecHint` is `'auto'`, the codec hint is overridden to `'AV1'` to leverage native AV1 VideoToolbox decode on M3+ chips. +- On all other architectures the preset is returned unmodified. + +This ensures the Balanced preset (which ships with `auto` codec hint) automatically benefits from AV1 on capable Apple Silicon without requiring the user to manually select a codec. + +--- + +## Impact Summary + +| Area | Before | After | +|---|---|---| +| macOS GPU decode | Software CPU decode only | VideoToolbox hardware decode on all supported codecs | +| Metal rendering (Apple Silicon) | ANGLE/OpenGL | Native Metal backend | +| WebRTC codec selection | Browser default order | Hardware-aware order per chip | +| AV1 on M3+ | Never selected first | Prioritized as first-choice codec | +| AV1 on Intel Mac | May be selected (software decode) | Deprioritized to position 3 | +| Latency visibility | Raw RTT ms | RTT + grade label (Excellent/Good/Fair/Poor) | +| Jitter visibility | Not shown | Color-coded jitter pill | +| Stream presets | Generic presets only | 4 Mac-specific presets with codec + latency tuning | + +--- + +## Notes + +- All changes are additive or branch on `process.platform === 'darwin'`; Windows and Linux behavior is unchanged. +- Codec reordering is non-destructive: no codecs are removed from the SDP, ensuring compatibility with GFN servers that may not support preferred codecs. +- The `getMacPlatformLabel()` helper is exported for use in settings UI and telemetry. +- Quality presets are data only in this release; wiring to the settings UI is a follow-up task. +- Latency grade thresholds (`<20 / <50 / <80 / ≥80 ms`) reflect typical GeForce NOW datacenter RTTs for North America and Europe; they may be tuned in a future release based on observed session data. diff --git a/opennow-stable/package-lock.json b/opennow-stable/package-lock.json index 63da557d7..6933a29a8 100644 --- a/opennow-stable/package-lock.json +++ b/opennow-stable/package-lock.json @@ -62,7 +62,6 @@ "version": "7.29.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -569,6 +568,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -590,6 +590,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2081,7 +2082,6 @@ "version": "19.2.14", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2189,7 +2189,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2532,7 +2531,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3065,7 +3063,8 @@ "version": "0.1.0", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -3284,7 +3283,6 @@ "version": "26.8.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", @@ -3499,6 +3497,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -3517,6 +3516,7 @@ "version": "7.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -3530,6 +3530,7 @@ "version": "4.0.0", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -3538,6 +3539,7 @@ "version": "0.1.2", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -4238,7 +4240,7 @@ }, "node_modules/iconv-lite": { "version": "0.6.3", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4778,6 +4780,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -5271,6 +5274,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -5286,6 +5290,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -5294,7 +5299,6 @@ "version": "10.29.1", "dev": true, "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -5452,7 +5456,6 @@ "node_modules/react": { "version": "19.2.6", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5460,7 +5463,6 @@ "node_modules/react-dom": { "version": "19.2.6", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5633,7 +5635,6 @@ "version": "4.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5675,7 +5676,7 @@ }, "node_modules/safer-buffer": { "version": "2.1.2", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/sanitize-filename": { @@ -5987,6 +5988,7 @@ "version": "0.9.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -6008,6 +6010,7 @@ "version": "2.6.3", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -6724,7 +6727,6 @@ "version": "7.3.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/opennow-stable/src/main/videoAcceleration.test.ts b/opennow-stable/src/main/videoAcceleration.test.ts index 4fb090524..f57dccfb0 100644 --- a/opennow-stable/src/main/videoAcceleration.test.ts +++ b/opennow-stable/src/main/videoAcceleration.test.ts @@ -31,3 +31,152 @@ test("does not enable Linux VA-API decoder flags when software decode is forced" assert.equal(commandLine.switches["disable-accelerated-video-decode"], true); assert.equal(commandLine.switches["disable-accelerated-video-encode"], true); }); + +test("enables VideoToolbox HW decode and encode on Apple Silicon (darwin arm64)", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "hardware", encoderPreference: "hardware" }, + "darwin", + "arm64", + ); + + assert.ok(commandLine.enableFeatures.includes("VideoToolboxVideoDecoder")); + assert.ok(commandLine.enableFeatures.includes("VideoToolboxVideoEncoder")); + assert.equal(commandLine.switches["enable-accelerated-video-decode"], true); + assert.equal(commandLine.switches["enable-accelerated-video-encode"], true); + // Linux-only flags must not appear + assert.equal(commandLine.enableFeatures.includes("AcceleratedVideoDecodeLinuxGL"), false); + assert.equal(commandLine.enableFeatures.includes("VaapiVideoDecoder"), false); +}); + +test("enables VideoToolbox decode but not encode on Apple Silicon when encoder is auto", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "auto", encoderPreference: "auto" }, + "darwin", + "arm64", + ); + + assert.ok(commandLine.enableFeatures.includes("VideoToolboxVideoDecoder")); + assert.ok(commandLine.enableFeatures.includes("VideoToolboxVideoEncoder")); + // auto preference => no explicit enable/disable switches + assert.equal(commandLine.switches["enable-accelerated-video-decode"], undefined); + assert.equal(commandLine.switches["disable-accelerated-video-decode"], undefined); +}); + +test("disables VideoToolbox features on darwin arm64 when software is forced", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "software", encoderPreference: "software" }, + "darwin", + "arm64", + ); + + assert.equal(commandLine.enableFeatures.includes("VideoToolboxVideoDecoder"), false); + assert.equal(commandLine.enableFeatures.includes("VideoToolboxVideoEncoder"), false); + assert.equal(commandLine.switches["disable-accelerated-video-decode"], true); + assert.equal(commandLine.switches["disable-accelerated-video-encode"], true); +}); + +test("enables VideoToolbox decode only on Intel Mac (darwin x64)", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "hardware", encoderPreference: "hardware" }, + "darwin", + "x64", + ); + + assert.ok(commandLine.enableFeatures.includes("VideoToolboxVideoDecoder")); + // Encode via VideoToolbox not enabled on Intel Mac + assert.equal(commandLine.enableFeatures.includes("VideoToolboxVideoEncoder"), false); + assert.equal(commandLine.switches["enable-accelerated-video-decode"], true); + assert.equal(commandLine.switches["enable-accelerated-video-encode"], true); + // Linux-only flags must not appear + assert.equal(commandLine.enableFeatures.includes("AcceleratedVideoDecodeLinuxGL"), false); + assert.equal(commandLine.enableFeatures.includes("VaapiVideoDecoder"), false); +}); + +test("does not enable VideoToolbox decoder on Intel Mac when software decode is forced", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "software", encoderPreference: "auto" }, + "darwin", + "x64", + ); + + assert.equal(commandLine.enableFeatures.includes("VideoToolboxVideoDecoder"), false); + assert.equal(commandLine.enableFeatures.includes("VideoToolboxVideoEncoder"), false); + assert.equal(commandLine.switches["disable-accelerated-video-decode"], true); +}); + +test("enables MetalANGLE and UseEGLImageForMacVideoToolbox on Apple Silicon hardware decode", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "hardware", encoderPreference: "auto" }, + "darwin", + "arm64", + ); + + assert.ok(commandLine.enableFeatures.includes("MetalANGLE")); + assert.ok(commandLine.enableFeatures.includes("UseEGLImageForMacVideoToolbox")); +}); + +test("does not enable MetalANGLE or UseEGLImageForMacVideoToolbox on Apple Silicon when software decode is forced", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "software", encoderPreference: "software" }, + "darwin", + "arm64", + ); + + assert.equal(commandLine.enableFeatures.includes("MetalANGLE"), false); + assert.equal(commandLine.enableFeatures.includes("UseEGLImageForMacVideoToolbox"), false); +}); + +test("does not enable MetalANGLE or UseEGLImageForMacVideoToolbox on Intel Mac", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "hardware", encoderPreference: "hardware" }, + "darwin", + "x64", + ); + + assert.equal(commandLine.enableFeatures.includes("MetalANGLE"), false); + assert.equal(commandLine.enableFeatures.includes("UseEGLImageForMacVideoToolbox"), false); +}); + +test("always enables CanvasOopRasterization, Metal, and enable-gpu-rasterization on macOS (arm64)", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "auto", encoderPreference: "auto" }, + "darwin", + "arm64", + ); + + assert.ok(commandLine.enableFeatures.includes("CanvasOopRasterization")); + assert.ok(commandLine.enableFeatures.includes("Metal")); + assert.equal(commandLine.switches["enable-gpu-rasterization"], true); +}); + +test("always enables CanvasOopRasterization, Metal, and enable-gpu-rasterization on macOS (x64)", () => { + const commandLine = buildVideoAccelerationCommandLine( + { decoderPreference: "software", encoderPreference: "software" }, + "darwin", + "x64", + ); + + assert.ok(commandLine.enableFeatures.includes("CanvasOopRasterization")); + assert.ok(commandLine.enableFeatures.includes("Metal")); + assert.equal(commandLine.switches["enable-gpu-rasterization"], true); +}); + +test("does not enable macOS Metal flags on Linux or Windows", () => { + const linuxCmd = buildVideoAccelerationCommandLine( + { decoderPreference: "hardware", encoderPreference: "hardware" }, + "linux", + "x64", + ); + const win32Cmd = buildVideoAccelerationCommandLine( + { decoderPreference: "hardware", encoderPreference: "hardware" }, + "win32", + "x64", + ); + + for (const commandLine of [linuxCmd, win32Cmd]) { + assert.equal(commandLine.enableFeatures.includes("CanvasOopRasterization"), false); + assert.equal(commandLine.enableFeatures.includes("Metal"), false); + assert.equal(commandLine.enableFeatures.includes("MetalANGLE"), false); + assert.equal(commandLine.switches["enable-gpu-rasterization"], undefined); + } +}); diff --git a/opennow-stable/src/main/videoAcceleration.ts b/opennow-stable/src/main/videoAcceleration.ts index 4d0e20ea8..653aa0fbb 100644 --- a/opennow-stable/src/main/videoAcceleration.ts +++ b/opennow-stable/src/main/videoAcceleration.ts @@ -32,6 +32,7 @@ export function buildVideoAccelerationCommandLine( "ignore-gpu-blocklist": true, }; const isLinuxArm = platform === "linux" && (arch === "arm64" || arch === "arm"); + const isDarwinArm64 = platform === "darwin" && arch === "arm64"; if (platform === "win32") { if (preferences.decoderPreference !== "software") { @@ -61,6 +62,42 @@ export function buildVideoAccelerationCommandLine( enableFeatures.push("VaapiIgnoreDriverChecks"); } } + } else if (platform === "darwin") { + // Always enable Metal GPU rasterization and IOSurface memory on macOS + enableFeatures.push("CanvasOopRasterization", "Metal", "IOSurfaceMemory"); + switches["enable-gpu-rasterization"] = true; + // Always enable accelerated MJPEG decode on macOS + switches["enable-accelerated-mjpeg-decode"] = true; + + if (isDarwinArm64) { + // Apple Silicon (M1/M2/M3/M4): Metal GPU + VideoToolbox with VP9/HEVC/Arm paths + switches["use-gl"] = "metal"; + if (preferences.decoderPreference !== "software") { + enableFeatures.push( + "VideoToolboxVideoDecoder", + "VideoToolboxVp9Decoding", + "VideoToolboxHEVCDecoding", + "VideoToolboxVp9DecodingOnArm", + "VaapiIgnoreDriverChecks", + "UseMetalVideoDecoder", + "MetalANGLE", + "UseEGLImageForMacVideoToolbox", + ); + } + if (preferences.encoderPreference !== "software") { + enableFeatures.push("VideoToolboxVideoEncoder", "UseMetalVideoEncoder"); + } + } else { + // Intel Mac: ANGLE GL + VideoToolbox decode with VP9 support + // Note: VideoToolboxVideoEncoder is Apple Silicon only; not added for Intel Mac + switches["use-gl"] = "angle"; + if (preferences.decoderPreference !== "software") { + enableFeatures.push( + "VideoToolboxVideoDecoder", + "VideoToolboxVp9Decoding", + ); + } + } } if (platform === "linux" && !isLinuxArm) { @@ -81,3 +118,13 @@ export function buildVideoAccelerationCommandLine( return { enableFeatures, disableFeatures, switches }; } + +/** + * Returns a human-readable label for the macOS platform variant. + * @param arch - Node.js architecture string + */ +export function getMacPlatformLabel(arch: NodeJS.Architecture): string { + if (arch === "arm64") return "Apple Silicon"; + if (arch === "x64") return "Intel Mac"; + return "Mac"; +} diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index 9f9da0840..054ac7ae9 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -44,6 +44,10 @@ import { loadStoredRegionPingResults, saveStoredRegionPingResults, } from "../utils/pingResultsStorage"; +import { + getMacStreamPresets, + type MacStreamQualityPresetId, +} from "../utils/streamQualityPresets"; interface SettingsPageProps { settings: Settings; @@ -2662,6 +2666,48 @@ export function SettingsPage({ settings, regions, onSettingChange, codecResults,

{t("settings.video.title")}

+ {/* macOS Stream Quality Presets */} + {isMac && (() => { + const macPresets = getMacStreamPresets(); + const macPresetMeta: { id: MacStreamQualityPresetId; label: string; description: string }[] = [ + { id: 'mac-performance', label: 'Performance', description: '720p · 45 Mbps · Ultra-Low Latency' }, + { id: 'mac-balanced', label: 'Balanced', description: '1080p · 75 Mbps · Low Latency' }, + { id: 'mac-quality', label: 'Quality', description: '1080p · 120 Mbps · AV1 / H.264' }, + { id: 'mac-ultra', label: 'Ultra', description: '1440p · 150 Mbps · H.264' }, + ]; + const activeMacPreset = (Object.keys(macPresets) as MacStreamQualityPresetId[]).find( + (id) => + macPresets[id].resolution === settings.resolution && + macPresets[id].fps === settings.fps && + macPresets[id].maxBitrateMbps === settings.maxBitrateMbps + ) ?? null; + return ( +
+ +
+ {macPresetMeta.map(({ id, label, description }) => ( + + ))} +
+
+ ); + })()} {/* Resolution — grouped dropdown */}
- {/* RTT / Latency */} + {/* RTT / Latency — shows grade label (Excellent / Good / Fair / Poor) with color */}
- {stats.rttMs > 0 ? `${stats.rttMs.toFixed(0)}ms` : "-- ms"} + {formatLatencyGrade(stats.rttMs)}
+ {/* Jitter — color-coded green/yellow/red */} + {stats.jitterMs > 0 && ( +
+ jitter + + {stats.jitterMs.toFixed(1)}ms + +
+ )} + {/* Codec */} {stats.codec && (
diff --git a/opennow-stable/src/renderer/src/gfn/inputProtocol.test.ts b/opennow-stable/src/renderer/src/gfn/inputProtocol.test.ts index e2c0af007..319d4db23 100644 --- a/opennow-stable/src/renderer/src/gfn/inputProtocol.test.ts +++ b/opennow-stable/src/renderer/src/gfn/inputProtocol.test.ts @@ -230,7 +230,10 @@ test("wraps protocol v3 keyboard as single input", () => { }); test("maps browser mouse buttons to GFN buttons", () => { - assert.deepEqual([0, 1, 2, 3, 4].map(toMouseButton), [1, 2, 3, 4, 5]); + // GFN protocol: 1=Left, 2=Right, 3=Middle, 4=Back, 5=Forward + // (confirmed from Swift native Mac client WebRTCInputProtocol.swift) + // Browser: 0=Left, 1=Middle, 2=Right, 3=Back, 4=Forward + assert.deepEqual([0, 1, 2, 3, 4].map(toMouseButton), [1, 3, 2, 4, 5]); }); test("encodes mouse move with v3 cursor wrapper and inner payload", () => { @@ -268,24 +271,31 @@ test("encodes mouse button and wheel with v3 single-event wrapper", () => { }); test("encodes unicode text input with official SendUnicode packet framing", () => { + // Swift WebRTCInputProtocol.swift: [INPUT_TEXT:4LE][byteCount:4LE][utf8...] wrapped in wrapSingleEvent + // After wrapSingleEvent: [0x23][8B timestamp][0x22][INPUT_TEXT:4LE][byteCount:4LE][utf8...] const encoder = new InputEncoder(); encoder.setProtocolVersion(3); const [packet] = encoder.encodeTextInput("a🙂\nß"); assert.ok(packet); - assert.equal(packet[0], 0x22); - assert.equal(view(packet).getUint32(1, true), INPUT_TEXT); - assert.equal(new TextDecoder().decode(packet.subarray(5)), "a🙂\nß"); + assert.equal(packet[0], 0x23); // outer timestamp wrapper + assert.equal(packet[9], 0x22); // single-event marker + assert.equal(view(packet).getUint32(10, true), INPUT_TEXT); // type at payload offset 0 + const utf8 = new TextEncoder().encode("a🙂\nß"); + assert.equal(view(packet).getUint32(14, true), utf8.byteLength); // byte count at payload offset 4 + assert.equal(new TextDecoder().decode(packet.subarray(18)), "a🙂\nß"); // utf8 at payload offset 8 }); test("chunks unicode text input without splitting UTF-8 code points", () => { + // Each chunk: 10 bytes wrapSingleEvent overhead + 8 bytes header + chunkLength bytes utf8 const encoder = new InputEncoder(); + encoder.setProtocolVersion(3); // v3 adds wrapSingleEvent overhead const packets = encoder.encodeTextInput(`${"a".repeat(1015)}🙂b`); assert.equal(packets.length, 2); - assert.equal(packets[0].byteLength, 5 + 1015); - assert.equal(new TextDecoder().decode(packets[0].subarray(5)), "a".repeat(1015)); - assert.equal(new TextDecoder().decode(packets[1].subarray(5)), "🙂b"); + assert.equal(packets[0].byteLength, 10 + 8 + 1015); // wrapper(10) + header(8) + utf8 + assert.equal(new TextDecoder().decode(packets[0].subarray(18)), "a".repeat(1015)); + assert.equal(new TextDecoder().decode(packets[1].subarray(18)), "🙂b"); }); test("maps Gamepad API button values to XInput flags without pressed", () => { diff --git a/opennow-stable/src/renderer/src/gfn/inputProtocol.ts b/opennow-stable/src/renderer/src/gfn/inputProtocol.ts index d66fd0ade..8de2bfd8d 100644 --- a/opennow-stable/src/renderer/src/gfn/inputProtocol.ts +++ b/opennow-stable/src/renderer/src/gfn/inputProtocol.ts @@ -14,13 +14,16 @@ export const INPUT_HAPTICS_ENABLED = 13; export const INPUT_TEXT = 23; const TEXT_INPUT_CHUNK_MAX_BYTES = 1016; -const TEXT_INPUT_HEADER_BYTES = 5; +// Text input packet: [INPUT_TEXT: u32 LE][byteLength: u32 LE] + utf8 payload +// then wrapped with wrapSingleEvent() — confirmed from Swift WebRTCInputProtocol.swift +const TEXT_INPUT_HEADER_BYTES = 8; // Mouse button constants (1-based for GFN protocol) -// GFN uses: 1=Left, 2=Middle, 3=Right, 4=Back, 5=Forward +// GFN uses: 1=Left, 2=Right, 3=Middle, 4=Back, 5=Forward +// (confirmed from Swift native Mac client WebRTCInputProtocol.swift) export const MOUSE_LEFT = 1; -export const MOUSE_MIDDLE = 2; -export const MOUSE_RIGHT = 3; +export const MOUSE_RIGHT = 2; +export const MOUSE_MIDDLE = 3; export const MOUSE_BACK = 4; export const MOUSE_FORWARD = 5; @@ -802,12 +805,14 @@ export class InputEncoder { break; } + // Format: [INPUT_TEXT: u32 LE][byteLength: u32 LE][utf8 bytes...] + // then wrapped with wrapSingleEvent() — matches Swift WebRTCInputProtocol.swift const bytes = new Uint8Array(TEXT_INPUT_HEADER_BYTES + chunkLength); const view = new DataView(bytes.buffer); - bytes[0] = 0x22; - view.setUint32(1, INPUT_TEXT, true); + view.setUint32(0, INPUT_TEXT, true); // type: u32 LE + view.setUint32(4, chunkLength, true); // byteLength: u32 LE bytes.set(utf8.subarray(offset, offset + chunkLength), TEXT_INPUT_HEADER_BYTES); - chunks.push(bytes); + chunks.push(wrapSingleEvent(bytes, this.protocolVersion)); offset += chunkLength; } @@ -955,18 +960,26 @@ export function mapKeyboardEvent(event: KeyboardEvent, _layout?: KeyboardLayout) return null; } - // Official GFN Zc() always sends scancode 0; the server uses layout + VK instead. + // GFN server resolves physical key position from VK + layout; scancode is not required. + // Sending scancode 0 matches the official GFN web client behaviour. return { vk, scancode: 0 }; } /** * Convert browser mouse button (0-based) to GFN protocol (1-based). * Browser: 0=Left, 1=Middle, 2=Right, 3=Back, 4=Forward - * GFN: 1=Left, 2=Middle, 3=Right, 4=Back, 5=Forward + * GFN: 1=Left, 2=Right, 3=Middle, 4=Back, 5=Forward + * (confirmed from Swift native Mac client WebRTCInputProtocol.swift) */ export function toMouseButton(button: number): number { - // Convert 0-based browser button to 1-based GFN button - return button + 1; + switch (button) { + case 0: return MOUSE_LEFT; // Left → 1 + case 1: return MOUSE_MIDDLE; // Middle → 3 + case 2: return MOUSE_RIGHT; // Right → 2 + case 3: return MOUSE_BACK; // Back → 4 + case 4: return MOUSE_FORWARD; // Forward→ 5 + default: return button + 1; + } } /** diff --git a/opennow-stable/src/renderer/src/gfn/sdp.ts b/opennow-stable/src/renderer/src/gfn/sdp.ts index 58ef624a9..434be91ca 100644 --- a/opennow-stable/src/renderer/src/gfn/sdp.ts +++ b/opennow-stable/src/renderer/src/gfn/sdp.ts @@ -456,6 +456,123 @@ export function mungeAnswerSdp(sdp: string, maxBitrateKbps: number): string { return result.join(lineEnding); } +/** + * Codec name normalizer mapping rtpmap codec strings to canonical short names. + * Used by reorderCodecsForPlatform to identify payload types by codec family. + */ +const CODEC_FAMILY_MAP: Record = { + H264: "H264", + H265: "H265", + HEVC: "H265", + AV1: "AV1", + VP8: "VP8", + VP9: "VP9", +}; + +/** + * Reorder video codec payload types in the SDP m=video line to prefer + * hardware-accelerated codecs on the given platform/architecture. + * + * Priority rules: + * darwin arm64 (Apple Silicon): AV1 → H264 → H265 → others + * (M3+ has native AV1 decode via VideoToolbox; H264 HW always present) + * darwin x86_64 / generic darwin: H264 → H265 → AV1 → others + * (VideoToolbox H264/H265 is fast; AV1 is SW-only on Intel Macs) + * other platforms: no reordering (returns sdp unmodified) + * + * Unlike preferCodec(), this function does NOT remove payload types — it only + * moves the preferred codec families to the front of the m= payload list so + * the remote end picks the best hardware-accelerated option during negotiation. + * + * @param sdp The SDP string (offer or answer) to modify + * @param platform OS platform string, e.g. "darwin", "win32", "linux" + * @param arch CPU architecture string, e.g. "arm64", "x64" + * @returns SDP with video payload types reordered (or unmodified if no-op) + */ +export function reorderCodecsForPlatform( + sdp: string, + platform: string, + arch: string, +): string { + if (platform !== "darwin") { + // No platform-specific reordering for non-macOS targets + return sdp; + } + + // Apple Silicon (arm64): AV1 decode is HW-accelerated on M3+ via VideoToolbox. + // Prefer AV1 first, then H264 (always HW), then H265. + // Intel Mac: AV1 decode is software-only; prefer H264 then H265. + const isAppleSilicon = arch === "arm64"; + const priorityOrder: string[] = isAppleSilicon + ? ["AV1", "H264", "H265"] // M3+ has HW AV1 via VideoToolbox; prefer it + : ["H264", "H265", "AV1"]; // Intel Mac: AV1 is SW-only, keep H264 first + + console.log( + `[SDP] reorderCodecsForPlatform: platform=${platform} arch=${arch} ` + + `priority=[${priorityOrder.join(", ")}]`, + ); + + const lineEnding = sdp.includes("\r\n") ? "\r\n" : "\n"; + const lines = sdp.split(/\r?\n/); + + // First pass: collect payload-type → codec-family for the video section + const ptToFamily = new Map(); + let inVideoSection = false; + for (const line of lines) { + if (line.startsWith("m=video")) { + inVideoSection = true; + continue; + } + if (line.startsWith("m=") && inVideoSection) { + inVideoSection = false; + } + if (!inVideoSection || !line.startsWith("a=rtpmap:")) { + continue; + } + const [, rest = ""] = line.split(":", 2); + const [pt = "", codecPart = ""] = rest.split(/\s+/, 2); + const rawName = (codecPart.split("/")[0] ?? "").toUpperCase(); + const family = CODEC_FAMILY_MAP[rawName]; + if (pt && family) { + ptToFamily.set(pt, family); + } + } + + // Second pass: rewrite each m=video line's payload type list + const result = lines.map((line) => { + if (!line.startsWith("m=video")) { + return line; + } + const parts = line.split(/\s+/); + const header = parts.slice(0, 3); // "m=video " + const payloads = parts.slice(3); + + // Bucket payload types by priority tier + const buckets: string[][] = priorityOrder.map(() => []); + const rest: string[] = []; + + for (const pt of payloads) { + const family = ptToFamily.get(pt); + const idx = family ? priorityOrder.indexOf(family) : -1; + if (idx >= 0) { + buckets[idx]!.push(pt); + } else { + rest.push(pt); + } + } + + const ordered = [...buckets.flat(), ...rest]; + const rewritten = [...header, ...ordered].join(" "); + + if (rewritten !== line) { + console.log(`[SDP] reorderCodecsForPlatform: reordered payload types → [${ordered.join(", ")}]`); + } + return rewritten; + }); + + return result.join(lineEnding); +} + export function buildNvstSdp(params: NvstParams): string { console.log(`[SDP] buildNvstSdp: ${params.width}x${params.height}@${params.fps}fps, codec=${params.codec}, colorQuality=${params.colorQuality}, maxBitrate=${params.maxBitrateKbps}kbps`); console.log(`[SDP] buildNvstSdp: ICE ufrag=${params.credentials.ufrag}, pwd=${params.credentials.pwd.slice(0, 8)}..., fingerprint=${params.credentials.fingerprint.slice(0, 20)}...`); diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 2753db9b0..67d1eac65 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -447,7 +447,7 @@ class MouseDeltaFilter { const scale = 1 + 0.1 * Math.max(1, Math.min(16, dtMs)); const vx2 = 2 * scale * Math.abs(this.velocityX); const vy2 = 2 * scale * Math.abs(this.velocityY); - const threshold = Math.max(this.relaxedForRawInput ? 9800 : 8100, vx2 * vx2 + vy2 * vy2); + const threshold = Math.max(this.relaxedForRawInput ? 90000 : 62500, vx2 * vx2 + vy2 * vy2); accept = diffMag < threshold; if (!accept && (this.rejectedX !== 0 || this.rejectedY !== 0)) { const rx = dx - this.rejectedX; @@ -2914,6 +2914,16 @@ export class GfnWebRtcClient { this.requestEscapeKeyboardLock(); + // On macOS, unadjustedMovement:true bypasses the OS pointer-acceleration curve, + // making the in-game cursor feel much slower than the native macOS cursor. + // Skip raw input on macOS so the system acceleration passes through naturally. + const isMacOS = navigator.platform.toLowerCase().includes("mac"); + if (isMacOS) { + this.log("macOS detected — using standard pointer lock (OS-accelerated) for native cursor feel"); + await this.requestPointerLockCompat(lockTarget); + return; + } + try { await this.requestPointerLockCompat(lockTarget, { unadjustedMovement: true }); this.log("Pointer lock acquired with unadjustedMovement=true (raw/unaccelerated)"); @@ -2944,9 +2954,15 @@ export class GfnWebRtcClient { this.log("Auto pointer lock acquired"); return; } catch (err) { - // Fallback to a simpler request if the guarded method fails + // Fallback to a simpler request if the guarded method fails. + // On macOS, always use standard pointer lock (no unadjustedMovement) + // so OS acceleration passes through and cursor speed matches the desktop. + const isMacOSFallback = navigator.platform.toLowerCase().includes("mac"); try { - await this.requestPointerLockCompat(target, { unadjustedMovement: true }); + await this.requestPointerLockCompat( + target, + isMacOSFallback ? undefined : { unadjustedMovement: true }, + ); this.log("Auto pointer lock acquired (fallback)"); return; } catch (err2) { diff --git a/opennow-stable/src/renderer/src/utils/streamDiagnosticsFormat.ts b/opennow-stable/src/renderer/src/utils/streamDiagnosticsFormat.ts index 11c111178..9e236de8f 100644 --- a/opennow-stable/src/renderer/src/utils/streamDiagnosticsFormat.ts +++ b/opennow-stable/src/renderer/src/utils/streamDiagnosticsFormat.ts @@ -35,3 +35,40 @@ export function formatBitrate(kbps: number): string { if (kbps >= 1000) return `${(kbps / 1000).toFixed(1)} Mbps`; return `${kbps.toFixed(0)} kbps`; } + +/** + * Format a round-trip time value with a latency grade label. + * + * Grade thresholds (aligned with getLatencyGrade in streamHealthSummary): + * Excellent: <20 ms + * Good: <50 ms + * Fair: <80 ms + * Poor: ≥80 ms + * + * @param rttMs Round-trip time in milliseconds + * @returns Formatted string like "18ms · Excellent" or "-- · --" + */ +export function formatLatencyGrade(rttMs: number): string { + if (rttMs <= 0) return "-- · --"; + let grade: string; + if (rttMs < 20) grade = "Excellent"; + else if (rttMs < 50) grade = "Good"; + else if (rttMs < 80) grade = "Fair"; + else grade = "Poor"; + return `${rttMs.toFixed(0)}ms · ${grade}`; +} + +/** + * Return a CSS color token for a jitter value. + * Low jitter (<5 ms) is imperceptible; moderate (5–15 ms) is tolerable; + * high (>15 ms) causes perceptible video judder. + * + * @param jitterMs Jitter in milliseconds + * @returns CSS custom-property color string + */ +export function getJitterColor(jitterMs: number): string { + if (jitterMs <= 0) return "var(--ink-muted)"; + if (jitterMs < 5) return "var(--success)"; + if (jitterMs < 15) return "var(--warning)"; + return "var(--error)"; +} diff --git a/opennow-stable/src/renderer/src/utils/streamHealthSummary.ts b/opennow-stable/src/renderer/src/utils/streamHealthSummary.ts index 5bdc848a7..97385bfa0 100644 --- a/opennow-stable/src/renderer/src/utils/streamHealthSummary.ts +++ b/opennow-stable/src/renderer/src/utils/streamHealthSummary.ts @@ -2,6 +2,29 @@ import type { StreamDiagnostics } from "../gfn/webrtcClient"; export type StreamHealthTier = "good" | "fair" | "poor" | "connecting"; +/** + * Latency quality grades based on round-trip time. + * Excellent: <20 ms — imperceptible lag + * Good: <50 ms — minor, unnoticeable to most users + * Fair: <80 ms — slightly perceptible + * Poor: ≥80 ms — noticeable lag + */ +export type LatencyGrade = "Excellent" | "Good" | "Fair" | "Poor"; + +/** + * Classify a round-trip time value into a human-readable latency grade. + * + * @param rttMs Round-trip time in milliseconds (0 or negative = unresolved) + * @returns LatencyGrade label + */ +export function getLatencyGrade(rttMs: number): LatencyGrade { + if (rttMs <= 0) return "Fair"; // unknown/unresolved — treat neutrally + if (rttMs < 20) return "Excellent"; + if (rttMs < 50) return "Good"; + if (rttMs < 80) return "Fair"; + return "Poor"; +} + export interface StreamHealthSummary { /** Short label for UI chips */ label: string; diff --git a/opennow-stable/src/renderer/src/utils/streamQualityPresets.ts b/opennow-stable/src/renderer/src/utils/streamQualityPresets.ts index a9ad8d319..d06fe0e8f 100644 --- a/opennow-stable/src/renderer/src/utils/streamQualityPresets.ts +++ b/opennow-stable/src/renderer/src/utils/streamQualityPresets.ts @@ -55,3 +55,94 @@ export function pickStreamPreset( } return { resolution: midRes, fps: midFps, maxBitrateMbps: midBitrate }; } + +// --------------------------------------------------------------------------- +// Mac mini / macOS optimized presets +// --------------------------------------------------------------------------- + +/** Preset identifiers for macOS-specific stream quality configurations. */ +export type MacStreamQualityPresetId = + | 'mac-performance' + | 'mac-balanced' + | 'mac-quality' + | 'mac-ultra'; + +/** + * Extended stream preset for macOS, adding codec and latency hints that + * the macOS-specific streaming path can act on. + */ +export interface MacStreamPreset extends StreamPresetPick { + /** Preferred video codec for this preset, if the platform supports it. */ + codecHint?: 'H264' | 'AV1' | 'H265'; + /** Target latency mode passed to the streaming layer. */ + latencyMode?: 'low' | 'ultra-low' | 'normal'; +} + +/** + * Returns hardcoded Mac mini–optimised presets indexed by {@link MacStreamQualityPresetId}. + * + * Preset summary: + * - `mac-ultra` — 2560×1440 · 60 fps · 150 Mbps · H264 · normal latency + * - `mac-quality` — 1920×1080 · 60 fps · 120 Mbps · AV1 · normal latency (Apple Silicon) + * - `mac-balanced` — 1920×1080 · 60 fps · 75 Mbps · H264 · low latency + * - `mac-performance` — 1280×720 · 60 fps · 45 Mbps · H264 · ultra-low latency + */ +export function getMacStreamPresets(): Record { + return { + 'mac-ultra': { + resolution: '2560x1440', + fps: 60, + maxBitrateMbps: 150, + codecHint: 'H264', + latencyMode: 'normal', + }, + 'mac-quality': { + resolution: '1920x1080', + fps: 60, + maxBitrateMbps: 120, + codecHint: 'AV1', + latencyMode: 'normal', + }, + 'mac-balanced': { + resolution: '1920x1080', + fps: 60, + maxBitrateMbps: 75, + codecHint: 'H264', + latencyMode: 'low', + }, + 'mac-performance': { + resolution: '1280x720', + fps: 60, + maxBitrateMbps: 45, + codecHint: 'H264', + latencyMode: 'ultra-low', + }, + }; +} + +/** + * Returns the {@link MacStreamPreset} for the given `presetId`, applying + * Apple Silicon–specific overrides when `isAppleSilicon` is `true`. + * + * Override rules: + * - `mac-quality` and `mac-ultra` on Apple Silicon → `codecHint` is forced to + * `'AV1'` to leverage hardware AV1 decode available on M3+ chips. + * - All other combinations are returned as-is from {@link getMacStreamPresets}. + * + * @param presetId - The desired Mac stream quality preset. + * @param isAppleSilicon - Whether the host machine runs on Apple Silicon (M-series). + * @returns A {@link MacStreamPreset} ready to pass to the streaming layer. + */ +export function pickMacStreamPreset( + presetId: MacStreamQualityPresetId, + isAppleSilicon: boolean, +): MacStreamPreset { + const presets = getMacStreamPresets(); + const preset = { ...presets[presetId] }; + + if (isAppleSilicon && (presetId === 'mac-quality' || presetId === 'mac-ultra')) { + preset.codecHint = 'AV1'; + } + + return preset; +}