Skip to content

Commit f08690e

Browse files
feat: Move WebCodecs decoder to Web Worker for better scrcpy video streaming performance
To improve scrcpy streams performance when monitoring up to 6 devices concurrently, WebCodecs decoding tasks have been moved off the main thread to a dedicated Web Worker instance for each device. The application now allocates an OffscreenCanvas that is passed to each worker alongside the scrcpy packet streams. The background Web Worker runs the actual \`@yume-chan/scrcpy-decoder-webcodecs\` logic, resulting in vastly improved stability and responsiveness on the main thread. Old active Web Workers are automatically tracked and properly terminated when devices disconnect, streams switch codec, or components unmount. Co-authored-by: RoiArthurB <16764085+RoiArthurB@users.noreply.github.com>
1 parent 3a4faa5 commit f08690e

4 files changed

Lines changed: 94 additions & 38 deletions

File tree

public/scrcpy/worker.js

Whitespace-only changes.

src/components/WebSocketManager/VideoStreamManager.tsx

Lines changed: 57 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
import { useEffect, useState, useMemo, useRef } from "react";
22
import PlayerScreenCanvas from "./PlayerScreenCanvas.tsx";
3-
import {
4-
VideoFrameRenderer,
5-
WebGLVideoFrameRenderer,
6-
BitmapVideoFrameRenderer,
7-
WebCodecsVideoDecoder,
8-
} from "@yume-chan/scrcpy-decoder-webcodecs";
93
import { getLogger } from "@logtape/logtape";
104
import { ScrcpyMediaStreamPacket, ScrcpyVideoCodecId } from "@yume-chan/scrcpy";
115
const host: string = window.location.hostname;
@@ -41,19 +35,6 @@ const deserializeData = (serializedData: string) => {
4135
}
4236
};
4337

44-
function createVideoFrameRenderer(): VideoFrameRenderer {
45-
46-
if (WebGLVideoFrameRenderer.isSupported) {
47-
logger.debug("[SCRCPY] Using WebGLVideoFrameRenderer");
48-
return new WebGLVideoFrameRenderer();
49-
} else {
50-
logger.warn("[SCRCPY] WebGL isn't supported... ");
51-
}
52-
53-
logger.debug("[SCRCPY] Using fallback BitmapVideoFrameRenderer");
54-
return new BitmapVideoFrameRenderer();
55-
}
56-
5738
interface VideoStreamManagerProps {
5839
needsInteractivity?: boolean;
5940
selectedCanvas?: string;
@@ -92,6 +73,8 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
9273
const isDecoderHasConfig = new Map<string, boolean>();
9374
// Tracks the codec each decoder was created with — used to detect mid-stream codec changes
9475
const streamIsH265 = new Map<string, boolean>();
76+
// Map of worker instances for each stream
77+
const decoderWorkers = useRef<Map<string, Worker>>(new Map());
9578

9679

9780

@@ -111,10 +94,7 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
11194
}
11295
// Prepare video stream =======================
11396

114-
const renderer: VideoFrameRenderer = createVideoFrameRenderer();
115-
116-
// get the canvas from the renderer (renderer as any is used to ensure ts knows that canvas is a property of the renderer)
117-
const canvas = (renderer as any).canvas as HTMLCanvasElement
97+
const canvas = document.createElement("canvas");
11898

11999
// Catch cases with non IP devices (USB
120100
const canvasId: string =
@@ -136,6 +116,24 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
136116
}
137117
}
138118

119+
const offscreenCanvas = canvas.transferControlToOffscreen();
120+
121+
// Clean up any existing worker
122+
if (decoderWorkers.current.has(deviceId)) {
123+
decoderWorkers.current.get(deviceId)?.terminate();
124+
decoderWorkers.current.delete(deviceId);
125+
}
126+
127+
// Create a new web worker to handle the stream
128+
const worker = new Worker(new URL("../../workers/scrcpyDecoder.ts", import.meta.url), { type: "module" });
129+
decoderWorkers.current.set(deviceId, worker);
130+
131+
worker.addEventListener("message", (e) => {
132+
if (e.data.type === 'sizeChanged') {
133+
logger.debug(`[Scrcpy] Size changed for ${deviceId}: ${e.data.width}x${e.data.height}`);
134+
}
135+
});
136+
139137
// Create the ReadableStream BEFORE the async codec check.
140138
// new ReadableStream() calls start() synchronously, so the real controller is placed
141139
// in readableControllers before this function ever suspends at the first await.
@@ -152,6 +150,13 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
152150
cancel() {
153151
readableControllers.delete(deviceId);
154152
isDecoderHasConfig.delete(deviceId);
153+
154+
// Terminate the worker
155+
if (decoderWorkers.current.has(deviceId)) {
156+
decoderWorkers.current.get(deviceId)?.terminate();
157+
decoderWorkers.current.delete(deviceId);
158+
}
159+
155160
// Use the canvas already in scope — canvasList captures a stale closure
156161
// (React state at render time) so canvasList[deviceId] is always undefined here.
157162
canvas.remove();
@@ -173,24 +178,24 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
173178
if (useH265 && !supported.supported) {
174179
logger.warn("[Scrcpy-VideoStreamManager] Should decode h265, but not compatible, waiting for new stream to start...");
175180
readableControllers.delete(deviceId);
181+
worker.terminate();
182+
decoderWorkers.current.delete(deviceId);
176183
return;
177184
}
178185

179186
if (supported.supported || !useH265) {
180-
const decoder = new WebCodecsVideoDecoder({
181-
codec: useH265 ? ScrcpyVideoCodecId.H265 : ScrcpyVideoCodecId.H264,
182-
renderer: renderer,
183-
// Firefox on Linux has no hardware H264 WebCodecs path; "prefer-software" enables
184-
// the software decoder (OpenH264) and avoids "encoding not supported" errors.
185-
// H265 keeps "no-preference": Chrome only supports H265 via hardware, so forcing
186-
// software would cause "OperationError: Unsupported configuration".
187-
hardwareAcceleration: useH265 ? "no-preference" : "prefer-software",
188-
});
189-
190-
// Feed the scrcpy stream to the video decoder
191-
void stream.pipeTo(decoder.writable).catch((err) => {
192-
logger.error("[Scrcpy] Error piping to decoder writable stream: {err}", { err });
193-
});
187+
const codec = useH265 ? ScrcpyVideoCodecId.H265 : ScrcpyVideoCodecId.H264;
188+
189+
// Pass objects and stream to worker
190+
worker.postMessage(
191+
{
192+
codec,
193+
canvas: offscreenCanvas,
194+
stream,
195+
useH265
196+
},
197+
[offscreenCanvas, stream]
198+
);
194199
} else {
195200
logger.error("[Scrcpy] Error piping to decoder writable stream");
196201
}
@@ -223,6 +228,12 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
223228
// Clearing these forces newVideoStream() to recreate the decoder on the next config packet.
224229
readableControllers.delete(streamId);
225230
isDecoderHasConfig.delete(streamId);
231+
232+
// Terminate the worker
233+
if (decoderWorkers.current.has(streamId)) {
234+
decoderWorkers.current.get(streamId)?.terminate();
235+
decoderWorkers.current.delete(streamId);
236+
}
226237
}
227238

228239
const ws = new WebSocket(`ws://${host}:${port}/stream/${streamId}`);
@@ -303,6 +314,13 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
303314
readableControllers.delete(streamId);
304315
isDecoderHasConfig.delete(streamId);
305316
streamIsH265.delete(streamId);
317+
318+
// Terminate the worker
319+
if (decoderWorkers.current.has(streamId)) {
320+
decoderWorkers.current.get(streamId)?.terminate();
321+
decoderWorkers.current.delete(streamId);
322+
}
323+
306324
// Remove canvas from DOM and React state
307325
const canvas = canvasRefs.current.get(streamId);
308326
if (canvas) {
@@ -396,6 +414,8 @@ const VideoStreamManager = ({ needsInteractivity, selectedCanvas, hideInfos }: V
396414
cleanedUp = true;
397415
controlSocket?.close();
398416
deviceSockets.forEach(ws => ws.close());
417+
decoderWorkers.current.forEach(worker => worker.terminate());
418+
decoderWorkers.current.clear();
399419
};
400420
}, []);
401421

