170 lines
4.9 KiB
TypeScript
170 lines
4.9 KiB
TypeScript
/**
|
||
* 解析 scrcpy raw_stream(AVCC:4 字节大端长度 + NAL)并用 WebCodecs 画到 Canvas。
|
||
* 与官方 scrcpy / escrcpy 设备端编码一致;浏览器需 Chromium 系且支持 VideoDecoder(H.264)。
|
||
*/
|
||
|
||
function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||
const o = new Uint8Array(a.length + b.length);
|
||
o.set(a, 0);
|
||
o.set(b, a.length);
|
||
return o;
|
||
}
|
||
|
||
function readU32be(b: Uint8Array, o: number): number {
|
||
return (b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3];
|
||
}
|
||
|
||
function buildAvcC(sps: Uint8Array, pps: Uint8Array): Uint8Array {
|
||
const spsBody = sps.subarray(1);
|
||
const ppsBody = pps.subarray(1);
|
||
const out = new Uint8Array(11 + spsBody.length + 3 + ppsBody.length);
|
||
let p = 0;
|
||
out[p++] = 1;
|
||
out[p++] = sps[1];
|
||
out[p++] = sps[2];
|
||
out[p++] = sps[3];
|
||
out[p++] = 0xfc | 3;
|
||
out[p++] = 0xe0 | 1;
|
||
out[p++] = (spsBody.length >> 8) & 0xff;
|
||
out[p++] = spsBody.length & 0xff;
|
||
out.set(spsBody, p);
|
||
p += spsBody.length;
|
||
out[p++] = 1;
|
||
out[p++] = (ppsBody.length >> 8) & 0xff;
|
||
out[p++] = ppsBody.length & 0xff;
|
||
out.set(ppsBody, p);
|
||
return out;
|
||
}
|
||
|
||
function codecStringFromSps(sps: Uint8Array): string {
|
||
const h = (n: number) => n.toString(16).padStart(2, "0").toUpperCase();
|
||
return `avc1.${h(sps[1])}${h(sps[2])}${h(sps[3])}`;
|
||
}
|
||
|
||
export class ScrcpyH264Decoder {
|
||
private buf: Uint8Array<ArrayBufferLike> = new Uint8Array(0);
|
||
private decoder: VideoDecoder | null = null;
|
||
private configured = false;
|
||
private configuring = false;
|
||
private ts = 0;
|
||
private sps: Uint8Array | null = null;
|
||
private pps: Uint8Array | null = null;
|
||
|
||
constructor(
|
||
private readonly canvas: HTMLCanvasElement,
|
||
private readonly onDecodeError: (msg: string) => void,
|
||
) {}
|
||
|
||
reset(): void {
|
||
this.buf = new Uint8Array(0);
|
||
this.sps = null;
|
||
this.pps = null;
|
||
this.configured = false;
|
||
this.configuring = false;
|
||
this.ts = 0;
|
||
if (this.decoder) {
|
||
try {
|
||
this.decoder.close();
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
this.decoder = null;
|
||
}
|
||
}
|
||
|
||
push(chunk: ArrayBuffer): void {
|
||
const add = new Uint8Array(chunk.byteLength);
|
||
add.set(new Uint8Array(chunk));
|
||
this.buf = concatBuffers(this.buf, add);
|
||
this.drainAvcc();
|
||
}
|
||
|
||
private drainAvcc(): void {
|
||
const b = this.buf;
|
||
let o = 0;
|
||
while (o + 4 <= b.length) {
|
||
const len = readU32be(b, o);
|
||
if (len <= 0 || len > 6 * 1024 * 1024) {
|
||
this.buf = new Uint8Array(b.subarray(o + 1));
|
||
o = 0;
|
||
continue;
|
||
}
|
||
if (o + 4 + len > b.length) break;
|
||
const nal = b.subarray(o + 4, o + 4 + len);
|
||
o += 4 + len;
|
||
this.handleNal(nal);
|
||
}
|
||
if (o > 0) this.buf = new Uint8Array(b.subarray(o));
|
||
}
|
||
|
||
private handleNal(nal: Uint8Array): void {
|
||
if (nal.length < 2) return;
|
||
const t = nal[0] & 0x1f;
|
||
if (t === 7) this.sps = new Uint8Array(nal);
|
||
else if (t === 8) this.pps = new Uint8Array(nal);
|
||
|
||
if (this.sps && this.pps && !this.configured && !this.configuring) {
|
||
void this.configure();
|
||
}
|
||
if (!this.configured || !this.decoder) return;
|
||
if (t !== 1 && t !== 5 && t !== 7 && t !== 8) return;
|
||
if (t === 7 || t === 8) return;
|
||
|
||
const key = t === 5;
|
||
const data = new Uint8Array(4 + nal.length);
|
||
new DataView(data.buffer).setUint32(0, nal.length, false);
|
||
data.set(nal, 4);
|
||
try {
|
||
const chunk = new EncodedVideoChunk({
|
||
type: key ? "key" : "delta",
|
||
timestamp: this.ts,
|
||
data,
|
||
});
|
||
this.ts += 33_333;
|
||
this.decoder.decode(chunk);
|
||
} catch (e) {
|
||
this.onDecodeError((e as Error).message || String(e));
|
||
}
|
||
}
|
||
|
||
private async configure(): Promise<void> {
|
||
if (!this.sps || !this.pps || this.configured || this.configuring) return;
|
||
this.configuring = true;
|
||
try {
|
||
const avcc = buildAvcC(this.sps, this.pps);
|
||
const codec = codecStringFromSps(this.sps);
|
||
const dec = new VideoDecoder({
|
||
output: (frame) => {
|
||
const ctx = this.canvas.getContext("2d");
|
||
if (!ctx) {
|
||
frame.close();
|
||
return;
|
||
}
|
||
if (this.canvas.width !== frame.displayWidth || this.canvas.height !== frame.displayHeight) {
|
||
this.canvas.width = frame.displayWidth;
|
||
this.canvas.height = frame.displayHeight;
|
||
}
|
||
ctx.drawImage(frame, 0, 0);
|
||
frame.close();
|
||
},
|
||
error: (e) => this.onDecodeError(String((e as DOMException).message || e)),
|
||
});
|
||
await dec.configure({
|
||
codec,
|
||
description: avcc,
|
||
optimizeForLatency: true,
|
||
} as VideoDecoderConfig);
|
||
this.decoder = dec;
|
||
this.configured = true;
|
||
} catch (e) {
|
||
this.onDecodeError((e as Error).message || String(e));
|
||
} finally {
|
||
this.configuring = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
export function webCodecsH264Supported(): boolean {
|
||
return typeof VideoDecoder !== "undefined" && typeof VideoFrame !== "undefined";
|
||
}
|