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(`
+ `)} `;
+ });
+ 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.');