Skip to content

Commit 60da95c

Browse files
Viewport resolution scale parameter 2 (#759)
* Add ViewportResolutionScale configuration parameter * Add Docs for Viewport Resolution Scale in Frontend/Docs/Settings Panel.md * Address PR feedback: improve ViewportResolutionScale configuration * Harden ViewportResScale: round output, warn past encoder limit, rename constant - Round scaled dimensions to integers before sending Resolution.Width / Height commands to UE (non-integers were being JSON-stringified and handed to cvar parsing). - Warn via Logger when a scaled dimension exceeds the H.264 4096 limit so large viewports with high scales surface a clear encoding-failure hint. - Rename NumericParameters.ViewportResolutionScale -> ViewportResScale so the TS constant matches the URL-param / persisted key ('ViewportResScale'). - Add Config.hasNumericSetting and fall back to 1.0 in VideoPlayer when the setting is absent, so custom Config subclasses do not throw on every resize. - Add VideoPlayer Jest coverage for default, scaled, rounded, >4096-warn, within-limit, missing-setting-fallback, and flag-disabled paths. --------- Co-authored-by: Matthew.Cotton <matt@tensorworks.com.au>
1 parent 4dea601 commit 60da95c

6 files changed

Lines changed: 200 additions & 6 deletions

File tree

.changeset/cold-toys-arrive.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@epicgames-ps/lib-pixelstreamingfrontend-ui-ue5.7': minor
3+
'@epicgames-ps/lib-pixelstreamingfrontend-ue5.7': minor
4+
---
5+
6+
Added Viewport Resolution Scale parameter to request higher resolution streams on small screens

Frontend/Docs/Settings Panel.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ This page will be updated with new features and commands as they become availabl
3030
### UI
3131
| **Setting** | **Description** |
3232
| --- | --- |
33-
| **Match viewport resolution** | Resizes the Unreal Engine application resolution to match the browser's video element size.|
33+
| **Match viewport resolution** | Resizes the Unreal Engine application resolution to match the browser's video element size. (Note: We recommend using `-windowed` on the UE side to allow scaling beyond monitor size.)|
34+
| **Viewport Resolution Scale** | Scale factor for viewport resolution when Match Viewport Resolution is enabled. Range: 0.1-3.0, Default: 1.0 (no scaling). Values above 1.0 (e.g., 1.5, 2.0) can improve visual quality on small screens by requesting higher resolution streams. |
3435
| **Control scheme** | If the scheme is `locked mouse` the browser will use `pointerlock` to capture your mouse, whereas if the scheme is `hovering mouse` you will retain your OS/browser cursor. |
3536
| **Color scheme** | Allows you to switch between light mode and dark mode. |
3637

Frontend/library/src/Config/Config.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export class NumericParameters {
6565
static MaxReconnectAttempts = 'MaxReconnectAttempts' as const;
6666
static StreamerAutoJoinInterval = 'StreamerAutoJoinInterval' as const;
6767
static KeepaliveDelay = 'KeepaliveDelay' as const;
68+
static ViewportResScale = 'ViewportResScale' as const;
6869
}
6970

7071
export type NumericParametersKeys = Exclude<keyof typeof NumericParameters, 'prototype'>;
@@ -821,6 +822,21 @@ export class Config {
821822
useUrlParams
822823
)
823824
);
825+
826+
this.numericParameters.set(
827+
NumericParameters.ViewportResScale,
828+
new SettingNumber(
829+
NumericParameters.ViewportResScale,
830+
'Viewport Resolution Scale',
831+
'Scale factor for viewport resolution when MatchViewportResolution is enabled. 1.0 = 100%, 0.5 = 50%, 2.0 = 200%.',
832+
0.1 /*min*/,
833+
3.0 /*max*/,
834+
settings && Object.prototype.hasOwnProperty.call(settings, NumericParameters.ViewportResScale)
835+
? settings[NumericParameters.ViewportResScale]
836+
: 1.0 /*value*/,
837+
useUrlParams
838+
)
839+
);
824840
}
825841

