fix(rdclient): recover WebCodecs video from black screen on native RustDesk

WebCodecs VideoDecoder must receive a keyframe as the first chunk after configure(); a leading delta frame throws and the decoder produces no output. Because delta frames kept resetting the last-frame timer, the 3s stall-recovery never fired and the keyframe was never requested, leaving a permanent black screen (and 0x0 remote size, which also broke input mapping).

video.js: drop delta frames until a keyframe arrives (_needKeyframe gate after configure), use a dedicated monotonic input counter for chunk timestamps, drop deltas when the decode queue backs up, and rebuild the decoder with a software-decoding fallback + keyframe request on decoder error. init() now probes codec support without forcing hardware acceleration so software-only environments still pass.

client.js: wire video.onNeedKeyframe to send refresh_video, request an initial keyframe at session start, and extend stall-recovery to also fire when peer frames arrive but the decoder yields no output for 2s.

This commit was made possible thanks to Insolve.
This commit is contained in:
UNITRONIX
2026-05-31 02:46:44 +02:00
parent a39ea71968
commit 9c83917240
2 changed files with 145 additions and 20 deletions
+35 -3
View File
@@ -999,7 +999,15 @@ class RDClient {
// Initialize video decoder callbacks
this.video.onFrame = (frame) => this.renderer.pushFrame(frame);
this.video.onError = (err) => this._emit('log', 'Video error: ' + err.message);
this.video.onError = (err) => this._emit('log', 'Video error: ' + (err && err.message ? err.message : err));
// The decoder asks for a keyframe whenever it (re)configures or recovers
// from an error; forward that as a refresh_video request to the peer.
this.video.onNeedKeyframe = () => {
if (this._state === 'streaming') {
this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true));
}
};
// Request keyframe on resize/fullscreen to fix blur
this.renderer.onResizeRefresh = () => {
@@ -1035,6 +1043,10 @@ class RDClient {
imageQuality: quality
}));
// Proactively request an initial keyframe so the decoder can start
// immediately even if we joined an already-running stream on a delta.
this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true));
// Start ping interval
this._pingInterval = setInterval(() => {
if (this._state === 'streaming') {
@@ -1050,15 +1062,35 @@ class RDClient {
}
}, 1000);
// Stall recovery: if no video frames arrive for 3 seconds, request a keyframe
// Stall recovery. Two cases trigger a keyframe request:
// 1. No VideoFrame at all from the peer for 3s.
// 2. The peer keeps sending frames but the decoder produces no output
// (e.g. we joined mid-stream on delta frames) for 2s.
this._lastDecodedCount = 0;
this._lastDecodeProgressTime = Date.now();
this._stallCheckInterval = setInterval(() => {
if (this._state !== 'streaming') return;
const now = Date.now();
const decoded = this.video ? this.video.frameCount : 0;
if (decoded !== this._lastDecodedCount) {
this._lastDecodedCount = decoded;
this._lastDecodeProgressTime = now;
}
const lastFrame = this._lastVideoFrameTime || 0;
if (lastFrame > 0 && now - lastFrame > 3000) {
const noPeerFrames = lastFrame > 0 && now - lastFrame > 3000;
const peerFramesButNoDecode = (this._peerFrameCount || 0) > 0
&& decoded === 0 && now - this._lastDecodeProgressTime > 2000;
if (noPeerFrames || peerFramesButNoDecode) {
this._emit('log', 'Video stall detected, requesting keyframe');
this._sendPeerMessage(this.proto.buildMisc('refreshVideo', true));
if (this.video) {
this.video._needKeyframe = true;
}
this._lastVideoFrameTime = now; // prevent rapid retries
this._lastDecodeProgressTime = now;
}
}, 1500);
+110 -17
View File
@@ -52,6 +52,18 @@ class RDVideo {
this._autoplayBlocked = false;
/** @type {Function|null} Callback when autoplay is blocked */
this.onAutoplayBlocked = null;
/** @type {boolean} WebCodecs decoder is waiting for a keyframe before it can decode */
this._needKeyframe = false;
/** @type {Function|null} Callback asking the client to request a keyframe (refresh_video) */
this.onNeedKeyframe = null;
/** @type {number} Monotonic counter for WebCodecs chunk timestamps */
this._decodeInputCount = 0;
/** @type {Object|null} Last WebCodecs decoder configuration (for recovery) */
this._codecConfig = null;
/** @type {boolean} Whether we already retried software decoding after a hardware failure */
this._softwareRetry = false;
/** @type {number} Last decoder error timestamp (ms) for throttled recovery */
this._lastDecodeErrorTime = 0;
}
/**
@@ -162,12 +174,27 @@ class RDVideo {
throw new Error(`Unsupported codec: ${codecName}`);
}
// Verify codec is supported
const support = await VideoDecoder.isConfigSupported({
codec: codecString,
hardwareAcceleration: 'prefer-hardware'
});
// Choose acceleration: prefer hardware, but fall back to software after
// a hardware decode failure (some Linux/Chrome setups have flaky
// hardware H.264 paths that silently stop producing frames).
const accel = this._softwareRetry ? 'prefer-software' : 'prefer-hardware';
// Verify codec is supported. If the preferred acceleration is not
// available, retry the probe without a preference so software-only
// environments still pass.
let support;
try {
support = await VideoDecoder.isConfigSupported({ codec: codecString, hardwareAcceleration: accel });
} catch {
support = { supported: false };
}
if (!support.supported) {
try {
support = await VideoDecoder.isConfigSupported({ codec: codecString });
} catch {
support = { supported: false };
}
}
if (!support.supported) {
throw new Error(`Codec ${codecName} not supported by browser`);
}
@@ -177,17 +204,26 @@ class RDVideo {
error: (err) => this._handleError(err)
});
this.decoder.configure({
// Remember the configuration so the decoder can be rebuilt on error.
this._codecConfig = {
codec: codecString,
hardwareAcceleration: 'prefer-hardware',
hardwareAcceleration: accel,
optimizeForLatency: true
});
};
this.decoder.configure(this._codecConfig);
this.fallbackMode = false;
this.currentCodec = codecName;
this.frameCount = 0;
this.droppedFrames = 0;
this.initialized = true;
// A freshly configured decoder must receive a keyframe before any delta
// frame. Drop deltas until one arrives and proactively request one.
this._needKeyframe = true;
this._decodeInputCount = 0;
if (this.onNeedKeyframe) {
this.onNeedKeyframe();
}
}
/**
@@ -532,11 +568,29 @@ class RDVideo {
return;
}
// A WebCodecs decoder must start from a keyframe. Drop delta frames
// until the first keyframe (or one requested after an error) arrives.
if (this._needKeyframe) {
if (!frameData.key) {
this.droppedFrames++;
return;
}
this._needKeyframe = false;
}
// If the decode queue is backing up (slow/overloaded decoder), drop
// delta frames to catch up. Keyframes are always decoded.
if (!frameData.key && this.decoder.decodeQueueSize > 30) {
this.droppedFrames++;
return;
}
try {
// WebCodecs expects timestamps in microseconds.
// Use monotonic frameCount * 16667µs (~60fps) for stable timing.
// Using pts directly with * 1000 would give 1ms intervals which overflows the decoder queue.
const timestamp = this.frameCount * 16667; // ~60fps in microseconds
// WebCodecs expects monotonically increasing timestamps in
// microseconds. Use a dedicated input counter (the output
// frameCount can stay 0 while decoding has not produced a frame
// yet, which would yield duplicate timestamps).
const timestamp = (this._decodeInputCount++) * 16667; // ~60fps in microseconds
const chunk = new EncodedVideoChunk({
type: frameData.key ? 'key' : 'delta',
timestamp: timestamp,
@@ -546,9 +600,9 @@ class RDVideo {
this.decoder.decode(chunk);
} catch (err) {
this.droppedFrames++;
if (this.onError) {
this.onError(err);
}
// The next frame must be a keyframe to resynchronise the decoder.
this._needKeyframe = true;
this._handleError(err);
}
}
@@ -637,14 +691,53 @@ class RDVideo {
}
/**
* Handle decoder error
* Handle decoder error.
* Rebuilds the WebCodecs decoder (falling back to software decoding once)
* and requests a fresh keyframe so the stream can resynchronise instead of
* staying stuck on a black screen.
* @param {Error} err
*/
_handleError(err) {
console.error('[RDVideo] Decoder error:', err);
console.error('[RDVideo] Decoder error:', err && err.message ? err.message : err);
if (this.onError) {
this.onError(err);
}
if (this.fallbackMode || !RDVideo.isSupported()) {
return;
}
// Throttle recovery attempts to once per second.
const now = performance.now();
if (now - this._lastDecodeErrorTime < 1000) {
return;
}
this._lastDecodeErrorTime = now;
// First recovery after a hardware decode failure: retry with software.
if (!this._softwareRetry) {
this._softwareRetry = true;
console.warn('[RDVideo] Rebuilding decoder with software decoding fallback');
}
this._needKeyframe = true;
try {
if (this.decoder && this.decoder.state !== 'closed') {
this.decoder.close();
}
} catch {
// ignore
}
const codecName = this.currentCodec;
this.decoder = null;
this.initialized = false;
if (codecName) {
this.init(codecName).catch((e) => {
console.error('[RDVideo] Decoder rebuild failed:', e && e.message ? e.message : e);
});
}
}
/**