diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c600a0b..07dd2f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,5 +56,8 @@ jobs: - name: Build run: npm run build + - name: Test stream color negotiation + run: npm run test:stream-color + - name: Test UI run: npm run test:ui diff --git a/docs/verification/stream-color/sdr-diagnostics.png b/docs/verification/stream-color/sdr-diagnostics.png new file mode 100644 index 0000000..853dd22 Binary files /dev/null and b/docs/verification/stream-color/sdr-diagnostics.png differ diff --git a/package.json b/package.json index 8b6f3b6..0bb0560 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dist": "npm run build && electron-builder", "lint": "eslint .", "test": "node --import tsx --test electron/*.test.ts", + "test:stream-color": "tsx tools/stream-color.test.ts", "test:ui": "node tools/ui-smoke.mjs", "preview": "vite preview", "start": "electron build/electron/electron/main.js", diff --git a/src/pages/StreamPage.tsx b/src/pages/StreamPage.tsx index 7bb2b62..74db2f0 100644 --- a/src/pages/StreamPage.tsx +++ b/src/pages/StreamPage.tsx @@ -355,6 +355,10 @@ function TopStatus({ function StatsPanel({ stats, maxBitrate }: { stats: StreamRealtimeStats | null; maxBitrate: number }) { const bitrate = stats?.bitrate ?? 0; const bitratePercent = Math.min(100, Math.round((bitrate / Math.max(maxBitrate * 1_000_000, 1)) * 100)); + const colorSpace = stats?.colorSpace; + const colorDescription = colorSpace + ? `${colorSpace.primaries ?? 'unknown'} / ${colorSpace.transfer ?? 'unknown'} / ${colorSpace.matrix ?? 'unknown'} / ${colorSpace.fullRange === null ? 'unknown range' : colorSpace.fullRange ? 'full' : 'limited'}` + : 'Waiting for frame metadata'; return ( Packet loss {stats?.packetLoss ?? 0}% + + Color mode + {stats?.colorMode ?? 'SDR'} + + {colorDescription} {stats?.gatewayHost || 'Waiting for gateway'} diff --git a/src/stream/OpenStroidStreamClient.ts b/src/stream/OpenStroidStreamClient.ts index 696e39b..2c3d9d2 100644 --- a/src/stream/OpenStroidStreamClient.ts +++ b/src/stream/OpenStroidStreamClient.ts @@ -66,11 +66,19 @@ interface StreamRuntimeSettings { encoding: StreamEncodingPreset; fsrEnabled: boolean; microphoneEnabled: boolean; - hdrEnabled: boolean; fillerEnabled: boolean; quality: StreamQualityPreset; } +interface GatewayStatusParamsInput { + maxFramerate: number; + maxBitrate: number; + cursorZip: boolean; + filler: boolean; + networkType: string; + codec: StreamVideoCodec; +} + interface VideoSurfaceMetrics { left: number; top: number; @@ -175,6 +183,32 @@ function connectionType() { return connection?.effectiveType ?? 'unknown'; } +export function buildGatewayStatusParams({ + maxFramerate, + maxBitrate, + cursorZip, + filler, + networkType, + codec, +}: GatewayStatusParamsInput) { + return { + type: 'web', + ver: 'openstroid', + gpu: 'unknown', + proto: 1, + framerate_max: maxFramerate, + bitrate_max: maxBitrate, + hdr: false, + cursor_zip: cursorZip, + filler, + beta: 0, + rtcEngine: 'webrtc', + rtcAudio: 'pcm', + network_type: networkType, + ...(codec === 'av1' ? { codec: 'av1' } : {}), + }; +} + let av1SupportPromise: Promise | null = null; async function supportsAv1Decoding() { @@ -430,6 +464,7 @@ export class OpenStroidStreamClient { private preferredCodec: StreamEncodingPreset = 'h264'; private activeCodec: StreamVideoCodec = 'h264'; private gatewayCodec = ''; + private decodedColorSpace: StreamRealtimeStats['colorSpace']; private gateways: unknown[] = []; private remoteIceTimer: number | null = null; private remoteIcePollingGeneration = 0; @@ -485,7 +520,6 @@ export class OpenStroidStreamClient { encoding: 'h264', fsrEnabled: false, microphoneEnabled: false, - hdrEnabled: false, fillerEnabled: false, quality: 'auto', }; @@ -777,12 +811,12 @@ export class OpenStroidStreamClient { ? message.value as Record : {}; if (typeof value.codec === 'string') this.gatewayCodec = value.codec; - if (typeof value.hdr === 'boolean') this.runtimeSettings.hdrEnabled = value.hdr; if (typeof value.framerate === 'number') { this.runtimeSettings.maxFramerate = value.framerate >= 120 ? 120 : 60; } if (typeof value.fsr === 'boolean') this.runtimeSettings.fsrEnabled = value.fsr; - this.log(`Gateway status updated codec=${this.gatewayCodec || 'unknown'} fps=${this.runtimeSettings.maxFramerate}`); + const gatewayHdr = typeof value.hdr === 'boolean' ? value.hdr : false; + this.log(`Gateway status updated codec=${this.gatewayCodec || 'unknown'} fps=${this.runtimeSettings.maxFramerate} gatewayHdr=${gatewayHdr} clientColorMode=SDR`); return; } @@ -1674,6 +1708,7 @@ export class OpenStroidStreamClient { this.invalidateVideoSurfaceMetrics(); void this.videoElement.play().then(() => { this.log(`Video playback started readyState=${this.videoElement.readyState}`); + this.inspectDecodedColorSpace(); }).catch((error: unknown) => { this.log(`Video play failed: ${error instanceof Error ? error.message : String(error)}`); }); @@ -1762,6 +1797,30 @@ export class OpenStroidStreamClient { .replace(/a=extmap:\d+ urn:3gpp:video-orientation\r\n/g, ''); } + private inspectDecodedColorSpace() { + this.decodedColorSpace = undefined; + if (!('VideoFrame' in window)) { + this.log('Decoded color metadata unavailable; negotiated clientColorMode=SDR'); + return; + } + + try { + const frame = new VideoFrame(this.videoElement); + const colorSpace = frame.colorSpace; + this.decodedColorSpace = { + primaries: colorSpace.primaries, + transfer: colorSpace.transfer, + matrix: colorSpace.matrix, + fullRange: colorSpace.fullRange, + }; + const format = frame.format; + frame.close(); + this.log(`Decoded video format=${format ?? 'unknown'} colorPrimaries=${colorSpace.primaries ?? 'unknown'} transfer=${colorSpace.transfer ?? 'unknown'} matrix=${colorSpace.matrix ?? 'unknown'} range=${colorSpace.fullRange === null ? 'unknown' : colorSpace.fullRange ? 'full' : 'limited'} clientColorMode=SDR`); + } catch (error) { + this.log(`Decoded color metadata unavailable: ${error instanceof Error ? error.message : String(error)}; negotiated clientColorMode=SDR`); + } + } + private async fetchGatewayCodec() { const url = `${this.webrtcApiBase}/api/getParams?sessionId=${encodeURIComponent(this.sessionId)}`; try { @@ -1785,22 +1844,14 @@ export class OpenStroidStreamClient { type: 'stream', action: 'status', value: 'ok', - params: { - type: 'web', - ver: 'openstroid', - gpu: 'unknown', - proto: 1, - framerate_max: maxFramerate, - bitrate_max: maxBitrate, - hdr: this.runtimeSettings.hdrEnabled, - cursor_zip: 'CompressionStream' in window, + params: buildGatewayStatusParams({ + maxFramerate, + maxBitrate, + cursorZip: 'CompressionStream' in window, filler: this.runtimeSettings.fillerEnabled, - beta: 0, - rtcEngine: 'webrtc', - rtcAudio: 'pcm', - network_type: connectionType(), - ...(this.activeCodec === 'av1' ? { codec: 'av1' } : {}), - }, + networkType: connectionType(), + codec: this.activeCodec, + }), }); this.sendEvent({ type: 'stream', action: 'refreshRate', value: maxFramerate }); if (this.runtimeSettings.fsrEnabled) { @@ -1973,6 +2024,8 @@ export class OpenStroidStreamClient { connectionState: this.pc?.connectionState ?? 'unknown', gatewayHost: this.gatewayHost, codec: this.gatewayCodec || this.activeCodec, + colorMode: 'SDR', + colorSpace: this.decodedColorSpace, at: Date.now(), }); this.statsPrev = { timestamp: report.timestamp, bytesReceived, framesDecoded, framesReceived, packetsReceived, packetsLost }; diff --git a/src/types/index.ts b/src/types/index.ts index c2ce1c3..4824028 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -97,6 +97,13 @@ export interface StreamRealtimeStats { connectionState: RTCPeerConnectionState | 'unknown'; gatewayHost: string; codec?: string; + colorMode: 'SDR'; + colorSpace?: { + primaries: string | null; + transfer: string | null; + matrix: string | null; + fullRange: boolean | null; + }; at: number; } diff --git a/tools/stream-color-preview.mjs b/tools/stream-color-preview.mjs new file mode 100644 index 0000000..1de3941 --- /dev/null +++ b/tools/stream-color-preview.mjs @@ -0,0 +1,76 @@ +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; + +const origin = 'http://127.0.0.1:4173'; +const projectRoot = fileURLToPath(new URL('..', import.meta.url)); +const viteCli = fileURLToPath(new URL('../node_modules/vite/bin/vite.js', import.meta.url)); +const outputPath = fileURLToPath(new URL('../docs/verification/stream-color/sdr-diagnostics.png', import.meta.url)); +const server = spawn(process.execPath, [viteCli, 'preview', '--host', '127.0.0.1', '--port', '4173'], { + cwd: projectRoot, + stdio: 'ignore', +}); + +for (let attempt = 0; attempt < 100; attempt += 1) { + try { + if ((await fetch(origin)).ok) break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} + +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + await page.addInitScript(() => { + window.localStorage.setItem('stream_stats_visible', 'true'); + window.sessionStorage.setItem('openstroid:lastLaunch', JSON.stringify({ + appId: 1091, + app: { name: 'Cyberpunk 2077 — SDR diagnostics' }, + sessionId: 'color-verification', + streamingUrl: 'https://example.invalid', + gateways: ['gateway.example.invalid'], + streamClientConfig: { + homeUrl: 'https://example.invalid', + sessionId: 'color-verification', + sessionQueries: ['sessionId=color-verification&token=verification'], + gateways: ['gateway.example.invalid'], + accessToken: '', + authDataToken: '', + }, + localStorage: {}, + cookies: [], + startPayload: {}, + })); + }); + await page.route('wss://gateway.example.invalid/**', (route) => route.abort()); + await page.goto(`${origin}/stream`); + await page.waitForTimeout(750); + await page.evaluate(() => { + const video = document.querySelector('video'); + if (!video) return; + video.poster = `data:image/svg+xml,${encodeURIComponent(` + + + + + + + + + + + + SDR color pipeline + BT.709 · limited range · client HDR off + + COLOR CHECK + `)} `; + }); + await page.screenshot({ path: outputPath }); +} finally { + await browser.close(); + server.kill('SIGTERM'); +} + +console.log(outputPath); diff --git a/tools/stream-color.test.ts b/tools/stream-color.test.ts new file mode 100644 index 0000000..9b07b81 --- /dev/null +++ b/tools/stream-color.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { buildGatewayStatusParams } from '../src/stream/OpenStroidStreamClient.ts'; + +const h264 = buildGatewayStatusParams({ + maxFramerate: 60, + maxBitrate: 20_000_000, + cursorZip: true, + filler: false, + networkType: '4g', + codec: 'h264', +}); + +assert.equal(h264.hdr, false, 'The WebRTC client must not request HDR from the gateway'); +assert.equal('codec' in h264, false, 'H.264 remains the default codec without an explicit override'); + +const av1 = buildGatewayStatusParams({ + maxFramerate: 120, + maxBitrate: 50_000_000, + cursorZip: false, + filler: true, + networkType: 'unknown', + codec: 'av1', +}); + +assert.equal(av1.hdr, false, 'AV1 profile 0 is also negotiated as SDR'); +assert.equal(av1.codec, 'av1'); +assert.equal(av1.framerate_max, 120); +assert.equal(av1.bitrate_max, 50_000_000); + +console.log('Stream color negotiation checks passed.');