H.264 passthrough: guacd patch + WebCodecs browser decoder

Phase 1-3 of H.264 passthrough for premium RDP video performance.

guacamole-server patch (patches/004-h264-passthrough.patch):
- Enable GfxH264 and GfxAVC444 in FreeRDP settings when GFX is on
- Intercept AVC420/AVC444 SurfaceCommand before GDI decode
- Extract raw H.264 NAL units and send as new "h264" instruction
- Keyframe detection via Annex B start code + NAL type parsing
- Original GDI decode path still runs as fallback

Browser-side (static/guac/):
- H264Decoder.js: WebCodecs VideoDecoder, hardware-accelerated decode
- Client.js: "h264" instruction handler, base64→ArrayBuffer→decode
- Feature detection: falls back gracefully if WebCodecs unavailable

rustguac:
- Advertise video/h264 in guacd handshake
This commit is contained in:
Dave Kempe
2026-03-28 13:02:06 +11:00
parent b2892e404f
commit b013678e42
5 changed files with 441 additions and 1 deletions
+172
View File
@@ -0,0 +1,172 @@
diff --git a/src/protocols/rdp/channels/rdpgfx.c b/src/protocols/rdp/channels/rdpgfx.c
index 327e7c2b..260681f2 100644
--- a/src/protocols/rdp/channels/rdpgfx.c
+++ b/src/protocols/rdp/channels/rdpgfx.c
@@ -24,14 +24,120 @@
#include "settings.h"
#include <freerdp/client/rdpgfx.h>
+#include <freerdp/channels/rdpgfx.h>
#include <freerdp/freerdp.h>
#include <freerdp/gdi/gfx.h>
#include <freerdp/event.h>
#include <guacamole/client.h>
+#include <guacamole/protocol.h>
+#include <guacamole/socket.h>
+#include <guacamole/stream.h>
+#include <stdio.h>
#include <stdlib.h>
#include <string.h>
+/**
+ * The original SurfaceCommand handler installed by gdi_graphics_pipeline_init().
+ */
+static pcRdpgfxSurfaceCommand guac_rdpgfx_orig_surface_command = NULL;
+
+/**
+ * Check if a H.264 NAL unit buffer contains a keyframe (IDR slice).
+ */
+static int guac_rdp_h264_is_keyframe(const BYTE* data, UINT32 length) {
+ for (UINT32 i = 0; i + 3 < length; i++) {
+ if (data[i] == 0 && data[i+1] == 0) {
+ int offset = -1;
+ if (data[i+2] == 1)
+ offset = i + 3;
+ else if (i + 4 < length && data[i+2] == 0 && data[i+3] == 1)
+ offset = i + 4;
+ if (offset >= 0 && (UINT32) offset < length) {
+ int nal_type = data[offset] & 0x1F;
+ if (nal_type == 5 || nal_type == 7)
+ return 1;
+ }
+ }
+ }
+ return 0;
+}
+
+/**
+ * Send raw H.264 NAL units to the Guacamole client as a custom "h264"
+ * instruction followed by blob data and an end instruction.
+ */
+static void guac_rdp_h264_send_passthrough(guac_client* client,
+ const RDPGFX_SURFACE_COMMAND* cmd,
+ const BYTE* h264_data, UINT32 h264_length) {
+
+ guac_socket* socket = client->socket;
+ guac_stream* stream = guac_client_alloc_stream(client);
+ int keyframe = guac_rdp_h264_is_keyframe(h264_data, h264_length);
+
+ /* Build and send h264 instruction header */
+ char arg_bufs[7][16];
+ int vals[] = { stream->index, 0, keyframe,
+ (int) cmd->left, (int) cmd->top,
+ (int) cmd->width, (int) cmd->height };
+
+ guac_socket_instruction_begin(socket);
+ guac_socket_write_string(socket, "4.h264");
+ for (int i = 0; i < 7; i++) {
+ int n = snprintf(arg_bufs[i], sizeof(arg_bufs[i]), "%d", vals[i]);
+ char len_buf[8];
+ snprintf(len_buf, sizeof(len_buf), ",%d.", n);
+ guac_socket_write_string(socket, len_buf);
+ guac_socket_write_string(socket, arg_bufs[i]);
+ }
+ guac_socket_write_string(socket, ";");
+ guac_socket_instruction_end(socket);
+
+ guac_protocol_send_blobs(socket, stream, h264_data, h264_length);
+ guac_protocol_send_end(socket, stream);
+ guac_client_free_stream(client, stream);
+
+ guac_client_log(client, GUAC_LOG_TRACE,
+ "H.264 passthrough: %u bytes, %s, %ux%u at (%u,%u)",
+ h264_length, keyframe ? "keyframe" : "delta",
+ cmd->width, cmd->height, cmd->left, cmd->top);
+}
+
+/**
+ * Wrapper for the FreeRDP GDI SurfaceCommand callback. Intercepts AVC420
+ * and AVC444 commands to extract raw H.264 NAL units for passthrough.
+ */
+static UINT guac_rdp_gfx_surface_command(RdpgfxClientContext* context,
+ const RDPGFX_SURFACE_COMMAND* cmd) {
+
+ rdpContext* rdp_context = (rdpContext*) context->custom;
+ guac_client* client = ((rdp_freerdp_context*) rdp_context)->client;
+
+ if (cmd->codecId == RDPGFX_CODECID_AVC420 && cmd->extra != NULL) {
+ RDPGFX_AVC420_BITMAP_STREAM* avc420 =
+ (RDPGFX_AVC420_BITMAP_STREAM*) cmd->extra;
+ if (avc420->data != NULL && avc420->length > 0)
+ guac_rdp_h264_send_passthrough(client, cmd,
+ avc420->data, avc420->length);
+ }
+ else if ((cmd->codecId == RDPGFX_CODECID_AVC444
+ || cmd->codecId == RDPGFX_CODECID_AVC444v2)
+ && cmd->extra != NULL) {
+ RDPGFX_AVC444_BITMAP_STREAM* avc444 =
+ (RDPGFX_AVC444_BITMAP_STREAM*) cmd->extra;
+ if (avc444->bitstream[0].data != NULL
+ && avc444->bitstream[0].length > 0)
+ guac_rdp_h264_send_passthrough(client, cmd,
+ avc444->bitstream[0].data,
+ avc444->bitstream[0].length);
+ }
+
+ if (guac_rdpgfx_orig_surface_command != NULL)
+ return guac_rdpgfx_orig_surface_command(context, cmd);
+
+ return CHANNEL_RC_OK;
+}
+
/**
* Callback which associates handlers specific to Guacamole with the
* RdpgfxClientContext instance allocated by FreeRDP to deal with received
@@ -63,13 +169,22 @@ static void guac_rdp_rdpgfx_channel_connected(rdpContext* context,
RdpgfxClientContext* rdpgfx = (RdpgfxClientContext*) args->pInterface;
rdpGdi* gdi = context->gdi;
- if (!gdi_graphics_pipeline_init(gdi, rdpgfx))
+ if (!gdi_graphics_pipeline_init(gdi, rdpgfx)) {
guac_client_log(client, GUAC_LOG_WARNING, "Rendering backend for RDPGFX "
"channel could not be loaded. Graphics may not render at all!");
- else
+ }
+ else {
guac_client_log(client, GUAC_LOG_DEBUG, "RDPGFX channel will be used for "
"the RDP Graphics Pipeline Extension.");
+ /* Wrap SurfaceCommand to intercept H.264 data before GDI decode */
+ guac_rdpgfx_orig_surface_command = rdpgfx->SurfaceCommand;
+ rdpgfx->SurfaceCommand = guac_rdp_gfx_surface_command;
+
+ guac_client_log(client, GUAC_LOG_INFO,
+ "H.264 passthrough enabled for RDPGFX channel.");
+ }
+
}
/**
diff --git a/src/protocols/rdp/settings.c b/src/protocols/rdp/settings.c
index d97d98e1..bd11f6da 100644
--- a/src/protocols/rdp/settings.c
+++ b/src/protocols/rdp/settings.c
@@ -1619,6 +1619,8 @@ void guac_rdp_push_settings(guac_client* client,
freerdp_settings_set_bool(rdp_settings, FreeRDP_SupportGraphicsPipeline, TRUE);
freerdp_settings_set_bool(rdp_settings, FreeRDP_RemoteFxCodec, TRUE);
+ freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxH264, TRUE);
+ freerdp_settings_set_bool(rdp_settings, FreeRDP_GfxAVC444, TRUE);
if (freerdp_settings_get_uint32(rdp_settings, FreeRDP_ColorDepth) != RDP_GFX_REQUIRED_DEPTH) {
guac_client_log(client, GUAC_LOG_WARNING, "Ignoring requested "
@@ -1878,6 +1880,8 @@ void guac_rdp_push_settings(guac_client* client,
rdp_settings->SupportGraphicsPipeline = TRUE;
rdp_settings->RemoteFxCodec = TRUE;
+ rdp_settings->GfxH264 = TRUE;
+ rdp_settings->GfxAVC444 = TRUE;
if (rdp_settings->ColorDepth != RDP_GFX_REQUIRED_DEPTH) {
guac_client_log(client, GUAC_LOG_WARNING, "Ignoring requested "
+1 -1
View File
@@ -439,7 +439,7 @@ async fn send_handshake(
vec![width.to_string(), height.to_string(), dpi.to_string()],
),
Instruction::new("audio", vec!["audio/L16".into(), "audio/L8".into()]),
Instruction::new("video", vec![]),
Instruction::new("video", vec!["video/h264".into()]),
Instruction::new(
"image",
vec!["image/png".into(), "image/jpeg".into(), "image/webp".into()],
+1
View File
@@ -101,6 +101,7 @@
<script src="/guac/AudioPlayer.js"></script>
<script src="/guac/AudioRecorder.js"></script>
<script src="/guac/VideoPlayer.js"></script>
<script src="/guac/H264Decoder.js"></script>
<script src="/guac/JSONReader.js"></script>
<script src="/guac/Object.js"></script>
<script src="/guac/RawAudioFormat.js"></script>
+57
View File
@@ -1431,6 +1431,63 @@ Guacamole.Client = function(tunnel) {
},
"h264": function(parameters) {
var stream_index = parseInt(parameters[0]);
var layer = getLayer(parseInt(parameters[1]));
var isKeyFrame = parseInt(parameters[2]) !== 0;
var x = parseInt(parameters[3]);
var y = parseInt(parameters[4]);
var width = parseInt(parameters[5]);
var height = parseInt(parameters[6]);
// Create stream to receive H.264 NAL unit data
var stream = streams[stream_index] = new Guacamole.InputStream(guac_client, stream_index);
// Check for WebCodecs support
if (!Guacamole.H264Decoder.isSupported()) {
// No WebCodecs: acknowledge stream but discard data
stream.onblob = function() {};
stream.onend = function() {};
guac_client.sendAck(stream_index, "OK", 0x0000);
return;
}
// Create or reuse H.264 decoder for this display
if (!guac_client._h264Decoder) {
guac_client._h264Decoder = new Guacamole.H264Decoder(display);
}
// Collect base64 blob data
var base64Data = '';
stream.onblob = function(data) {
base64Data += data;
};
stream.onend = function() {
if (!base64Data) return;
// Decode base64 to ArrayBuffer
try {
var binaryString = atob(base64Data);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
// Feed to H.264 decoder
guac_client._h264Decoder.decode(
layer, x, y, width, height,
bytes.buffer, isKeyFrame
);
} catch (e) {
if (typeof console !== 'undefined')
console.error('[rustguac] H.264 base64 decode error:', e);
}
};
},
"jpeg": function(parameters) {
var channelMask = parseInt(parameters[0]);
+210
View File
@@ -0,0 +1,210 @@
/*
* H.264 decoder for Guacamole using the WebCodecs API.
* Decodes H.264 NAL units received via the "h264" instruction and
* renders decoded frames to a Guacamole Display layer.
*
* Copyright (C) 2026 Sol1 Pty Ltd. Licensed under Apache 2.0.
*/
var Guacamole = Guacamole || {};
/**
* H.264 video decoder that uses the WebCodecs VideoDecoder API for
* hardware-accelerated decoding of H.264 NAL units received from guacd.
*
* @constructor
* @param {!Guacamole.Display} display
* The Guacamole display to render decoded frames to.
*/
Guacamole.H264Decoder = function H264Decoder(display) {
/**
* The WebCodecs VideoDecoder instance, or null if not yet initialised
* or if WebCodecs is not supported.
*
* @private
* @type {?VideoDecoder}
*/
var decoder = null;
/**
* Whether the decoder has been configured with codec parameters.
*
* @private
* @type {boolean}
*/
var configured = false;
/**
* Monotonic timestamp counter for EncodedVideoChunk (microseconds).
*
* @private
* @type {number}
*/
var timestamp = 0;
/**
* The target layer for rendering decoded frames.
*
* @private
* @type {?Guacamole.Display.VisibleLayer}
*/
var targetLayer = null;
/**
* Pending draw position for the current frame.
*
* @private
*/
var pendingX = 0;
var pendingY = 0;
/**
* Total frames decoded.
*
* @type {number}
*/
this.framesDecoded = 0;
/**
* Total frames dropped or errored.
*
* @type {number}
*/
this.framesDropped = 0;
/**
* Reference to this for closures.
*/
var self = this;
/**
* Initialise the VideoDecoder if not already done.
*
* @private
* @param {number} width - Expected frame width.
* @param {number} height - Expected frame height.
*/
function ensureDecoder(width, height) {
if (decoder && configured)
return;
if (typeof VideoDecoder === 'undefined') {
console.warn('[rustguac] WebCodecs VideoDecoder not available');
return;
}
decoder = new VideoDecoder({
output: function(frame) {
self.framesDecoded++;
try {
// Draw the decoded VideoFrame directly to the layer's canvas
if (targetLayer) {
var canvas = targetLayer.getCanvas();
var ctx = canvas.getContext('2d');
ctx.drawImage(frame, pendingX, pendingY);
}
} finally {
// CRITICAL: always close VideoFrame to release GPU memory
frame.close();
}
},
error: function(e) {
self.framesDropped++;
console.error('[rustguac] H.264 decode error:', e.message);
}
});
// Configure for H.264 Constrained Baseline (most compatible)
// The actual profile/level will be negotiated by the RDP server
decoder.configure({
codec: 'avc1.42001f', // Baseline profile, level 3.1
optimizeForLatency: true
});
configured = true;
console.log('[rustguac] H.264 WebCodecs decoder initialised (' + width + 'x' + height + ')');
}
/**
* Decode a complete H.264 NAL unit buffer and render to the given layer.
*
* @param {!Guacamole.Display.VisibleLayer} layer
* The layer to draw the decoded frame to.
* @param {number} x - X position on the layer.
* @param {number} y - Y position on the layer.
* @param {number} width - Frame width.
* @param {number} height - Frame height.
* @param {!ArrayBuffer} nalData - Raw H.264 NAL unit data (Annex B format).
* @param {boolean} isKeyFrame - Whether this contains an IDR/keyframe.
*/
this.decode = function(layer, x, y, width, height, nalData, isKeyFrame) {
ensureDecoder(width, height);
if (!decoder || decoder.state === 'closed')
return;
targetLayer = layer;
pendingX = x;
pendingY = y;
try {
var chunk = new EncodedVideoChunk({
type: isKeyFrame ? 'key' : 'delta',
timestamp: timestamp,
data: nalData
});
timestamp += 33333; // ~30fps in microseconds
decoder.decode(chunk);
} catch (e) {
self.framesDropped++;
console.error('[rustguac] H.264 chunk error:', e.message);
}
};
/**
* Reset the decoder (e.g. after reconnection or error recovery).
* The next frame must be a keyframe.
*/
this.reset = function() {
if (decoder && decoder.state !== 'closed') {
try {
decoder.reset();
configured = false;
timestamp = 0;
console.log('[rustguac] H.264 decoder reset');
} catch (e) {
// Decoder may be in error state
}
}
};
/**
* Close and release the decoder.
*/
this.destroy = function() {
if (decoder && decoder.state !== 'closed') {
try {
decoder.close();
} catch (e) {
// Ignore
}
}
decoder = null;
configured = false;
};
};
/**
* Check if the browser supports H.264 decoding via WebCodecs.
*
* @returns {boolean}
* true if WebCodecs VideoDecoder is available and supports H.264.
*/
Guacamole.H264Decoder.isSupported = function isSupported() {
return typeof VideoDecoder !== 'undefined';
};