Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -17,6 +17,7 @@
"build": "tsc -b && tsc -p tsconfig.server.json && tsc -p tsconfig.electron.json && vite build",
"dist": "npm run build && electron-builder",
"lint": "eslint .",
"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
90 changes: 71 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,29 @@ export class OpenStroidStreamClient {
.replace(/a=extmap:\d+ urn:3gpp:video-orientation\r\n/g, '');
}

private inspectDecodedColorSpace() {
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 +1843,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 +2023,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