826842
/**
@@ -858,6 +874,14 @@ export class Config {
858874
}
859875
}
860876

877+
/**
878+
* @param id The id of the numeric setting to check for.
879+
* @returns True if the numeric setting is registered in this Config.
880+
*/
881+
hasNumericSetting(id: NumericParametersIds): boolean {
882+
return this.numericParameters.has(id);
883+
}
884+
861885
/**
862886
* @param id The id of the text setting we are interested in getting a value for.
863887
* @returns The text value stored in the parameter with the passed id.
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Logger } from '@epicgames-ps/lib-pixelstreamingcommon-ue5.7';
2+
import { Config, Flags, NumericParameters } from '../Config/Config';
3+
import { mockRTCRtpReceiver, unmockRTCRtpReceiver } from '../__test__/mockRTCRtpReceiver';
4+
import { VideoPlayer } from './VideoPlayer';
5+
6+
/**
7+
* Tests for the ViewportResScale numeric parameter added to VideoPlayer.
8+
*
9+
* The callback onMatchViewportResolutionCallback is invoked with the scaled
10+
* viewport dimensions when MatchViewportResolution is enabled. We validate:
11+
* - default scale (1.0) leaves dimensions unchanged
12+
* - explicit scale multiplies both dimensions
13+
* - non-integer products are rounded to integers
14+
* - dimensions > 4096 emit a warning via Logger
15+
* - a Config missing the setting falls back to 1.0 instead of throwing
16+
*/
17+
describe('VideoPlayer.updateVideoStreamSize — ViewportResScale', () => {
18+
let parent: HTMLDivElement;
19+
let config: Config;
20+
let player: VideoPlayer;
21+
let callback: jest.Mock;
22+
23+
const setViewportSize = (w: number, h: number) => {
24+
Object.defineProperty(parent, 'clientWidth', { configurable: true, value: w });
25+
Object.defineProperty(parent, 'clientHeight', { configurable: true, value: h });
26+
};
27+
28+
beforeEach(() => {
29+
mockRTCRtpReceiver();
30+
parent = document.createElement('div');
31+
document.body.appendChild(parent);
32+
33+
config = new Config({ initialSettings: { [Flags.MatchViewportResolution]: true } });
34+
35+
player = new VideoPlayer(parent, config);
36+
callback = jest.fn();
37+
player.onMatchViewportResolutionCallback = callback;
38+
39+
// Bypass the 300ms throttle in updateVideoStreamSize.
40+
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
41+
});
42+
43+
afterEach(() => {
44+
player.destroy();
45+
parent.remove();
46+
unmockRTCRtpReceiver();
47+
jest.restoreAllMocks();
48+
});
49+
50+
it('passes viewport dimensions through unchanged when scale is 1.0 (default)', () => {
51+
setViewportSize(375, 667);
52+
player.updateVideoStreamSize();
53+
expect(callback).toHaveBeenCalledWith(375, 667);
54+
});
55+
56+
it('multiplies both dimensions by the configured scale', () => {
57+
config.setNumericSetting(NumericParameters.ViewportResScale, 2.0);
58+
setViewportSize(375, 667);
59+
60+
// lastTimeResized was updated on construction, reset again.
61+
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
62+
player.updateVideoStreamSize();
63+
64+
expect(callback).toHaveBeenCalledWith(750, 1334);
65+
});
66+
67+
it('rounds non-integer products to integers', () => {
68+
config.setNumericSetting(NumericParameters.ViewportResScale, 1.5);
69+
setViewportSize(375, 667);
70+
71+
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
72+
player.updateVideoStreamSize();
73+
74+
// 375 * 1.5 = 562.5 → 563, 667 * 1.5 = 1000.5 → 1001
75+
expect(callback).toHaveBeenCalledWith(563, 1001);
76+
const [w, h] = callback.mock.calls[0] as [number, number];
77+
expect(Number.isInteger(w)).toBe(true);
78+
expect(Number.isInteger(h)).toBe(true);
79+
});
80+
81+
it('logs a warning when scaled width or height exceeds 4096', () => {
82+
const warnSpy = jest.spyOn(Logger, 'Warning').mockImplementation(() => {});
83+
84+
config.setNumericSetting(NumericParameters.ViewportResScale, 3.0);
85+
setViewportSize(2000, 1000); // 2000*3 = 6000 > 4096
86+
87+
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
88+
player.updateVideoStreamSize();
89+
90+
expect(warnSpy).toHaveBeenCalledTimes(1);
91+
expect(warnSpy.mock.calls[0][0]).toContain('4096');
92+
expect(warnSpy.mock.calls[0][0]).toContain('6000');
93+
expect(callback).toHaveBeenCalledWith(6000, 3000);
94+
});
95+
96+
it('does not warn when scaled dimensions stay within the encoder limit', () => {
97+
const warnSpy = jest.spyOn(Logger, 'Warning').mockImplementation(() => {});
98+
99+
config.setNumericSetting(NumericParameters.ViewportResScale, 2.0);
100+
setViewportSize(1920, 1080); // 3840 x 2160, under 4096
101+
102+
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
103+
player.updateVideoStreamSize();
104+
105+
expect(warnSpy).not.toHaveBeenCalled();
106+
});
107+
108+
it('falls back to scale 1.0 when the setting is not registered on the Config', () => {
109+
const strippedConfig = new Config({ initialSettings: { [Flags.MatchViewportResolution]: true } });
110+
// Remove the registration to simulate a custom Config subclass that omits it.
111+
const params = (strippedConfig as unknown as { numericParameters: Map<string, unknown> })
112+
.numericParameters;
113+
params.delete(NumericParameters.ViewportResScale);
114+
115+
const strippedParent = document.createElement('div');
116+
document.body.appendChild(strippedParent);
117+
const strippedPlayer = new VideoPlayer(strippedParent, strippedConfig);
118+
const strippedCallback = jest.fn();
119+
strippedPlayer.onMatchViewportResolutionCallback = strippedCallback;
120+
121+
Object.defineProperty(strippedParent, 'clientWidth', { configurable: true, value: 500 });
122+
Object.defineProperty(strippedParent, 'clientHeight', { configurable: true, value: 400 });
123+
124+
(strippedPlayer as unknown as { lastTimeResized: number }).lastTimeResized = 0;
125+
expect(() => strippedPlayer.updateVideoStreamSize()).not.toThrow();
126+
expect(strippedCallback).toHaveBeenCalledWith(500, 400);
127+
128+
strippedPlayer.destroy();
129+
strippedParent.remove();
130+
});
131+
132+
it('does not invoke the callback when MatchViewportResolution is disabled', () => {
133+
config.setFlagEnabled(Flags.MatchViewportResolution, false);
134+
setViewportSize(375, 667);
135+
136+
(player as unknown as { lastTimeResized: number }).lastTimeResized = 0;
137+
player.updateVideoStreamSize();
138+
139+
expect(callback).not.toHaveBeenCalled();
140+
});
141+
});

