From 86fd2c76922fc9c71e91ee7f6e4e4a4c587d4d58 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:26:11 +0000 Subject: [PATCH] fix: fallback to MessagePort when ReadableStream transfer is unsupported Safari does not support transferring ReadableStream objects directly to Web Workers via postMessage. This commit adds a feature detection check to see if transferring a ReadableStream is supported by the browser. If supported (like in Chrome and Firefox), it continues to transfer the stream directly for optimal performance. If unsupported (like in Safari), it falls back to using a MessageChannel to stream chunks manually to the worker, where the stream is reconstructed. Also adds checks for VideoDecoder availability to prevent errors in unsupported environments. Co-authored-by: RoiArthurB <16764085+RoiArthurB@users.noreply.github.com> --- .../WebSocketManager/VideoStreamManager.tsx | 121 +++++++++++++----- src/workers/scrcpyDecoder.ts | 36 +++++- 2 files changed, 123 insertions(+), 34 deletions(-) diff --git a/src/components/WebSocketManager/VideoStreamManager.tsx b/src/components/WebSocketManager/VideoStreamManager.tsx index 5ec08f1..ac65225 100644 --- a/src/components/WebSocketManager/VideoStreamManager.tsx +++ b/src/components/WebSocketManager/VideoStreamManager.tsx @@ -165,6 +165,14 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V }, }); + if (typeof VideoDecoder === 'undefined') { + logger.warn("[Scrcpy-VideoStreamManager] WebCodecs API (VideoDecoder) is not available in this browser, aborting stream"); + readableControllers.delete(deviceId); + worker.terminate(); + decoderWorkers.current.delete(deviceId); + return; + } + await VideoDecoder.isConfigSupported({ // Check if h265 is supported codec: "hev1.1.60.L153.B0.0.0.0.0.0", @@ -180,16 +188,64 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V if (supported.supported || !useH265) { const codec = useH265 ? ScrcpyVideoCodecId.H265 : ScrcpyVideoCodecId.H264; - // Pass objects and stream to worker - worker.postMessage( - { - codec, - canvas: offscreenCanvas, - stream, - useH265 - }, - [offscreenCanvas, stream] - ); + // Check if browser supports transferring ReadableStream + let canTransferStream = false; + try { + const { port1 } = new MessageChannel(); + const testStream = new ReadableStream(); + port1.postMessage(testStream, [testStream]); + canTransferStream = true; + } catch (e) { + canTransferStream = false; + } + + if (canTransferStream) { + // Pass objects and stream to worker directly + worker.postMessage( + { + codec, + canvas: offscreenCanvas, + stream, + useH265, + type: 'direct' + }, + [offscreenCanvas, stream] + ); + } else { + // Fallback for browsers that don't support transferring ReadableStream (like Safari) + logger.info("[Scrcpy-VideoStreamManager] ReadableStream transfer not supported, using MessageChannel fallback"); + const { port1, port2 } = new MessageChannel(); + worker.postMessage( + { codec, canvas: offscreenCanvas, port: port2, useH265, type: 'port' }, + [offscreenCanvas, port2] + ); + + const reader = stream.getReader(); + (async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + port1.postMessage({ done: true }); + break; + } + const transferables: Transferable[] = []; + // Clone the buffer to avoid detaching it if it's needed elsewhere, + // or just send the value. In Firefox, detaching was causing issues. + // However, since we fallback to MessageChannel ONLY when ReadableStream transfer fails, + // Firefox (which supports stream transfer) will use the direct path above. + if (value?.data instanceof Uint8Array) { + transferables.push(value.data.buffer); + } + port1.postMessage({ done: false, value }, transferables); + } + } catch { + port1.postMessage({ done: true }); + } finally { + port1.close(); + } + })(); + } } else { logger.error("[Scrcpy] Error piping to decoder writable stream"); } @@ -211,6 +267,7 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V // Reconnects automatically after 1 s on unexpected close. function connectDeviceSocket(streamId: string) { if (cleanedUp) return; + if (typeof VideoDecoder === 'undefined') return; // Prevent the stale socket's onclose from firing a reconnect when we replace it const existing = deviceSockets.get(streamId); @@ -339,32 +396,34 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V // Send browser's codecs compatibility socket.onopen = async () => { - let supportH264: boolean, supportH265: boolean, supportAv1: boolean; - // Check if h264 is supported - await VideoDecoder.isConfigSupported({ codec: "avc1.4D401E" }).then((r) => { - supportH264 = r.supported!; - logger.info("[SCRCPY] Supports h264: {supportH264}", { supportH264 }); - }) - - // Check if h265 is supported - await VideoDecoder.isConfigSupported({ codec: "hev1.1.60.L153.B0.0.0.0.0.0" }).then((r) => { - supportH265 = r.supported!; - logger.info("[SCRCPY] Supports h265 {supportH265}", { supportH265 }); - }) - - // Check if AV1 is supported - await VideoDecoder.isConfigSupported({ codec: "av01.0.05M.08" }).then((r) => { - supportAv1 = r.supported!; - logger.info("[SCRCPY] Supports AV1 {supportAv1}", { supportAv1 }); - }) + let supportH264 = false, supportH265 = false, supportAv1 = false; + + if (typeof VideoDecoder === 'undefined') { + logger.warn("[SCRCPY] WebCodecs API not available, reporting no codec support"); + } else { + // Check if h264 is supported + await VideoDecoder.isConfigSupported({ codec: "avc1.4D401E" }).then((r) => { + supportH264 = r.supported!; + logger.info("[SCRCPY] Supports h264: {supportH264}", { supportH264 }); + }) + + // Check if h265 is supported + await VideoDecoder.isConfigSupported({ codec: "hev1.1.60.L153.B0.0.0.0.0.0" }).then((r) => { + supportH265 = r.supported!; + logger.info("[SCRCPY] Supports h265 {supportH265}", { supportH265 }); + }) + + // Check if AV1 is supported + await VideoDecoder.isConfigSupported({ codec: "av01.0.05M.08" }).then((r) => { + supportAv1 = r.supported!; + logger.info("[SCRCPY] Supports AV1 {supportAv1}", { supportAv1 }); + }) + } socket.send(JSON.stringify({ "type": "codecVideo", - // @ts-expect-error "h264": supportH264, - // @ts-expect-error "h265": supportH265, - // @ts-expect-error "av1": supportAv1, })); } diff --git a/src/workers/scrcpyDecoder.ts b/src/workers/scrcpyDecoder.ts index 2e06edc..ac15fbf 100644 --- a/src/workers/scrcpyDecoder.ts +++ b/src/workers/scrcpyDecoder.ts @@ -6,11 +6,13 @@ import { } from "@yume-chan/scrcpy-decoder-webcodecs"; self.addEventListener("message", (e) => { - const { codec, canvas, stream, useH265 } = e.data as { + const { codec, canvas, stream, port, useH265, type } = e.data as { codec: ScrcpyVideoCodecId; canvas: OffscreenCanvas; - stream: ReadableStream; + stream?: ReadableStream; + port?: MessagePort; useH265: boolean; + type: 'direct' | 'port'; }; let renderer; @@ -30,7 +32,35 @@ self.addEventListener("message", (e) => { postMessage({ type: 'sizeChanged', width, height }); }); - void stream.pipeTo(decoder.writable).catch((err) => { + let activeStream: ReadableStream; + + if (type === 'direct' && stream) { + activeStream = stream; + } else if (type === 'port' && port) { + // Reconstruct a ReadableStream from the MessagePort (Safari doesn't support + // transferring ReadableStream directly via postMessage). + activeStream = new ReadableStream({ + start(controller) { + port.onmessage = ({ data }) => { + if (data.done) { + controller.close(); + port.close(); + } else { + controller.enqueue(data.value as ScrcpyMediaStreamPacket); + } + }; + port.start(); + }, + cancel() { + port.close(); + }, + }); + } else { + console.error("[Worker] Invalid stream transfer type or missing stream/port."); + return; + } + + void activeStream.pipeTo(decoder.writable).catch((err) => { console.error("[Worker] Error piping to decoder writable stream:", err); }); });