2026-04-21 13:16:36 +08:00

232 lines
6.5 KiB
JavaScript

/*
* This file is part of Xpra.
* Copyright (C) 2021 Tijs van der Zwaan <tijzwa@vpo.nl>
* Licensed under MPL 2.0, see:
* http://www.mozilla.org/MPL/2.0/
*
*/
/*
* Receives native video packets and decodes them via VideoDecoder.
* https://developer.mozilla.org/en-US/docs/Web/API/VideoDecoder
* VideoDecoder is only working in Chrome 94+ and Android
*
* Example taken from: https://github.com/w3c/webcodecs/blob/main/explainer.md
*
*/
const XpraVideoDecoderLoader = {
hasNativeDecoder() {
return typeof VideoDecoder !== "undefined";
},
};
class XpraVideoDecoder {
constructor() {
this.initialized = false;
this.had_first_key = false;
this.draining = false;
this.decoder_queue = [];
this.decoded_frames = [];
this.erroneous_frame = null;
this.codec = null;
this.vp9_params = null;
this.frameWaitTimeout = 1; // Interval for wait loop while frame is being decoded
this.frame_threshold = 250;
}
prepareVP9params(csc) {
// Check if we have the right VP9 params before init.
// This is needed because we can only set the params when the video decoder is created.
// We close the decoder when the coding changes.
if (csc === "YUV444P" && this.vp9_params !== ".01.10.08") {
this.vp9_params = ".01.10.08";
this._close();
} else if (csc === "YUV444P10" && this.vp9_params !== ".03.10.10") {
this.vp9_params = ".03.10.10";
this._close();
} else if (this.vp9_params !== ".00.20.08.01.02.02") {
// chroma shift for YUV420P, both X and Y are downscaled by 2^1
this.vp9_params = ".00.20.08.01.02.02";
this._close();
}
}
init(coding) {
this.draining = false;
this.codec = this.resolveCodec(coding);
this.videoDecoder = new VideoDecoder({
output: this._on_decoded_frame.bind(this),
error: this._on_decoder_error.bind(this),
});
// ToDo: hardwareAcceleration can be "no-preference" / "prefer-hardware" / "prefer-software"
// Figure out when "prefer-hardware" is the right choise and when not (ie: no GPU, remote session like RDP?)
this.videoDecoder.configure({
codec: this.codec,
hardwareAcceleration: "no-preference",
optimizeForLatency: true,
colorSpace: new VideoColorSpace({
fullRange: true
})
});
this.last_timestamp = 0;
this.initialized = true;
}
resolveCodec(coding) {
if (coding === "h264") return "avc1.42C01E";
if (coding === "vp8") return "vp8";
if (coding === "vp9") return `vp09${this.vp9_params}`;
throw `No codec defined for coding ${coding}`;
}
_on_decoded_frame(videoFrame) {
if (this.decoder_queue.length === 0) {
videoFrame.close();
return;
}
// Find the frame
const frame_timestamp = videoFrame.timestamp;
let current_frame = this.decoder_queue.filter(
(q) => q.p[10]["frame"] === frame_timestamp
);
if (current_frame.length === 1) {
// We found our frame!
this.decoder_queue = this.decoder_queue.filter(
(q) => q.p[10]["frame"] !== frame_timestamp
);
current_frame = current_frame[0];
} else {
// We decoded a frame the is no longer queued??
// TODO: handle error??
videoFrame.close();
return;
}
if (frame_timestamp === 0) {
this.last_timestamp = 0;
}
if (
this.decoder_queue.length > this.frame_threshold ||
this.last_timestamp > frame_timestamp
) {
// Skip if the decoders queue is growing too big or this frames timestamp is smaller then the last one painted.
videoFrame.close();
const packet = current_frame.p;
packet[6] = "throttle";
packet[7] = null;
this.decoded_frames.push(packet);
return;
}
this.last_timestamp = frame_timestamp;
const packet = current_frame.p;
// Latest possible check for draining
if (this.draining) {
videoFrame.close();
return;
}
packet[6] = `frame:${packet[6]}`;
packet[7] = videoFrame;
this.decoded_frames.push(packet);
}
_on_decoder_error(error) {
// TODO: Handle error? Or just assume we will catch up?
this.erroneous_frame = `Error decoding frame: ${error}`;
console.error(this.erroneous_frame);
}
queue_frame(packet) {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
const options = packet[10] || {};
const data = packet[7];
const packet_sequence = packet[8];
// H264 (avc1.42C01E) needs key frames
if (
this.codec.startsWith("avc1") &&
!this.had_first_key &&
!(options["type"] &&
options["type"] == "IDR")
) {
reject(
new Error(
`first h264 frame must be a key frame but packet ${packet_sequence} is not: ${options}`
)
);
return;
}
if (this.videoDecoder.state === "closed") {
reject(new Error("video decoder is closed"));
return;
}
if (this.draining) {
reject(new Error("video decoder is draining"));
return;
}
this.had_first_key = true;
this.decoder_queue.push({
p: packet
});
const init = {
type: options["type"] === "IDR" ? "key" : "delta",
data,
timestamp: options["frame"],
};
const chunk = new EncodedVideoChunk(init);
this.videoDecoder.decode(chunk);
let frame_out = this.decoded_frames.filter(
(p) => p[8] === packet_sequence
);
while (frame_out.length === 0) {
// Await our frame
await new Promise((r) => setTimeout(r, this.frameWaitTimeout));
if (this.erroneous_frame != null) {
// The last frame was erroneous, break the wait loop
break;
}
frame_out = this.decoded_frames.filter((p) => p[8] === packet_sequence);
}
if (this.erroneous_frame != null) {
// Last frame was erroneous. Reject the promise and reset the state.
const msg = this.erroneous_frame;
this.erroneous_frame = null;
reject(new Error(msg));
}
// Remove the frame from decoded frames list
this.decoded_frames = this.decoded_frames.filter(
(p) => p[8] !== packet_sequence
);
resolve(frame_out[0]);
});
}
_close() {
if (this.initialized) {
if (this.videoDecoder.state !== "closed") {
this.videoDecoder.close();
}
this.had_first_key = false;
this.draining = true;
this.decoder_queue = [];
}
this.initialized = false;
}
}