From cd8b83e28b50f0b12ba99a54dd3ed829af580629 Mon Sep 17 00:00:00 2001 From: CK Date: Sun, 19 Apr 2026 23:57:05 +0800 Subject: [PATCH 01/11] feat(web): decouple QR code preview from WHIP publishing in WebStream dialog Add intermediate qrPreviewing state so clicking 'Encode Latency' only starts local canvas preview without pushing to server. A separate 'Publish' button triggers the actual WHIP publish flow. --- web/shared/components/dialog-web-stream.tsx | 25 ++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/web/shared/components/dialog-web-stream.tsx b/web/shared/components/dialog-web-stream.tsx index 3da825f8..8fdee830 100644 --- a/web/shared/components/dialog-web-stream.tsx +++ b/web/shared/components/dialog-web-stream.tsx @@ -29,6 +29,7 @@ export const WebStreamDialog = forwardRef((props, ref) const refVideo = useRef(null); const refCanvas = useRef(null); const refQrCodeStream = useRef(null); + const [qrPreviewing, setQrPreviewing] = useState(false); useImperativeHandle(ref, () => { return { @@ -100,14 +101,27 @@ export const WebStreamDialog = forwardRef((props, ref) handleStreamStart(stream); }; - const handleEncodeLatencyStart = () => { + const handleEncodeLatencyPreview = () => { if (!refQrCodeStream.current) { refQrCodeStream.current = new QRCodeStream(refCanvas.current!); } - handleStreamStart(refQrCodeStream.current!.capture()); + const stream = refQrCodeStream.current.capture(); + refMediaStream.current = stream; + if (refVideo.current) { + refVideo.current.srcObject = stream; + } + setQrPreviewing(true); + }; + + const handleEncodeLatencyPublish = () => { + if (refMediaStream.current) { + handleStreamStart(refMediaStream.current); + setQrPreviewing(false); + } }; const handleStreamStop = async () => { + setQrPreviewing(false); if (refQrCodeStream.current) { refQrCodeStream.current.stop(); refQrCodeStream.current = null; @@ -159,9 +173,14 @@ export const WebStreamDialog = forwardRef((props, ref) {refWhipClient.current ? ( + ) : qrPreviewing ? ( + <> + + + ) : ( <> - + )} From 56311e7b25f526306c173aff94f7532ebd799a47 Mon Sep 17 00:00:00 2001 From: CK Date: Tue, 21 Apr 2026 15:58:53 +0800 Subject: [PATCH 02/11] refactor(qrcode): separate frame receive time from decode processing in latency calculation Record frameReceiveTime before QR decoding begins so that the recognition algorithm cost is excluded from the latency result. Refactor decodeFrame to return sentTimestamp instead of computing latency internally. --- web/shared/qrcode-stream.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/web/shared/qrcode-stream.ts b/web/shared/qrcode-stream.ts index 294f197a..1ea7b53c 100644 --- a/web/shared/qrcode-stream.ts +++ b/web/shared/qrcode-stream.ts @@ -164,7 +164,10 @@ export class QRCodeStreamDecoder extends TypedEventTarget= 5) { this.seq = 0; try { - this.decodeFrame(Date.now(), metadata.width, metadata.height); + // 在二维码识别开始前记录"帧可处理时刻" + // 这样二维码识别本身的耗时不会被计入延时结果 + // 测量的是:发送端写入时间戳 → 接收端视频帧进入回调可被处理 + const frameReceiveTime = Date.now(); + const sentTimestamp = this.decodeFrame(metadata.width, metadata.height); + if (sentTimestamp !== null) { + this.emitLatencyEvent(frameReceiveTime - sentTimestamp); + } } catch (e) { console.log(e); } From 88484ecd537a2a8864737fa8313ca433ec1a7947 Mon Sep 17 00:00:00 2001 From: CK Date: Tue, 21 Apr 2026 17:01:02 +0800 Subject: [PATCH 03/11] feat(debugger): add QR latency measurement panel with sender and receiver --- pnpm-lock.yaml | 25 +++++ web/debugger/components/debugger.tsx | 2 + web/debugger/components/qr-latency.tsx | 14 +++ web/debugger/components/qr-receiver.tsx | 110 +++++++++++++++++++++ web/debugger/components/qr-sender.tsx | 122 ++++++++++++++++++++++++ web/debugger/package.json | 4 +- 6 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 web/debugger/components/qr-latency.tsx create mode 100644 web/debugger/components/qr-receiver.tsx create mode 100644 web/debugger/components/qr-sender.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ebcd8dc..638aae2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,12 +87,18 @@ importers: web/debugger: devDependencies: + '@nuintun/qrcode': + specifier: ^5.0.2 + version: 5.0.2 '@solidjs/router': specifier: ^0.15.3 version: 0.15.3(solid-js@1.9.10) solid-js: specifier: ^1.9.10 version: 1.9.10 + typescript-event-target: + specifier: ^1.1.1 + version: 1.1.1 web/livecam: {} @@ -318,24 +324,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.3.3': resolution: {integrity: sha512-zeiKwALNB/hax7+LLhCYqhqzlWdTfgE9BGkX2Z8S4VmCYnGFrf2fON/ec6KCos7mra5MDm6fYICsEWN2+HKZhw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.3.3': resolution: {integrity: sha512-IyqQ+jYzU5MVy9CK5NV0U+NnUMPUAhYMrB/x4QgL/Dl1MqzBVc61bHeyhLnKM6DSEk73/TQYrk/8/QmVHudLdQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.3.3': resolution: {integrity: sha512-05CjPLbvVVU8J6eaO6iSEoA0FXKy2l6ddL+1h/VpiosCmIp3HxRKLOa1hhC1n+D13Z8g9b1DtnglGtM5U3sTag==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.3.3': resolution: {integrity: sha512-NtlLs3pdFqFAQYZjlEHKOwJEn3GEaz7rtR2oCrzaLT2Xt3Cfd55/VvodQ5V+X+KepLa956QJagckJrNL+DmumQ==} @@ -831,56 +841,67 @@ packages: resolution: {integrity: sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.52.5': resolution: {integrity: sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.52.5': resolution: {integrity: sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.52.5': resolution: {integrity: sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.52.5': resolution: {integrity: sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.52.5': resolution: {integrity: sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.52.5': resolution: {integrity: sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.52.5': resolution: {integrity: sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.52.5': resolution: {integrity: sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.52.5': resolution: {integrity: sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.52.5': resolution: {integrity: sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.52.5': resolution: {integrity: sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==} @@ -1728,24 +1749,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} diff --git a/web/debugger/components/debugger.tsx b/web/debugger/components/debugger.tsx index e16b0484..44ed35c1 100644 --- a/web/debugger/components/debugger.tsx +++ b/web/debugger/components/debugger.tsx @@ -3,6 +3,7 @@ import "./debugger.css"; import Publisher from "./publisher"; import Subscriber from "./subscriber"; +import QrLatency from "./qr-latency"; export default function Debugger() { const [searchParams, setSearchParams] = useSearchParams(); @@ -42,6 +43,7 @@ export default function Debugger() { + ); } diff --git a/web/debugger/components/qr-latency.tsx b/web/debugger/components/qr-latency.tsx new file mode 100644 index 00000000..755c0d27 --- /dev/null +++ b/web/debugger/components/qr-latency.tsx @@ -0,0 +1,14 @@ +import QrSender from "./qr-sender"; +import QrReceiver from "./qr-receiver"; + +export default function QrLatency() { + return ( +
+ QR Latency +
+ + +
+
+ ); +} diff --git a/web/debugger/components/qr-receiver.tsx b/web/debugger/components/qr-receiver.tsx new file mode 100644 index 00000000..727b2125 --- /dev/null +++ b/web/debugger/components/qr-receiver.tsx @@ -0,0 +1,110 @@ +import { useSearchParams } from "@solidjs/router"; +import { createSignal, onCleanup } from "solid-js"; +import { QRCodeStreamDecoder } from "../../shared/qrcode-stream"; +import subscribe from "./subscribe"; + +type State = "idle" | "subscribing" | "measuring"; + +export default function QrReceiver() { + const [searchParams] = useSearchParams(); + const [state, setState] = createSignal("idle"); + const [latency, setLatency] = createSignal(""); + + let videoRef: HTMLVideoElement | undefined; + let decoder: QRCodeStreamDecoder | null = null; + let stopWhep: (() => Promise) | null = null; + + onCleanup(() => { + stopAll(); + }); + + const startSubscribe = async () => { + setState("subscribing"); + try { + [stopWhep] = await subscribe({ + url: `${location.origin}/whep/${searchParams.id || "-"}`, + token: (searchParams.token as string) || "", + onStream: (ms: MediaStream | null) => { + if (videoRef) videoRef.srcObject = ms; + }, + onChannel: () => {}, + log: () => {}, + }); + } catch (e) { + console.error(e); + setState("idle"); + } + }; + + const startMeasure = () => { + if (!videoRef) return; + // video 元素需要有 width/height 属性供 QRCodeStreamDecoder 构造 OffscreenCanvas + videoRef.width = videoRef.videoWidth || 320; + videoRef.height = videoRef.videoHeight || 240; + decoder = new QRCodeStreamDecoder(videoRef); + decoder.addEventListener("latency", (e: CustomEvent) => { + setLatency(`${e.detail} ms`); + }); + decoder.start(); + setState("measuring"); + }; + + const stopAll = async () => { + if (decoder) { + decoder.stop(); + decoder = null; + } + if (stopWhep) { + await stopWhep(); + stopWhep = null; + } + if (videoRef) { + videoRef.srcObject = null; + } + setLatency(""); + setState("idle"); + }; + + return ( +
+ QR Receiver (WHEP) +
+
+
+
+ {state() === "idle" && ( + + )} + {state() === "subscribing" && ( + <> + + + + )} + {state() === "measuring" && ( + + )} +
+
+ State: {state()} + {latency() && | Latency: {latency()}} +
+
+
+ ); +} diff --git a/web/debugger/components/qr-sender.tsx b/web/debugger/components/qr-sender.tsx new file mode 100644 index 00000000..a3549865 --- /dev/null +++ b/web/debugger/components/qr-sender.tsx @@ -0,0 +1,122 @@ +import { useSearchParams } from "@solidjs/router"; +import { createSignal, onCleanup, onMount } from "solid-js"; +import { WHIPClient } from "@binbat/whip-whep/whip.js"; +import { QRCodeStream } from "../../shared/qrcode-stream"; +import convertSessionDescription from "./sdp"; + +type State = "idle" | "previewing" | "publishing"; + +export default function QrSender() { + const [searchParams] = useSearchParams(); + const [state, setState] = createSignal("idle"); + + let canvasRef: HTMLCanvasElement | undefined; + let videoRef: HTMLVideoElement | undefined; + + let qrStream: QRCodeStream | null = null; + let whipClient: WHIPClient | null = null; + let pc: RTCPeerConnection | null = null; + + onMount(() => { + // canvas 尺寸必须在 mount 后设置,QRCodeStream 构造时从 canvas 属性读取 + canvasRef!.width = 320; + canvasRef!.height = 320; + }); + + onCleanup(() => { + stopAll(); + }); + + const startPreview = () => { + if (!qrStream) { + qrStream = new QRCodeStream(canvasRef!); + } + const ms = qrStream.capture(); + videoRef!.srcObject = ms; + setState("previewing"); + }; + + const publish = async () => { + if (!qrStream) return; + const ms = qrStream.capture(); + + pc = new RTCPeerConnection(); + pc.addTransceiver(ms.getVideoTracks()[0], { direction: "sendonly" }); + + whipClient = new WHIPClient(); + // biome-ignore lint/suspicious/noExplicitAny: whip-whep.js uses any + whipClient.onAnswer = (answer: any) => + convertSessionDescription(answer, "", ""); + + setState("publishing"); + try { + const url = `${location.origin}/whip/${searchParams.id || "-"}`; + await whipClient.publish(pc, url, (searchParams.token as string) || ""); + } catch (e) { + console.error(e); + stopAll(); + } + }; + + const stopAll = async () => { + if (whipClient) { + await whipClient.stop(); + whipClient = null; + } + if (pc) { + pc.close(); + pc = null; + } + if (qrStream) { + qrStream.stop(); + qrStream = null; + } + if (videoRef) { + videoRef.srcObject = null; + } + setState("idle"); + }; + + return ( +
+ QR Sender (WHIP) +
+ {/* canvas 隐藏,仅用于 QRCodeStream 渲染 */} + +
+
+
+ {state() === "idle" && ( + + )} + {state() === "previewing" && ( + <> + + + + )} + {state() === "publishing" && ( + + )} +
+
+ State: {state()} +
+
+
+ ); +} diff --git a/web/debugger/package.json b/web/debugger/package.json index 8bf57611..dfab0a14 100644 --- a/web/debugger/package.json +++ b/web/debugger/package.json @@ -19,7 +19,9 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "devDependencies": { + "@nuintun/qrcode": "^5.0.2", "@solidjs/router": "^0.15.3", - "solid-js": "^1.9.10" + "solid-js": "^1.9.10", + "typescript-event-target": "^1.1.1" } } From ea391dc314fb22321f43dc07fc4bb7f42c8c36bc Mon Sep 17 00:00:00 2001 From: CK Date: Wed, 22 Apr 2026 12:58:11 +0800 Subject: [PATCH 04/11] fix(debugger): simplify QR receiver and improve robustness - Remove complex canMeasure detection logic that caused timing bugs - Keep video element always in DOM instead of conditional Show rendering - Add 404 error tolerance in WHEP stop to prevent stopAll interruption - Add published flag in QR sender to avoid stopping unpublished session - Unify preview styling with consistent size and white background - Force light theme in debugger for better QR code visibility - Remove timestamp text overlay from QR code stream --- web/debugger/components/qr-receiver.tsx | 12 ++++--- web/debugger/components/qr-sender.tsx | 27 +++++++++++----- web/debugger/components/subscribe.ts | 15 ++++++++- web/debugger/index.css | 18 +++-------- web/shared/qrcode-stream.ts | 42 ------------------------- 5 files changed, 45 insertions(+), 69 deletions(-) diff --git a/web/debugger/components/qr-receiver.tsx b/web/debugger/components/qr-receiver.tsx index 727b2125..36064e05 100644 --- a/web/debugger/components/qr-receiver.tsx +++ b/web/debugger/components/qr-receiver.tsx @@ -4,6 +4,7 @@ import { QRCodeStreamDecoder } from "../../shared/qrcode-stream"; import subscribe from "./subscribe"; type State = "idle" | "subscribing" | "measuring"; +const PREVIEW_SIZE = 320; export default function QrReceiver() { const [searchParams] = useSearchParams(); @@ -38,7 +39,6 @@ export default function QrReceiver() { const startMeasure = () => { if (!videoRef) return; - // video 元素需要有 width/height 属性供 QRCodeStreamDecoder 构造 OffscreenCanvas videoRef.width = videoRef.videoWidth || 320; videoRef.height = videoRef.videoHeight || 240; decoder = new QRCodeStreamDecoder(videoRef); @@ -69,13 +69,17 @@ export default function QrReceiver() {
QR Receiver (WHEP)
-
+
diff --git a/web/debugger/components/qr-sender.tsx b/web/debugger/components/qr-sender.tsx index a3549865..726a93fd 100644 --- a/web/debugger/components/qr-sender.tsx +++ b/web/debugger/components/qr-sender.tsx @@ -6,6 +6,8 @@ import convertSessionDescription from "./sdp"; type State = "idle" | "previewing" | "publishing"; +const PREVIEW_SIZE = 320; + export default function QrSender() { const [searchParams] = useSearchParams(); const [state, setState] = createSignal("idle"); @@ -16,11 +18,11 @@ export default function QrSender() { let qrStream: QRCodeStream | null = null; let whipClient: WHIPClient | null = null; let pc: RTCPeerConnection | null = null; + let published = false; onMount(() => { - // canvas 尺寸必须在 mount 后设置,QRCodeStream 构造时从 canvas 属性读取 - canvasRef!.width = 320; - canvasRef!.height = 320; + canvasRef!.width = PREVIEW_SIZE; + canvasRef!.height = PREVIEW_SIZE; }); onCleanup(() => { @@ -44,6 +46,7 @@ export default function QrSender() { pc.addTransceiver(ms.getVideoTracks()[0], { direction: "sendonly" }); whipClient = new WHIPClient(); + published = false; // biome-ignore lint/suspicious/noExplicitAny: whip-whep.js uses any whipClient.onAnswer = (answer: any) => convertSessionDescription(answer, "", ""); @@ -52,17 +55,21 @@ export default function QrSender() { try { const url = `${location.origin}/whip/${searchParams.id || "-"}`; await whipClient.publish(pc, url, (searchParams.token as string) || ""); + published = true; } catch (e) { console.error(e); - stopAll(); + await stopAll(); } }; const stopAll = async () => { if (whipClient) { - await whipClient.stop(); + if (published) { + await whipClient.stop(); + } whipClient = null; } + published = false; if (pc) { pc.close(); pc = null; @@ -81,12 +88,16 @@ export default function QrSender() {
QR Sender (WHIP)
- {/* canvas 隐藏,仅用于 QRCodeStream 渲染 */} -
+
+ ); } diff --git a/web/debugger/components/qr-latency.tsx b/web/debugger/components/qr-latency.tsx new file mode 100644 index 00000000..755c0d27 --- /dev/null +++ b/web/debugger/components/qr-latency.tsx @@ -0,0 +1,14 @@ +import QrSender from "./qr-sender"; +import QrReceiver from "./qr-receiver"; + +export default function QrLatency() { + return ( +
+ QR Latency +
+ + +
+
+ ); +} diff --git a/web/debugger/components/qr-receiver.tsx b/web/debugger/components/qr-receiver.tsx new file mode 100644 index 00000000..727b2125 --- /dev/null +++ b/web/debugger/components/qr-receiver.tsx @@ -0,0 +1,110 @@ +import { useSearchParams } from "@solidjs/router"; +import { createSignal, onCleanup } from "solid-js"; +import { QRCodeStreamDecoder } from "../../shared/qrcode-stream"; +import subscribe from "./subscribe"; + +type State = "idle" | "subscribing" | "measuring"; + +export default function QrReceiver() { + const [searchParams] = useSearchParams(); + const [state, setState] = createSignal("idle"); + const [latency, setLatency] = createSignal(""); + + let videoRef: HTMLVideoElement | undefined; + let decoder: QRCodeStreamDecoder | null = null; + let stopWhep: (() => Promise) | null = null; + + onCleanup(() => { + stopAll(); + }); + + const startSubscribe = async () => { + setState("subscribing"); + try { + [stopWhep] = await subscribe({ + url: `${location.origin}/whep/${searchParams.id || "-"}`, + token: (searchParams.token as string) || "", + onStream: (ms: MediaStream | null) => { + if (videoRef) videoRef.srcObject = ms; + }, + onChannel: () => {}, + log: () => {}, + }); + } catch (e) { + console.error(e); + setState("idle"); + } + }; + + const startMeasure = () => { + if (!videoRef) return; + // video 元素需要有 width/height 属性供 QRCodeStreamDecoder 构造 OffscreenCanvas + videoRef.width = videoRef.videoWidth || 320; + videoRef.height = videoRef.videoHeight || 240; + decoder = new QRCodeStreamDecoder(videoRef); + decoder.addEventListener("latency", (e: CustomEvent) => { + setLatency(`${e.detail} ms`); + }); + decoder.start(); + setState("measuring"); + }; + + const stopAll = async () => { + if (decoder) { + decoder.stop(); + decoder = null; + } + if (stopWhep) { + await stopWhep(); + stopWhep = null; + } + if (videoRef) { + videoRef.srcObject = null; + } + setLatency(""); + setState("idle"); + }; + + return ( +
+ QR Receiver (WHEP) +
+
+
+
+ {state() === "idle" && ( + + )} + {state() === "subscribing" && ( + <> + + + + )} + {state() === "measuring" && ( + + )} +
+
+ State: {state()} + {latency() && | Latency: {latency()}} +
+
+
+ ); +} diff --git a/web/debugger/components/qr-sender.tsx b/web/debugger/components/qr-sender.tsx new file mode 100644 index 00000000..a3549865 --- /dev/null +++ b/web/debugger/components/qr-sender.tsx @@ -0,0 +1,122 @@ +import { useSearchParams } from "@solidjs/router"; +import { createSignal, onCleanup, onMount } from "solid-js"; +import { WHIPClient } from "@binbat/whip-whep/whip.js"; +import { QRCodeStream } from "../../shared/qrcode-stream"; +import convertSessionDescription from "./sdp"; + +type State = "idle" | "previewing" | "publishing"; + +export default function QrSender() { + const [searchParams] = useSearchParams(); + const [state, setState] = createSignal("idle"); + + let canvasRef: HTMLCanvasElement | undefined; + let videoRef: HTMLVideoElement | undefined; + + let qrStream: QRCodeStream | null = null; + let whipClient: WHIPClient | null = null; + let pc: RTCPeerConnection | null = null; + + onMount(() => { + // canvas 尺寸必须在 mount 后设置,QRCodeStream 构造时从 canvas 属性读取 + canvasRef!.width = 320; + canvasRef!.height = 320; + }); + + onCleanup(() => { + stopAll(); + }); + + const startPreview = () => { + if (!qrStream) { + qrStream = new QRCodeStream(canvasRef!); + } + const ms = qrStream.capture(); + videoRef!.srcObject = ms; + setState("previewing"); + }; + + const publish = async () => { + if (!qrStream) return; + const ms = qrStream.capture(); + + pc = new RTCPeerConnection(); + pc.addTransceiver(ms.getVideoTracks()[0], { direction: "sendonly" }); + + whipClient = new WHIPClient(); + // biome-ignore lint/suspicious/noExplicitAny: whip-whep.js uses any + whipClient.onAnswer = (answer: any) => + convertSessionDescription(answer, "", ""); + + setState("publishing"); + try { + const url = `${location.origin}/whip/${searchParams.id || "-"}`; + await whipClient.publish(pc, url, (searchParams.token as string) || ""); + } catch (e) { + console.error(e); + stopAll(); + } + }; + + const stopAll = async () => { + if (whipClient) { + await whipClient.stop(); + whipClient = null; + } + if (pc) { + pc.close(); + pc = null; + } + if (qrStream) { + qrStream.stop(); + qrStream = null; + } + if (videoRef) { + videoRef.srcObject = null; + } + setState("idle"); + }; + + return ( +
+ QR Sender (WHIP) +
+ {/* canvas 隐藏,仅用于 QRCodeStream 渲染 */} + +
+
+
+ {state() === "idle" && ( + + )} + {state() === "previewing" && ( + <> + + + + )} + {state() === "publishing" && ( + + )} +
+
+ State: {state()} +
+
+
+ ); +} diff --git a/web/debugger/package.json b/web/debugger/package.json index 8bf57611..dfab0a14 100644 --- a/web/debugger/package.json +++ b/web/debugger/package.json @@ -19,7 +19,9 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "devDependencies": { + "@nuintun/qrcode": "^5.0.2", "@solidjs/router": "^0.15.3", - "solid-js": "^1.9.10" + "solid-js": "^1.9.10", + "typescript-event-target": "^1.1.1" } } From 99de0e513442fee01aca355e82a257c0c183fdf8 Mon Sep 17 00:00:00 2001 From: CK Date: Wed, 22 Apr 2026 12:58:11 +0800 Subject: [PATCH 08/11] fix(debugger): simplify QR receiver and improve robustness - Remove complex canMeasure detection logic that caused timing bugs - Keep video element always in DOM instead of conditional Show rendering - Add 404 error tolerance in WHEP stop to prevent stopAll interruption - Add published flag in QR sender to avoid stopping unpublished session - Unify preview styling with consistent size and white background - Force light theme in debugger for better QR code visibility - Remove timestamp text overlay from QR code stream --- web/debugger/components/qr-receiver.tsx | 12 ++++--- web/debugger/components/qr-sender.tsx | 27 +++++++++++----- web/debugger/components/subscribe.ts | 15 ++++++++- web/debugger/index.css | 18 +++-------- web/shared/qrcode-stream.ts | 42 ------------------------- 5 files changed, 45 insertions(+), 69 deletions(-) diff --git a/web/debugger/components/qr-receiver.tsx b/web/debugger/components/qr-receiver.tsx index 727b2125..36064e05 100644 --- a/web/debugger/components/qr-receiver.tsx +++ b/web/debugger/components/qr-receiver.tsx @@ -4,6 +4,7 @@ import { QRCodeStreamDecoder } from "../../shared/qrcode-stream"; import subscribe from "./subscribe"; type State = "idle" | "subscribing" | "measuring"; +const PREVIEW_SIZE = 320; export default function QrReceiver() { const [searchParams] = useSearchParams(); @@ -38,7 +39,6 @@ export default function QrReceiver() { const startMeasure = () => { if (!videoRef) return; - // video 元素需要有 width/height 属性供 QRCodeStreamDecoder 构造 OffscreenCanvas videoRef.width = videoRef.videoWidth || 320; videoRef.height = videoRef.videoHeight || 240; decoder = new QRCodeStreamDecoder(videoRef); @@ -69,13 +69,17 @@ export default function QrReceiver() {
QR Receiver (WHEP)
-
+
diff --git a/web/debugger/components/qr-sender.tsx b/web/debugger/components/qr-sender.tsx index a3549865..726a93fd 100644 --- a/web/debugger/components/qr-sender.tsx +++ b/web/debugger/components/qr-sender.tsx @@ -6,6 +6,8 @@ import convertSessionDescription from "./sdp"; type State = "idle" | "previewing" | "publishing"; +const PREVIEW_SIZE = 320; + export default function QrSender() { const [searchParams] = useSearchParams(); const [state, setState] = createSignal("idle"); @@ -16,11 +18,11 @@ export default function QrSender() { let qrStream: QRCodeStream | null = null; let whipClient: WHIPClient | null = null; let pc: RTCPeerConnection | null = null; + let published = false; onMount(() => { - // canvas 尺寸必须在 mount 后设置,QRCodeStream 构造时从 canvas 属性读取 - canvasRef!.width = 320; - canvasRef!.height = 320; + canvasRef!.width = PREVIEW_SIZE; + canvasRef!.height = PREVIEW_SIZE; }); onCleanup(() => { @@ -44,6 +46,7 @@ export default function QrSender() { pc.addTransceiver(ms.getVideoTracks()[0], { direction: "sendonly" }); whipClient = new WHIPClient(); + published = false; // biome-ignore lint/suspicious/noExplicitAny: whip-whep.js uses any whipClient.onAnswer = (answer: any) => convertSessionDescription(answer, "", ""); @@ -52,17 +55,21 @@ export default function QrSender() { try { const url = `${location.origin}/whip/${searchParams.id || "-"}`; await whipClient.publish(pc, url, (searchParams.token as string) || ""); + published = true; } catch (e) { console.error(e); - stopAll(); + await stopAll(); } }; const stopAll = async () => { if (whipClient) { - await whipClient.stop(); + if (published) { + await whipClient.stop(); + } whipClient = null; } + published = false; if (pc) { pc.close(); pc = null; @@ -81,12 +88,16 @@ export default function QrSender() {
QR Sender (WHIP)
- {/* canvas 隐藏,仅用于 QRCodeStream 渲染 */} -
+
- ); } diff --git a/web/debugger/components/device.tsx b/web/debugger/components/device.tsx index b2607ec0..cdef7f26 100644 --- a/web/debugger/components/device.tsx +++ b/web/debugger/components/device.tsx @@ -23,6 +23,7 @@ function uniqByValue(items: T[]) { export default function Device(props: { disabled: boolean; + refreshToken: number; onSelectAudio: (deviceId: string) => void; onSelectVideo: (deviceId: string) => void; }) { @@ -57,6 +58,17 @@ export default function Device(props: { console.error("refreshDevice failed:", e); } }; + createEffect( + on( + () => props.refreshToken, + () => { + void refreshDevice(); + }, + { + defer: true, + }, + ), + ); createEffect( on( audioDevices, @@ -78,13 +90,6 @@ export default function Device(props: { return ( <> -
Audio Device: