This commit is contained in:
eric
2026-03-29 02:57:08 -05:00
parent ef87c43b33
commit e3ac026710
15 changed files with 799 additions and 222 deletions

View File

@@ -0,0 +1,28 @@
/**
* 将 HTTP API 根地址转为 WebSocket 根(用于 /android/scrcpy/ws
* next dev 下若未设置 NEXT_PUBLIC_API_URL浏览器会连到 :3000WebSocket 升级通常无法被中间件代理;
* 真流调试请设 NEXT_PUBLIC_API_URL 指向 uvicorn例如 http://127.0.0.1:8001
*/
export function httpApiBaseToWsBase(apiBase: string): string {
if (typeof window === "undefined") return "ws://127.0.0.1:8001";
const base = (apiBase || "").trim();
if (base) {
try {
const u = new URL(base, window.location.href);
const proto = u.protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${u.host}`;
} catch {
/* ignore */
}
}
const { protocol, host } = window.location;
const proto = protocol === "https:" ? "wss:" : "ws:";
return `${proto}//${host}`;
}
export function androidScrcpyWsUrl(apiBase: string, serial: string, maxSize = 1920): string {
const root = httpApiBaseToWsBase(apiBase).replace(/\/$/, "");
const q = new URLSearchParams({ serial, max_size: String(maxSize) });
return `${root}/android/scrcpy/ws?${q.toString()}`;
}

View File

@@ -0,0 +1,169 @@
/**
* 解析 scrcpy raw_streamAVCC4 字节大端长度 + NAL并用 WebCodecs 画到 Canvas。
* 与官方 scrcpy / escrcpy 设备端编码一致;浏览器需 Chromium 系且支持 VideoDecoderH.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";
}

View File

@@ -24,8 +24,8 @@ export function parseYoutubeProChannelList(raw: unknown): YoutubeProChannel[] {
function migrateRow(x: Record<string, unknown>): YoutubeProChannel | null {
if (!x || typeof x !== "object") return null;
const id = String(x.id || "").trim();
const name = String(x.name || "").trim();
if (!id || !name) return null;
if (!id) return null;
const name = String(x.name || "").trim() || id;
return {
id,
name,