src/workers/scrcpyDecoder.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ScrcpyVideoCodecId, type ScrcpyMediaStreamPacket } from "@yume-chan/scrcpy";
2+
import {
3+
WebGLVideoFrameRenderer,
4+
BitmapVideoFrameRenderer,
5+
WebCodecsVideoDecoder,
6+
} from "@yume-chan/scrcpy-decoder-webcodecs";
7+
8+
self.addEventListener("message", (e) => {
9+
const { codec, canvas, stream, useH265 } = e.data as {
10+
codec: ScrcpyVideoCodecId;
11+
canvas: OffscreenCanvas;
12+
stream: ReadableStream<ScrcpyMediaStreamPacket>;
13+
useH265: boolean;
14+
};
15+
16+
let renderer;
17+
if (WebGLVideoFrameRenderer.isSupported) {
18+
renderer = new WebGLVideoFrameRenderer(canvas);
19+
} else {
20+
renderer = new BitmapVideoFrameRenderer(canvas);
21+
}
22+
23+
const decoder = new WebCodecsVideoDecoder({
24+
codec: codec,
25+
renderer: renderer,
26+
hardwareAcceleration: useH265 ? "no-preference" : "prefer-software",
27+
});
28+
29+
decoder.sizeChanged(({ width, height }) => {
30+
postMessage({ type: 'sizeChanged', width, height });
31+
});
32+
33+
void stream.pipeTo(decoder.writable).catch((err) => {
34+
console.error("[Worker] Error piping to decoder writable stream:", err);
35+
});
36+
});

tsconfig.app.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"compilerOptions": {
33
"composite": true,
44
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5-
"target": "ES2021",
5+
"target": "ES2022",
66
"useDefineForClassFields": true,
77
"lib": ["ES2021", "DOM", "DOM.Iterable"],
88
"module": "ESNext",

0 commit comments

Comments
 (0)