From a9921e18e15c9aabd3cc9589c02d644a17b774f0 Mon Sep 17 00:00:00 2001 From: Meganugger <182369132+Meganugger@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:06:34 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20AMD=20HEVC=20green=20screen=20=E2=80=94?= =?UTF-8?q?=20GPU=20detection,=20codec=20fallback,=20and=20HEVC=20compatib?= =?UTF-8?q?ility=20mode=20setting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GPU detection via WebGL ANGLE renderer info to identify AMD Polaris/Vega GPUs - Auto-fallback from H.265 to H.264 on affected GPUs (RX 550, Vega iGPU, etc.) - Add Settings > Video > HEVC Compatibility Mode (Auto/Force H.264/Force HEVC/HEVC Software) - Add green screen frame detection with diagnostic logging - Add HevcCompatMode type to shared types and settings --- opennow-stable/src/main/settings.ts | 47 +++++- opennow-stable/src/renderer/src/App.tsx | 18 ++- .../renderer/src/components/SettingsPage.tsx | 24 +++ .../src/renderer/src/gfn/gpuDetect.ts | 144 ++++++++++++++++++ .../src/renderer/src/gfn/webrtcClient.ts | 131 +++++++++++++++- opennow-stable/src/shared/gfn.ts | 64 +++++++- 6 files changed, 416 insertions(+), 12 deletions(-) create mode 100644 opennow-stable/src/renderer/src/gfn/gpuDetect.ts diff --git a/opennow-stable/src/main/settings.ts b/opennow-stable/src/main/settings.ts index 6f3226e9..adb278c1 100644 --- a/opennow-stable/src/main/settings.ts +++ b/opennow-stable/src/main/settings.ts @@ -2,7 +2,8 @@ import { app } from "electron"; import { join } from "node:path"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import type { VideoCodec, ColorQuality, VideoAccelerationPreference, MicrophoneMode } from "@shared/gfn"; - +import type { VideoCodec, ColorQuality, VideoAccelerationPreference, FlightSlotConfig, HdrStreamingMode, MicMode, HevcCompatMode } from "@shared/gfn"; +import { defaultFlightSlots } from "@shared/gfn"; export interface Settings { /** Video resolution (e.g., "1920x1080") */ resolution: string; @@ -48,7 +49,34 @@ export interface Settings { windowWidth: number; /** Window height */ windowHeight: number; -} + /** Enable Discord Rich Presence */ + discordPresenceEnabled: boolean; + /** Discord Application Client ID */ + discordClientId: string; + /** Enable flight controls (HOTAS/joystick) */ + flightControlsEnabled: boolean; + /** Controller slot for flight controls (0-3) — legacy, kept for compat */ + flightControlsSlot: number; + /** Per-slot flight configurations */ + flightSlots: FlightSlotConfig[]; + /** HDR streaming mode: off, auto, on */ + hdrStreaming: HdrStreamingMode; + /** Microphone mode: off, on, push-to-talk */ + micMode: MicMode; + /** Selected microphone device ID (empty = default) */ + micDeviceId: string; + /** Microphone input gain 0.0 - 2.0 */ + micGain: number; + /** Enable noise suppression */ + micNoiseSuppression: boolean; + /** Enable automatic gain control */ + micAutoGainControl: boolean; + /** Enable echo cancellation */ + micEchoCancellation: boolean; + /** Toggle mic on/off shortcut (works in-stream) */ + shortcutToggleMic: string; + /** HEVC compatibility mode: auto, force_h264, force_hevc, hevc_software */ + hevcCompatMode: HevcCompatMode;} const defaultStopShortcut = "Ctrl+Shift+Q"; const defaultAntiAfkShortcut = "Ctrl+Shift+K"; @@ -79,7 +107,20 @@ const DEFAULT_SETTINGS: Settings = { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, -}; + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", + micMode: "off", + micDeviceId: "", + micGain: 1.0, + micNoiseSuppression: true, + micAutoGainControl: true, + micEchoCancellation: true, + shortcutToggleMic: "Ctrl+Shift+M", + hevcCompatMode: "auto",}; export class SettingsManager { private settings: Settings; diff --git a/opennow-stable/src/renderer/src/App.tsx b/opennow-stable/src/renderer/src/App.tsx index f62a8bde..e3e246d0 100644 --- a/opennow-stable/src/renderer/src/App.tsx +++ b/opennow-stable/src/renderer/src/App.tsx @@ -286,7 +286,20 @@ export function App(): JSX.Element { sessionClockShowDurationSeconds: 30, windowWidth: 1400, windowHeight: 900, - }); + discordPresenceEnabled: false, + discordClientId: "", + flightControlsEnabled: false, + flightControlsSlot: 3, + flightSlots: defaultFlightSlots(), + hdrStreaming: "off", + micMode: "off", + micDeviceId: "", + micGain: 1.0, + micNoiseSuppression: true, + micAutoGainControl: true, + micEchoCancellation: true, + shortcutToggleMic: DEFAULT_SHORTCUTS.shortcutToggleMic, + hevcCompatMode: "auto", }); const [settingsLoaded, setSettingsLoaded] = useState(false); const [regions, setRegions] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); @@ -722,7 +735,8 @@ export function App(): JSX.Element { resolution: settings.resolution, fps: settings.fps, maxBitrateKbps: settings.maxBitrateMbps * 1000, - }); + hdrEnabled: hdrEnabledForStream, + hevcCompatMode: settings.hevcCompatMode, }); setLaunchError(null); setStreamStatus("streaming"); setSessionStartedAtMs((current) => current ?? Date.now()); diff --git a/opennow-stable/src/renderer/src/components/SettingsPage.tsx b/opennow-stable/src/renderer/src/components/SettingsPage.tsx index a19c115e..f598b9ae 100644 --- a/opennow-stable/src/renderer/src/components/SettingsPage.tsx +++ b/opennow-stable/src/renderer/src/components/SettingsPage.tsx @@ -840,6 +840,30 @@ export function SettingsPage({ settings, regions, onSettingChange }: SettingsPag + {/* HEVC Compatibility Mode */} +
+ +
+ {([ + { value: "auto" as const, label: "Auto" }, + { value: "force_h264" as const, label: "Force H.264" }, + { value: "force_hevc" as const, label: "Force HEVC" }, + { value: "hevc_software" as const, label: "HEVC Software" }, + ] as const).map((option) => ( + + ))} +
+ + Auto disables HEVC on AMD Polaris/Vega GPUs (RX 550, Vega iGPU) to prevent green screen. + +
+ {/* Decoder preference */}
diff --git a/opennow-stable/src/renderer/src/gfn/gpuDetect.ts b/opennow-stable/src/renderer/src/gfn/gpuDetect.ts new file mode 100644 index 00000000..d269e11d --- /dev/null +++ b/opennow-stable/src/renderer/src/gfn/gpuDetect.ts @@ -0,0 +1,144 @@ +import type { HevcCompatMode, VideoCodec } from "@shared/gfn"; + +export interface GpuInfo { + vendor: "amd" | "nvidia" | "intel" | "unknown"; + renderer: string; + unmaskedRenderer: string; + isAmdPolarisOrVega: boolean; +} + +const AMD_HEVC_PROBLEM_PATTERNS = [ + /RX\s*550/i, + /RX\s*560/i, + /RX\s*570/i, + /RX\s*580/i, + /RX\s*590/i, + /RX\s*5[0-9]{2}\b/i, + /Polaris/i, + /Vega/i, + /Ryzen.*Vega/i, + /Radeon.*Vega/i, + /Radeon\(TM\).*Graphics/i, + /Radeon\s+Graphics/i, + /gfx80[0-3]/i, + /gfx90[0-2]/i, +]; + +function detectVendor(renderer: string): "amd" | "nvidia" | "intel" | "unknown" { + const r = renderer.toLowerCase(); + if (r.includes("amd") || r.includes("radeon") || r.includes("ati")) return "amd"; + if (r.includes("nvidia") || r.includes("geforce") || r.includes("quadro") || r.includes("rtx") || r.includes("gtx")) return "nvidia"; + if (r.includes("intel") || r.includes("iris") || r.includes("uhd")) return "intel"; + return "unknown"; +} + +function isProblematicAmdGpu(renderer: string): boolean { + if (detectVendor(renderer) !== "amd") return false; + return AMD_HEVC_PROBLEM_PATTERNS.some((pattern) => pattern.test(renderer)); +} + +let cachedGpuInfo: GpuInfo | null = null; + +export function detectGpu(): GpuInfo { + if (cachedGpuInfo) return cachedGpuInfo; + + let renderer = ""; + let unmaskedRenderer = ""; + + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl") ?? canvas.getContext("webgl2"); + if (gl) { + renderer = gl.getParameter(gl.RENDERER) as string; + const ext = gl.getExtension("WEBGL_debug_renderer_info"); + if (ext) { + unmaskedRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string; + } + const loseCtx = gl.getExtension("WEBGL_lose_context"); + if (loseCtx) loseCtx.loseContext(); + } + } catch { + // WebGL not available + } + + const effectiveRenderer = unmaskedRenderer || renderer; + const vendor = detectVendor(effectiveRenderer); + const isAmdPolarisOrVega = isProblematicAmdGpu(effectiveRenderer); + + cachedGpuInfo = { vendor, renderer, unmaskedRenderer, isAmdPolarisOrVega }; + return cachedGpuInfo; +} + +export interface HevcCodecDecision { + effectiveCodec: VideoCodec; + reason: string; + gpuInfo: GpuInfo; + wasOverridden: boolean; +} + +export function resolveHevcCompat( + requestedCodec: VideoCodec, + compatMode: HevcCompatMode, +): HevcCodecDecision { + const gpuInfo = detectGpu(); + + if (requestedCodec !== "H265") { + return { + effectiveCodec: requestedCodec, + reason: `Codec is ${requestedCodec}, HEVC compat not applicable`, + gpuInfo, + wasOverridden: false, + }; + } + + switch (compatMode) { + case "force_h264": + return { + effectiveCodec: "H264", + reason: "HEVC Compatibility Mode: Force H.264", + gpuInfo, + wasOverridden: true, + }; + + case "force_hevc": + return { + effectiveCodec: "H265", + reason: "HEVC Compatibility Mode: Force HEVC (user override)", + gpuInfo, + wasOverridden: false, + }; + + case "hevc_software": + return { + effectiveCodec: "H265", + reason: "HEVC Compatibility Mode: HEVC Software Decode requested", + gpuInfo, + wasOverridden: false, + }; + + case "auto": + default: { + if (gpuInfo.isAmdPolarisOrVega) { + return { + effectiveCodec: "H264", + reason: + `HEVC auto-disabled: AMD Polaris/Vega GPU detected (${gpuInfo.unmaskedRenderer || gpuInfo.renderer}). ` + + `Known HEVC hardware decode issue (D3D11/DXVA green screen). Falling back to H.264.`, + gpuInfo, + wasOverridden: true, + }; + } + + return { + effectiveCodec: "H265", + reason: "HEVC Compatibility Mode: Auto — no problematic GPU detected", + gpuInfo, + wasOverridden: false, + }; + } + } +} + +export function shouldRequestSoftwareDecode(compatMode: HevcCompatMode, codec: VideoCodec): boolean { + return compatMode === "hevc_software" && codec === "H265"; +} diff --git a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts index 3911ba3e..d5ae1ac6 100644 --- a/opennow-stable/src/renderer/src/gfn/webrtcClient.ts +++ b/opennow-stable/src/renderer/src/gfn/webrtcClient.ts @@ -5,7 +5,9 @@ import type { SessionInfo, VideoCodec, MicrophoneMode, -} from "@shared/gfn"; + FlightGamepadState, + HdrStreamState, + HevcCompatMode,} from "@shared/gfn"; import { InputEncoder, @@ -32,13 +34,16 @@ import { } from "./sdp"; import { MicrophoneManager, type MicState, type MicStateChange } from "./microphoneManager"; +import type { MicAudioService } from "./micAudioService"; +import { resolveHevcCompat, shouldRequestSoftwareDecode, detectGpu } from "./gpuDetect"; interface OfferSettings { codec: VideoCodec; colorQuality: ColorQuality; resolution: string; fps: number; maxBitrateKbps: number; -} + hdrEnabled: boolean; + hevcCompatMode: HevcCompatMode;} interface KeyStrokeSpec { vk: number; @@ -519,6 +524,10 @@ export class GfnWebRtcClient { private currentResolution = ""; private isHdr = false; private videoDecodeStallWarningSent = false; + private greenScreenFrameCount = 0; + private greenScreenWarningSent = false; + private greenScreenCheckCanvas: HTMLCanvasElement | null = null; + private greenScreenCheckCtx: CanvasRenderingContext2D | null = null; private serverRegion = ""; private gpuType = ""; @@ -646,6 +655,66 @@ export class GfnWebRtcClient { this.options.onLog(message); } + private checkForGreenScreen(): void { + if (this.greenScreenWarningSent) return; + const video = this.options.videoElement; + if (!video || video.videoWidth === 0 || video.videoHeight === 0) return; + if (this.currentCodec !== "H265") return; + + try { + if (!this.greenScreenCheckCanvas) { + this.greenScreenCheckCanvas = document.createElement("canvas"); + this.greenScreenCheckCanvas.width = 16; + this.greenScreenCheckCanvas.height = 16; + this.greenScreenCheckCtx = this.greenScreenCheckCanvas.getContext("2d", { willReadFrequently: true }); + } + + const ctx = this.greenScreenCheckCtx; + if (!ctx) return; + + ctx.drawImage(video, 0, 0, 16, 16); + const imageData = ctx.getImageData(0, 0, 16, 16); + const pixels = imageData.data; + + let greenDominantPixels = 0; + const totalPixels = 16 * 16; + + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + if (g > 100 && g > r * 1.8 && g > b * 1.8) { + greenDominantPixels++; + } + } + + const greenRatio = greenDominantPixels / totalPixels; + + if (greenRatio > 0.85) { + this.greenScreenFrameCount++; + if (this.greenScreenFrameCount >= 10) { + this.greenScreenWarningSent = true; + const gpuInfo = detectGpu(); + this.log( + `[HEVC Compat] WARNING: Possible HEVC decode failure detected — ` + + `${this.greenScreenFrameCount} consecutive green-dominant frames. ` + + `GPU: ${gpuInfo.unmaskedRenderer || gpuInfo.renderer}, vendor=${gpuInfo.vendor}, ` + + `isAmdPolarisOrVega=${gpuInfo.isAmdPolarisOrVega}, codec=${this.currentCodec}`, + ); + console.warn( + `[OpenNOW] Possible HEVC decode failure on AMD GPU. ` + + `${this.greenScreenFrameCount} green frames detected. ` + + `Consider switching to H.264 in Settings → Video → HEVC Compatibility Mode.`, + ); + } + } else { + this.greenScreenFrameCount = 0; + } + } catch { + // Canvas sampling failed, skip + } + } + private emitStats(): void { if (this.options.onStats) { this.options.onStats({ ...this.diagnostics }); @@ -658,6 +727,8 @@ export class GfnWebRtcClient { this.currentResolution = ""; this.isHdr = false; this.videoDecodeStallWarningSent = false; + this.greenScreenFrameCount = 0; + this.greenScreenWarningSent = false; this.diagnostics = { connectionState: this.pc?.connectionState ?? "closed", inputReady: false, @@ -959,7 +1030,23 @@ export class GfnWebRtcClient { this.inputQueuePeakBufferedBytesWindow = reliableBufferedAmount; this.inputQueueMaxSchedulingDelayMsWindow = 0; - this.emitStats(); + for (const entry of report.values()) { + const stats = entry as unknown as Record; + if (entry.type === "outbound-rtp" && stats.kind === "audio") { + const bytesSent = Number(stats.bytesSent ?? 0); + const packetsSent = Number(stats.packetsSent ?? 0); + const mid = String(stats.mid ?? ""); + const prevBytes = this.diagnostics.micBytesSent; + this.diagnostics.micBytesSent = bytesSent; + this.diagnostics.micPacketsSent = packetsSent; + if (bytesSent > 0 && bytesSent !== prevBytes) { + this.log(`Outbound audio RTP: mid=${mid} bytesSent=${bytesSent} packetsSent=${packetsSent}`); + } + break; + } + } + + this.checkForGreenScreen(); this.emitStats(); } private detachInputCapture(): void { @@ -2578,7 +2665,40 @@ export class GfnWebRtcClient { this.log(`Browser supported video codecs: ${supported.join(", ") || "unknown"}`); if (settings.codec === "H265") { - const hevcProfiles = this.getSupportedHevcProfiles(); + // 3a. HEVC Compatibility Mode — detect AMD Polaris/Vega GPU and fallback if needed + const hevcCompat = resolveHevcCompat(effectiveCodec, settings.hevcCompatMode); + this.log( + `[HEVC Compat] mode=${settings.hevcCompatMode}, vendor=${hevcCompat.gpuInfo.vendor}, ` + + `renderer="${hevcCompat.gpuInfo.unmaskedRenderer || hevcCompat.gpuInfo.renderer}", ` + + `isAmdPolarisOrVega=${hevcCompat.gpuInfo.isAmdPolarisOrVega}, ` + + `decision=${hevcCompat.effectiveCodec}, overridden=${hevcCompat.wasOverridden}`, + ); + this.log(`[HEVC Compat] reason: ${hevcCompat.reason}`); + if (hevcCompat.wasOverridden) { + effectiveCodec = hevcCompat.effectiveCodec; + this.log(`[HEVC Compat] Codec overridden: ${settings.codec} → ${effectiveCodec}`); + } + + const useSoftwareDecode = shouldRequestSoftwareDecode(settings.hevcCompatMode, effectiveCodec); + if (useSoftwareDecode) { + this.log("[HEVC Compat] Software decode requested for HEVC"); + } + + // HDR requires a 10-bit capable codec. H264 cannot carry 10-bit HDR. + // Upgrade to H265 (or AV1) when HDR is enabled and user selected H264. + if (settings.hdrEnabled && effectiveCodec === "H264") { + if (supported.includes("H265")) { + effectiveCodec = "H265"; + this.log("HDR: Upgraded codec H264 → H265 (H264 cannot carry 10-bit HDR)"); + } else if (supported.includes("AV1")) { + effectiveCodec = "AV1"; + this.log("HDR: Upgraded codec H264 → AV1 (H264 cannot carry 10-bit HDR, H265 unsupported)"); + } else { + this.log("HDR: Warning — H264 selected but no 10-bit capable codec available; HDR may not activate"); + } + } + + if (effectiveCodec === "H265") { const hevcProfiles = this.getSupportedHevcProfiles(); if (hevcProfiles.size > 0) { this.log(`Browser HEVC profile-id support: ${Array.from(hevcProfiles).join(", ")}`); } @@ -2779,7 +2899,8 @@ export class GfnWebRtcClient { this.micManager.dispose(); this.micManager = null; } - + this.greenScreenCheckCanvas = null; + this.greenScreenCheckCtx = null; for (const track of this.videoStream.getTracks()) { this.videoStream.removeTrack(track); } diff --git a/opennow-stable/src/shared/gfn.ts b/opennow-stable/src/shared/gfn.ts index eaa823ba..f9dc0a6a 100644 --- a/opennow-stable/src/shared/gfn.ts +++ b/opennow-stable/src/shared/gfn.ts @@ -1,7 +1,54 @@ export type VideoCodec = "H264" | "H265" | "AV1"; export type VideoAccelerationPreference = "auto" | "hardware" | "software"; -/** Color quality (bit depth + chroma subsampling), matching Rust ColorQuality enum */ +export type HdrStreamingMode = "off" | "auto" | "on"; + +export type MicMode = "off" | "on" | "push-to-talk"; + +export type HevcCompatMode = "auto" | "force_h264" | "force_hevc" | "hevc_software"; + +export interface MicSettings { + micMode: MicMode; + micDeviceId: string; + micGain: number; + micNoiseSuppression: boolean; + micAutoGainControl: boolean; + micEchoCancellation: boolean; + shortcutToggleMic: string; +} + +export interface MicDeviceInfo { + deviceId: string; + label: string; + isDefault: boolean; +} + +export type MicStatus = "off" | "active" | "muted" | "no-device" | "permission-denied" | "error"; + +export type HdrPlatformSupport = "supported" | "best_effort" | "unsupported" | "unknown"; + +export type HdrActiveStatus = "active" | "inactive" | "unsupported" | "fallback_sdr"; + +export interface HdrCapability { + platform: "windows" | "macos" | "linux" | "unknown"; + platformSupport: HdrPlatformSupport; + osHdrEnabled: boolean; + displayHdrCapable: boolean; + decoder10BitCapable: boolean; + hdrColorSpaceSupported: boolean; + notes: string[]; +} + +export interface HdrStreamState { + status: HdrActiveStatus; + bitDepth: 8 | 10; + colorPrimaries: "BT.709" | "BT.2020" | "unknown"; + transferFunction: "SDR" | "PQ" | "HLG" | "unknown"; + matrixCoefficients: "BT.709" | "BT.2020" | "unknown"; + codecProfile: string; + overlayForcesSdr: boolean; + fallbackReason: string | null; +}/** Color quality (bit depth + chroma subsampling), matching Rust ColorQuality enum */ export type ColorQuality = "8bit_420" | "8bit_444" | "10bit_420" | "10bit_444"; /** Helper: get CloudMatch bitDepth value (0 = 8-bit SDR, 10 = 10-bit HDR capable) */ @@ -49,7 +96,20 @@ export interface Settings { sessionClockShowDurationSeconds: number; windowWidth: number; windowHeight: number; -} + discordPresenceEnabled: boolean; + discordClientId: string; + flightControlsEnabled: boolean; + flightControlsSlot: number; + flightSlots: FlightSlotConfig[]; + hdrStreaming: HdrStreamingMode; + micMode: MicMode; + micDeviceId: string; + micGain: number; + micNoiseSuppression: boolean; + micAutoGainControl: boolean; + micEchoCancellation: boolean; + shortcutToggleMic: string; + hevcCompatMode: HevcCompatMode;} export interface LoginProvider { idpId: string;