fix(h264): convert 4:4:4 chroma in the stream's own colour space

The combining shader hardcoded BT.709 at full range, citing the coefficients
FreeRDP decodes these streams with. But in passthrough guacd never decodes --
the browser does, and the 4:2:0 path never reaches the shader at all: that
VideoFrame is drawn by the browser, which applies the frame's own colour
space including the limited-range expansion of 16-235 to 0-255.

So the two paths converted differently on the same session. An H.264 stream
carrying no VUI signalling is limited range, and treating it as full leaves
black at RGB 16 and white at 235, with chroma unscaled to match: the combined
4:4:4 picture renders visibly flatter than the 4:2:0 one beside it, depending
only on which codec the server chose.

Take the range and matrix from the decoded frame's colorSpace instead, read
once from the first main view, and default to limited range BT.709 when the
decoder reports nothing -- which is what the absence of signalling means.
VideoFrame.colorSpace was available in scope and never consulted.
This commit is contained in:
pletch
2026-09-08 01:49:08 -04:00
parent b3b2cce36e
commit 8988165159
2 changed files with 128 additions and 10 deletions
+27 -1
View File
@@ -257,6 +257,17 @@ Guacamole.H264Decoder = function H264Decoder(display) {
*/
var yuv444Unavailable = false;
/**
* Whether the renderer has been told this stream's colour space. Applied
* from the first main-view frame and not revisited: a decoder replaced
* mid-session re-runs this, but the stream's signalling does not change
* frame to frame, and reading it per frame would be pure overhead.
*
* @private
* @type {!boolean}
*/
var colorSpaceApplied = false;
/**
* Whether the current stream is being combined to 4:4:4. False until an
* auxiliary view actually arrives: an AVC420 stream has no second view to
@@ -353,6 +364,7 @@ Guacamole.H264Decoder = function H264Decoder(display) {
}
yuv444 = new Guacamole.Yuv444Renderer();
colorSpaceApplied = false;
if (!yuv444.supported) {
yuv444 = null;
@@ -549,8 +561,22 @@ Guacamole.H264Decoder = function H264Decoder(display) {
? [layout[0].stride, layout[1].stride]
: [layout[0].stride, layout[1].stride, layout[2].stride];
if (view === 0)
if (view === 0) {
/* Adopt whatever the decoder says this stream is, once. The
* 4:2:0 path never reaches the shader -- the browser draws
* that VideoFrame and applies its colour space itself -- so
* converting here on an assumption is how the two paths come
* out different colours on the same session. */
if (!colorSpaceApplied && renderer.setColorSpace) {
console.log('[rustguac] H.264: 4:4:4 colour space is '
+ renderer.setColorSpace(frame.colorSpace));
colorSpaceApplied = true;
}
renderer.uploadLuma(y, u, v, strides, pictureW, pictureH);
}
else
renderer.uploadAux(y, u, v, strides, planeW, planeH);
+101 -9
View File
@@ -182,6 +182,9 @@ Guacamole.Yuv444Renderer = function Yuv444Renderer() {
'uniform int uLayout;', /* 0 = 4:2:0 only, 1 = chroma v1, 2 = chroma v2 */
'uniform int uInterleaved;',/* bit 0: main view is NV12, bit 1: auxiliary is */
'uniform float uFilter;', /* recovery threshold, or 0 to leave the mean alone */
'uniform vec2 uRange;', /* luma offset, luma scale */
'uniform float uCScale;', /* chroma scale */
'uniform vec4 uCoef;', /* R:v, G:u, G:v, B:u */
'out vec4 fragColor;',
@@ -331,17 +334,25 @@ Guacamole.Yuv444Renderer = function Yuv444Renderer() {
' V = unfilter(V, vR + vD + vRD);',
' }',
/* YUV to RGB. BT.709 at full range, matching the coefficients FreeRDP
* decodes these streams with (prim_internal.h: 403, 475, 48, 120 over
* 256, with no 16 offset on Y). Using limited-range or BT.601 here
* would tint the whole session. */
' float u = U - 0.50196078;', /* 128/255 */
' float v = V - 0.50196078;',
/* YUV to RGB, with the matrix and range the decoder reported for
* this stream rather than an assumed pair.
*
* This matters because the 4:2:0 path does not come through here: the
* browser draws that VideoFrame itself and applies the frame's own
* colour space, including the limited-range expansion of 16-235 to
* 0-255. Converting here as though the same samples were full range
* leaves blacks at 16 and whites at 235, so the combined 4:4:4 picture
* renders visibly flatter than the 4:2:0 one beside it -- the same
* session, two different colours, depending only on which codec the
* server happened to choose. setColorSpace() supplies these. */
' float y = (Y - uRange.x) * uRange.y;',
' float u = (U - 0.50196078) * uCScale;', /* 128/255 */
' float v = (V - 0.50196078) * uCScale;',
' vec3 rgb = vec3(',
' Y + 1.57421875 * v,',
' Y - 0.18750000 * u - 0.46875000 * v,',
' Y + 1.85546875 * u);',
' y + uCoef.x * v,',
' y - uCoef.y * u - uCoef.z * v,',
' y + uCoef.w * u);',
' fragColor = vec4(clamp(rgb, 0.0, 1.0), 1.0);',
'}'
@@ -413,6 +424,9 @@ Guacamole.Yuv444Renderer = function Yuv444Renderer() {
uniforms.auxSize = gl.getUniformLocation(program, 'uAuxSize');
uniforms.layout = gl.getUniformLocation(program, 'uLayout');
uniforms.filter = gl.getUniformLocation(program, 'uFilter');
uniforms.range = gl.getUniformLocation(program, 'uRange');
uniforms.cScale = gl.getUniformLocation(program, 'uCScale');
uniforms.coef = gl.getUniformLocation(program, 'uCoef');
uniforms.interleaved = gl.getUniformLocation(program,
'uInterleaved');
@@ -595,6 +609,80 @@ Guacamole.Yuv444Renderer = function Yuv444Renderer() {
* The canvas holding the rendered image, or null if this renderer is
* not usable.
*/
/**
* The conversion currently in effect. Defaults to BT.709 at limited range,
* which is what an H.264 stream carrying no VUI signalling means, and what
* xrdp and Windows both send in practice.
*
* @private
*/
var colorSpace = conversionFor(false, 'bt709');
/**
* Returns the luma offset, scales and matrix coefficients for a decoded
* frame's colour space.
*
* Range is the half of this that shows: limited range carries black at 16
* and white at 235, so treating it as full range renders the whole picture
* with lifted blacks and dulled whites. The matrix mostly shifts hue in
* saturated regions, and only the two common ones are distinguished.
*
* @private
*
* @param {!boolean} fullRange
* Whether the samples span 0-255 rather than 16-235.
*
* @param {String} matrix
* The VideoColorSpace matrix name, if the decoder reported one.
*
* @returns {!Object}
* The conversion, as consumed by render().
*/
function conversionFor(fullRange, matrix) {
/* BT.601 for the standard-definition matrices, BT.709 otherwise --
* including when nothing was reported, since these are desktop
* streams. */
var sd = (matrix === 'smpte170m' || matrix === 'bt470bg');
return {
yOffset : fullRange ? 0.0 : 16.0 / 255.0,
yScale : fullRange ? 1.0 : 255.0 / 219.0,
cScale : fullRange ? 1.0 : 255.0 / 224.0,
coef : sd
? [1.402, 0.344136, 0.714136, 1.772]
: [1.5748, 0.187324, 0.468124, 1.8556],
describe: (fullRange ? 'full' : 'limited') + ' range, '
+ (sd ? 'BT.601' : 'BT.709')
};
}
/**
* Adopts the colour space of a decoded frame, so the combined picture
* matches what the browser draws for the 4:2:0 path.
*
* A frame whose colour space is absent or only partly populated keeps the
* current conversion for the unreported parts: guessing full range on a
* stream that never said so is the error this exists to avoid.
*
* @param {VideoColorSpace} reported
* The colorSpace of a decoded VideoFrame, if any.
*
* @returns {!String}
* A description of the conversion now in effect.
*/
this.setColorSpace = function setColorSpace(reported) {
var fullRange = (reported && reported.fullRange !== null
&& reported.fullRange !== undefined)
? !!reported.fullRange : false;
colorSpace = conversionFor(fullRange, reported && reported.matrix);
return colorSpace.describe;
};
this.render = function render(layout, filter) {
if (!renderer.supported || gl.isContextLost() || !width || !height)
@@ -607,6 +695,10 @@ Guacamole.Yuv444Renderer = function Yuv444Renderer() {
gl.uniform2i(uniforms.auxSize, auxWidth, auxHeight);
gl.uniform1i(uniforms.layout, layout);
gl.uniform1f(uniforms.filter, filter === false ? -1.0 : filter / 255.0);
gl.uniform2f(uniforms.range, colorSpace.yOffset, colorSpace.yScale);
gl.uniform1f(uniforms.cScale, colorSpace.cScale);
gl.uniform4f(uniforms.coef, colorSpace.coef[0], colorSpace.coef[1],
colorSpace.coef[2], colorSpace.coef[3]);
gl.uniform1i(uniforms.interleaved,
(lumaInterleaved ? 1 : 0) | (auxInterleaved ? 2 : 0));