Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions src/pages/StreamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Paper
Expand Down Expand Up @@ -384,6 +388,11 @@ function StatsPanel({ stats, maxBitrate }: { stats: StreamRealtimeStats | null;
<Text size="xs" c="dimmed">Packet loss</Text>
<Text size="xs" fw={700}>{stats?.packetLoss ?? 0}%</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">Color mode</Text>
<Text size="xs" fw={700}>{stats?.colorMode ?? 'SDR'}</Text>
</Group>
<Text size="xs" c="dimmed" lineClamp={1}>{colorDescription}</Text>
<Text size="xs" c="dimmed" lineClamp={1}>{stats?.gatewayHost || 'Waiting for gateway'}</Text>
</Stack>
</Paper>
Expand Down
91 changes: 72 additions & 19 deletions src/stream/OpenStroidStreamClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<boolean> | null = null;

async function supportsAv1Decoding() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -485,7 +520,6 @@ export class OpenStroidStreamClient {
encoding: 'h264',
fsrEnabled: false,
microphoneEnabled: false,
hdrEnabled: false,
fillerEnabled: false,
quality: 'auto',
};
Expand Down Expand Up @@ -777,12 +811,12 @@ export class OpenStroidStreamClient {
? message.value as Record<string, unknown>
: {};
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;
}

Expand Down Expand Up @@ -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)}`);
});
Expand Down Expand Up @@ -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`);
}
}
Comment thread
capy-ai[bot] marked this conversation as resolved.

private async fetchGatewayCodec() {
const url = `${this.webrtcApiBase}/api/getParams?sessionId=${encodeURIComponent(this.sessionId)}`;
try {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down
7 changes: 7 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
76 changes: 76 additions & 0 deletions tools/stream-color-preview.mjs
Original file line number Diff line number Diff line change
@@ -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(`
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="900" viewBox="0 0 1600 900">
<defs>
<linearGradient id="sky" x2="0" y2="1"><stop stop-color="#153051"/><stop offset="1" stop-color="#f1623f"/></linearGradient>
<linearGradient id="road" x2="1"><stop stop-color="#090b13"/><stop offset=".5" stop-color="#25283b"/><stop offset="1" stop-color="#0b0d16"/></linearGradient>
</defs>
<rect width="1600" height="900" fill="url(#sky)"/>
<circle cx="1210" cy="235" r="145" fill="#ffbe4c" opacity=".92"/>
<path d="M0 570 L430 320 760 570 1040 360 1600 570V900H0Z" fill="#101522"/>
<path d="M0 610H1600V900H0Z" fill="url(#road)"/>
<path d="M625 900L760 610H840L985 900Z" fill="#f4d55f" opacity=".78"/>
<rect x="55" y="58" width="480" height="170" rx="18" fill="#080a12" opacity=".82" stroke="#45f0df" stroke-width="3"/>
<text x="90" y="125" fill="#45f0df" font-family="sans-serif" font-size="34" font-weight="700">SDR color pipeline</text>
<text x="90" y="178" fill="white" font-family="sans-serif" font-size="26">BT.709 · limited range · client HDR off</text>
<rect x="1160" y="675" width="310" height="100" rx="12" fill="#f32961"/>
<text x="1210" y="738" fill="white" font-family="sans-serif" font-size="32" font-weight="700">COLOR CHECK</text>
</svg>`)} `;
});
await page.screenshot({ path: outputPath });
} finally {
await browser.close();
server.kill('SIGTERM');
}

console.log(outputPath);
30 changes: 30 additions & 0 deletions tools/stream-color.test.ts
Original file line number Diff line number Diff line change
@@ -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.');
Loading