Frontend/library/src/VideoPlayer/VideoPlayer.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright Epic Games, Inc. All Rights Reserved.
22

3-
import { Config, Flags } from '../Config/Config';
3+
import { Config, Flags, NumericParameters } from '../Config/Config';
44
import { Logger } from '@epicgames-ps/lib-pixelstreamingcommon-ue5.7';
55

66
/**
@@ -16,6 +16,9 @@ declare global {
1616
* The video player html element
1717
*/
1818
export class VideoPlayer {
19+
// Common H.264 maximum encoding dimension. Streams beyond this commonly fail to encode.
20+
private static readonly maxEncoderDimension = 4096;
21+
1922
private config: Config;
2023
private videoElement: HTMLVideoElement;
2124
private audioElement?: HTMLAudioElement;
@@ -222,10 +225,23 @@ export class VideoPlayer {
222225
return;
223226
}
224227

225-
this.onMatchViewportResolutionCallback(
226-
videoElementParent.clientWidth,
227-
videoElementParent.clientHeight
228-
);
228+
const viewportResolutionScale = this.config.hasNumericSetting(NumericParameters.ViewportResScale)
229+
? this.config.getNumericSettingValue(NumericParameters.ViewportResScale)
230+
: 1.0;
231+
232+
const scaledWidth = Math.round(videoElementParent.clientWidth * viewportResolutionScale);
233+
const scaledHeight = Math.round(videoElementParent.clientHeight * viewportResolutionScale);
234+
235+
if (
236+
scaledWidth > VideoPlayer.maxEncoderDimension ||
237+
scaledHeight > VideoPlayer.maxEncoderDimension
238+
) {
239+
Logger.Warning(
240+
`Requested stream resolution (${scaledWidth}x${scaledHeight}) exceeds the common H.264 encoder limit of ${VideoPlayer.maxEncoderDimension}x${VideoPlayer.maxEncoderDimension}; encoding may fail. Lower ViewportResScale or disable MatchViewportResolution.`
241+
);
242+
}
243+
244+
this.onMatchViewportResolutionCallback(scaledWidth, scaledHeight);
229245

230246
this.lastTimeResized = new Date().getTime();
231247
} else {

Frontend/ui-library/src/Config/ConfigUI.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ export class ConfigUI {
199199
if (isSettingEnabled(settingsConfig, Flags.MatchViewportResolution))
200200
this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.MatchViewportResolution));
201201

202+
if (isSettingEnabled(settingsConfig, NumericParameters.ViewportResScale))
203+
this.addSettingNumeric(
204+
viewSettingsSection,
205+
this.numericParametersUi.get(NumericParameters.ViewportResScale)
206+
);
207+
202208
if (isSettingEnabled(settingsConfig, Flags.HoveringMouseMode))
203209
this.addSettingFlag(viewSettingsSection, this.flagsUi.get(Flags.HoveringMouseMode));
204210

0 commit comments

Comments
 (0)