@9b9387/android-stream-scrcpy 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -4
- package/dist/backend/io/buffered-stream-reader.d.ts +2 -1
- package/dist/backend/io/buffered-stream-reader.js +13 -1
- package/dist/backend/server/options.d.ts +3 -1
- package/dist/backend/server/options.js +1 -0
- package/dist/backend/server/server-command.js +2 -1
- package/dist/protocol/v4_0/server-options.d.ts +1 -0
- package/dist/protocol/v4_0/server-options.js +26 -17
- package/dist/snapshot/ffmpeg-snapshot-cache.d.ts +19 -0
- package/dist/snapshot/ffmpeg-snapshot-cache.js +145 -6
- package/dist/snapshot/types.d.ts +21 -0
- package/dist/websocket/scrcpy-websocket-bridge.d.ts +2 -0
- package/dist/websocket/scrcpy-websocket-bridge.js +16 -0
- package/dist/websocket/types.d.ts +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -251,22 +251,54 @@ const snapshots = new FfmpegSnapshotCache(service, {
|
|
|
251
251
|
enabled: true,
|
|
252
252
|
fps: 2,
|
|
253
253
|
quality: 85,
|
|
254
|
+
// Optional robustness knobs (defaults shown):
|
|
255
|
+
drainTimeoutMs: 5000, // tear down ffmpeg if stdin stalls this long
|
|
256
|
+
killTimeoutMs: 2000, // SIGTERM grace before SIGKILL on stop()
|
|
257
|
+
staleTimeoutMs: 10000, // emit "stale" when no new frame for this long (0 = off)
|
|
258
|
+
maxStdoutBytes: 16 * 1024 * 1024, // cap on the JPEG reassembly buffer
|
|
254
259
|
});
|
|
255
260
|
|
|
256
261
|
await service.start();
|
|
257
|
-
snapshots.start();
|
|
262
|
+
snapshots.start(); // must be called after service.start()
|
|
258
263
|
|
|
264
|
+
// Non-blocking read of the most recent frame (may be null before the first):
|
|
259
265
|
const latest = snapshots.latest();
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
266
|
+
|
|
267
|
+
// Or wait up to N ms for the first/next frame instead of busy-polling 404s:
|
|
268
|
+
try {
|
|
269
|
+
const shot = await snapshots.waitForFresh(3000);
|
|
270
|
+
// shot.contentType === "image/jpeg"; shot.data is a JPEG Buffer
|
|
271
|
+
} catch {
|
|
272
|
+
// no frame within the timeout — surface a 503 / "warming up" to the client
|
|
263
273
|
}
|
|
274
|
+
|
|
275
|
+
// Observe a stalled stream (device asleep, encoder paused, etc.):
|
|
276
|
+
snapshots.on("stale", ({ ageMs }) => {
|
|
277
|
+
console.warn(`no new snapshot for ${ageMs}ms`);
|
|
278
|
+
});
|
|
264
279
|
```
|
|
265
280
|
|
|
266
281
|
The first implementation supports H.264 input and JPEG output. The configured
|
|
267
282
|
`fps` limits how often ffmpeg emits JPEG frames; ffmpeg still receives the
|
|
268
283
|
continuous H.264 stream so inter-frame decoding remains correct.
|
|
269
284
|
|
|
285
|
+
### Lifecycle & resource safety
|
|
286
|
+
|
|
287
|
+
- `FfmpegSnapshotCache` listens to the service state: when the service stops or
|
|
288
|
+
errors, the ffmpeg process is killed automatically, so it never lingers as an
|
|
289
|
+
orphan across reconnects. You should still call `snapshots.stop()` explicitly
|
|
290
|
+
when you tear a session down yourself.
|
|
291
|
+
- `waitForFresh()` resolves immediately if a frame is cached, otherwise it
|
|
292
|
+
resolves on the next frame or rejects after the timeout — use it in HTTP
|
|
293
|
+
handlers so a single request never hangs and clients never busy-poll.
|
|
294
|
+
- The WebSocket bridge applies backpressure: a slow client's droppable video
|
|
295
|
+
frames are dropped (config/keyframe/session packets are preserved) once its
|
|
296
|
+
outbound buffer exceeds `maxBufferedBytes` (8MiB default), preventing
|
|
297
|
+
unbounded memory growth. Tune via `new ScrcpyWebSocketBridge(service, { maxBufferedBytes })`.
|
|
298
|
+
|
|
299
|
+
For multi-device streaming and per-device screenshot HTTP routes, see
|
|
300
|
+
[docs/multi-device-screenshot.md](./docs/multi-device-screenshot.md) (Web integration guide, 中文).
|
|
301
|
+
|
|
270
302
|
## Next.js And Electron Usage
|
|
271
303
|
|
|
272
304
|
### Next.js
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import * as net from "node:net";
|
|
2
2
|
export declare class BufferedStreamReader {
|
|
3
|
+
private readonly maxBufferBytes;
|
|
3
4
|
private buffer;
|
|
4
5
|
private waiters;
|
|
5
6
|
private ended;
|
|
6
|
-
constructor(stream: net.Socket);
|
|
7
|
+
constructor(stream: net.Socket, maxBufferBytes?: number);
|
|
7
8
|
readExact(n: number): Promise<Uint8Array>;
|
|
8
9
|
private rejectAll;
|
|
9
10
|
private checkWaiters;
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import { concatBytes } from "../../protocol/core/binary.js";
|
|
2
|
+
// Safety cap on the reassembly buffer. A single scrcpy frame is well under
|
|
3
|
+
// this; exceeding it means the stream is desynchronized or a bogus frame size
|
|
4
|
+
// was read, so we fail fast instead of growing memory without bound.
|
|
5
|
+
const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
|
|
2
6
|
export class BufferedStreamReader {
|
|
7
|
+
maxBufferBytes;
|
|
3
8
|
buffer = new Uint8Array(0);
|
|
4
9
|
waiters = [];
|
|
5
10
|
ended = false;
|
|
6
|
-
constructor(stream) {
|
|
11
|
+
constructor(stream, maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES) {
|
|
12
|
+
this.maxBufferBytes = maxBufferBytes;
|
|
7
13
|
stream.on("data", (chunk) => {
|
|
8
14
|
this.buffer = concatBytes([this.buffer, chunk]);
|
|
15
|
+
if (this.buffer.byteLength > this.maxBufferBytes) {
|
|
16
|
+
this.ended = true;
|
|
17
|
+
this.rejectAll(new Error(`stream reader buffer exceeded ${this.maxBufferBytes} bytes; aborting`));
|
|
18
|
+
stream.destroy();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
9
21
|
this.checkWaiters();
|
|
10
22
|
});
|
|
11
23
|
stream.on("error", (err) => {
|
|
@@ -13,14 +13,16 @@ export interface ScrcpyBackendOptions {
|
|
|
13
13
|
control?: boolean;
|
|
14
14
|
videoCodec?: VideoCodec;
|
|
15
15
|
audioCodec?: AudioCodec;
|
|
16
|
+
videoEncoder?: string;
|
|
16
17
|
connectionTimeoutMs?: number;
|
|
17
18
|
deployTimeoutMs?: number;
|
|
18
19
|
pushTimeoutMs?: number;
|
|
19
20
|
serverJarPath?: string;
|
|
20
21
|
}
|
|
21
|
-
export interface NormalizedScrcpyBackendOptions extends Required<Omit<ScrcpyBackendOptions, "serverJarPath">> {
|
|
22
|
+
export interface NormalizedScrcpyBackendOptions extends Required<Omit<ScrcpyBackendOptions, "serverJarPath" | "videoEncoder">> {
|
|
22
23
|
protocol: ScrcpyProtocol;
|
|
23
24
|
socketName: string;
|
|
24
25
|
serverJarPath: string;
|
|
26
|
+
videoEncoder?: string;
|
|
25
27
|
}
|
|
26
28
|
export declare function normalizeBackendOptions(options?: ScrcpyBackendOptions): NormalizedScrcpyBackendOptions;
|
|
@@ -22,6 +22,7 @@ export function normalizeBackendOptions(options = {}) {
|
|
|
22
22
|
control: options.control !== false,
|
|
23
23
|
videoCodec: options.videoCodec ?? VideoCodec.H264,
|
|
24
24
|
audioCodec: options.audioCodec ?? AudioCodec.OPUS,
|
|
25
|
+
videoEncoder: options.videoEncoder,
|
|
25
26
|
connectionTimeoutMs: options.connectionTimeoutMs ?? 8000,
|
|
26
27
|
deployTimeoutMs: options.deployTimeoutMs ?? 5000,
|
|
27
28
|
pushTimeoutMs: options.pushTimeoutMs ?? 60000,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export function getDeviceServerPath(serverVersion) {
|
|
2
|
-
return
|
|
2
|
+
return "/data/local/tmp/scrcpy-server.jar";
|
|
3
3
|
}
|
|
4
4
|
export function buildServerCommand(deviceServerPath, options) {
|
|
5
5
|
const args = [
|
|
@@ -14,6 +14,7 @@ export function buildServerCommand(deviceServerPath, options) {
|
|
|
14
14
|
control: options.control,
|
|
15
15
|
videoCodec: options.videoCodec,
|
|
16
16
|
audioCodec: options.audioCodec,
|
|
17
|
+
videoEncoder: options.videoEncoder,
|
|
17
18
|
maxSize: options.maxSize,
|
|
18
19
|
maxFps: options.maxFps,
|
|
19
20
|
videoBitRate: options.videoBitRate,
|
|
@@ -11,28 +11,37 @@ export function getSocketName(scid = DEFAULT_SCRCPY_SCID) {
|
|
|
11
11
|
}
|
|
12
12
|
export function buildServerOptions(input) {
|
|
13
13
|
const scid = input.scid ?? DEFAULT_SCRCPY_SCID;
|
|
14
|
-
|
|
14
|
+
const opts = [
|
|
15
15
|
SCRCPY_4_0_SERVER_VERSION,
|
|
16
|
+
`scid=${(scid >>> 0).toString(16).padStart(8, "0")}`,
|
|
16
17
|
`log_level=${input.logLevel ?? "info"}`,
|
|
17
18
|
"tunnel_forward=true",
|
|
18
|
-
`scid=${scid.toString(16)}`,
|
|
19
19
|
`video=${input.video}`,
|
|
20
20
|
`audio=${input.audio}`,
|
|
21
21
|
`control=${input.control}`,
|
|
22
|
-
`video_codec=${input.videoCodec}`,
|
|
23
|
-
`audio_codec=${input.audioCodec}`,
|
|
24
|
-
`max_size=${input.maxSize}`,
|
|
25
|
-
`max_fps=${input.maxFps}`,
|
|
26
|
-
`video_bit_rate=${input.videoBitRate}`,
|
|
27
|
-
`audio_bit_rate=${input.audioBitRate}`,
|
|
28
|
-
"send_device_meta=true",
|
|
29
|
-
"send_frame_meta=true",
|
|
30
|
-
"send_dummy_byte=true",
|
|
31
|
-
"send_stream_meta=true",
|
|
32
|
-
"cleanup=true",
|
|
33
|
-
"stay_awake=false",
|
|
34
|
-
"show_touches=false",
|
|
35
|
-
"power_off_on_close=false",
|
|
36
|
-
"clipboard_autosync=false",
|
|
37
22
|
];
|
|
23
|
+
if (input.videoCodec) {
|
|
24
|
+
opts.push(`video_codec=${input.videoCodec}`);
|
|
25
|
+
}
|
|
26
|
+
if (input.audioCodec) {
|
|
27
|
+
opts.push(`audio_codec=${input.audioCodec}`);
|
|
28
|
+
}
|
|
29
|
+
if (input.videoEncoder) {
|
|
30
|
+
opts.push(`video_encoder=${input.videoEncoder}`);
|
|
31
|
+
}
|
|
32
|
+
if (input.maxSize > 0) {
|
|
33
|
+
opts.push(`max_size=${input.maxSize}`);
|
|
34
|
+
}
|
|
35
|
+
if (input.maxFps > 0) {
|
|
36
|
+
opts.push(`max_fps=${input.maxFps}`);
|
|
37
|
+
}
|
|
38
|
+
if (input.videoBitRate > 0 && input.videoBitRate !== 8000000) {
|
|
39
|
+
opts.push(`video_bit_rate=${input.videoBitRate}`);
|
|
40
|
+
}
|
|
41
|
+
if (input.audioBitRate > 0 && input.audioBitRate !== 128000) {
|
|
42
|
+
opts.push(`audio_bit_rate=${input.audioBitRate}`);
|
|
43
|
+
}
|
|
44
|
+
// Other options like cleanup, send_device_meta, etc. use server-side defaults
|
|
45
|
+
// to keep the command line short and avoid Samsung-specific crashes.
|
|
46
|
+
return opts;
|
|
38
47
|
}
|
|
@@ -12,6 +12,10 @@ export declare class FfmpegSnapshotCache extends EventEmitter {
|
|
|
12
12
|
private readonly fps;
|
|
13
13
|
private readonly quality;
|
|
14
14
|
private readonly ffmpegPath;
|
|
15
|
+
private readonly drainTimeoutMs;
|
|
16
|
+
private readonly killTimeoutMs;
|
|
17
|
+
private readonly staleTimeoutMs;
|
|
18
|
+
private readonly maxStdoutBytes;
|
|
15
19
|
private readonly spawnProcess;
|
|
16
20
|
private process;
|
|
17
21
|
private subscription;
|
|
@@ -19,16 +23,31 @@ export declare class FfmpegSnapshotCache extends EventEmitter {
|
|
|
19
23
|
private stderrTail;
|
|
20
24
|
private currentSnapshot;
|
|
21
25
|
private running;
|
|
26
|
+
private staleTimer;
|
|
27
|
+
private staleEmitted;
|
|
28
|
+
private readonly onServiceState;
|
|
22
29
|
constructor(service: ScrcpyStreamService, options?: SnapshotCacheOptions);
|
|
23
30
|
start(): void;
|
|
24
31
|
stop(): void;
|
|
25
32
|
latest(): Snapshot | null;
|
|
33
|
+
/**
|
|
34
|
+
* Resolve with a snapshot, waiting up to `timeoutMs` for the first frame if
|
|
35
|
+
* the cache has not produced one yet. Lets HTTP handlers avoid both a busy
|
|
36
|
+
* 404-poll loop and an unbounded hang while ffmpeg warms up.
|
|
37
|
+
*/
|
|
38
|
+
waitForFresh(timeoutMs?: number): Promise<Snapshot>;
|
|
26
39
|
private buildFfmpegArgs;
|
|
27
40
|
private pumpVideoPackets;
|
|
28
41
|
private writePacket;
|
|
42
|
+
private waitForDrain;
|
|
29
43
|
private handleStdout;
|
|
30
44
|
private handleStderr;
|
|
31
45
|
private handleProcessError;
|
|
46
|
+
private handlePumpError;
|
|
47
|
+
private startStaleWatchdog;
|
|
48
|
+
private bindServiceState;
|
|
49
|
+
private unbindServiceState;
|
|
32
50
|
private cleanup;
|
|
51
|
+
private killProcess;
|
|
33
52
|
private formatStderrTail;
|
|
34
53
|
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { EventEmitter } from "node:events";
|
|
3
|
-
import {
|
|
4
|
-
import { MediaKind, } from "../service/index.js";
|
|
3
|
+
import { MediaKind, StreamState, } from "../service/index.js";
|
|
5
4
|
import { VideoCodec } from "../protocol/index.js";
|
|
6
5
|
const JPEG_SOI = Buffer.from([0xff, 0xd8]);
|
|
7
6
|
const JPEG_EOI = Buffer.from([0xff, 0xd9]);
|
|
8
7
|
const DEFAULT_SNAPSHOT_FPS = 2;
|
|
9
8
|
const DEFAULT_JPEG_QUALITY = 85;
|
|
9
|
+
const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
|
|
10
|
+
const DEFAULT_KILL_TIMEOUT_MS = 2000;
|
|
11
|
+
const DEFAULT_STALE_TIMEOUT_MS = 10000;
|
|
12
|
+
const DEFAULT_MAX_STDOUT_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
const STALE_CHECK_INTERVAL_MS = 1000;
|
|
10
14
|
export function extractJpegFrames(buffer) {
|
|
11
15
|
const frames = [];
|
|
12
16
|
let offset = 0;
|
|
@@ -34,6 +38,10 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
34
38
|
fps;
|
|
35
39
|
quality;
|
|
36
40
|
ffmpegPath;
|
|
41
|
+
drainTimeoutMs;
|
|
42
|
+
killTimeoutMs;
|
|
43
|
+
staleTimeoutMs;
|
|
44
|
+
maxStdoutBytes;
|
|
37
45
|
spawnProcess;
|
|
38
46
|
process = null;
|
|
39
47
|
subscription = null;
|
|
@@ -41,6 +49,9 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
41
49
|
stderrTail = "";
|
|
42
50
|
currentSnapshot = null;
|
|
43
51
|
running = false;
|
|
52
|
+
staleTimer = null;
|
|
53
|
+
staleEmitted = false;
|
|
54
|
+
onServiceState;
|
|
44
55
|
constructor(service, options = {}) {
|
|
45
56
|
super();
|
|
46
57
|
this.service = service;
|
|
@@ -48,11 +59,23 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
48
59
|
this.fps = normalizeFps(options.fps ?? DEFAULT_SNAPSHOT_FPS);
|
|
49
60
|
this.quality = normalizeJpegQuality(options.quality ?? DEFAULT_JPEG_QUALITY);
|
|
50
61
|
this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
|
|
62
|
+
this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
|
|
63
|
+
this.killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
|
|
64
|
+
this.staleTimeoutMs = options.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
|
|
65
|
+
this.maxStdoutBytes = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT_BYTES;
|
|
51
66
|
this.spawnProcess =
|
|
52
67
|
options.spawnProcess ??
|
|
53
68
|
((command, args) => spawn(command, args, {
|
|
54
69
|
stdio: ["pipe", "pipe", "pipe"],
|
|
55
70
|
}));
|
|
71
|
+
this.onServiceState = (state) => {
|
|
72
|
+
// When the underlying stream stops or errors out, the subscription dries
|
|
73
|
+
// up but ffmpeg would otherwise linger as an orphan process. Tear it down
|
|
74
|
+
// so each device's ffmpeg lifetime is bounded by its service.
|
|
75
|
+
if (state === StreamState.STOPPED || state === StreamState.ERROR) {
|
|
76
|
+
this.stop();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
56
79
|
}
|
|
57
80
|
start() {
|
|
58
81
|
if (!this.enabled || this.running)
|
|
@@ -62,6 +85,8 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
62
85
|
throw new Error(`snapshot cache only supports h264 video, got ${videoCodec}`);
|
|
63
86
|
}
|
|
64
87
|
this.running = true;
|
|
88
|
+
this.staleEmitted = false;
|
|
89
|
+
this.bindServiceState();
|
|
65
90
|
this.process = this.spawnProcess(this.ffmpegPath, this.buildFfmpegArgs());
|
|
66
91
|
this.process.stdout.on("data", (chunk) => this.handleStdout(chunk));
|
|
67
92
|
this.process.stderr.on("data", (chunk) => {
|
|
@@ -75,6 +100,7 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
75
100
|
}
|
|
76
101
|
});
|
|
77
102
|
this.subscription = this.service.subscribe();
|
|
103
|
+
this.startStaleWatchdog();
|
|
78
104
|
void this.pumpVideoPackets();
|
|
79
105
|
}
|
|
80
106
|
stop() {
|
|
@@ -83,6 +109,40 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
83
109
|
latest() {
|
|
84
110
|
return this.currentSnapshot;
|
|
85
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve with a snapshot, waiting up to `timeoutMs` for the first frame if
|
|
114
|
+
* the cache has not produced one yet. Lets HTTP handlers avoid both a busy
|
|
115
|
+
* 404-poll loop and an unbounded hang while ffmpeg warms up.
|
|
116
|
+
*/
|
|
117
|
+
waitForFresh(timeoutMs = 3000) {
|
|
118
|
+
if (this.currentSnapshot)
|
|
119
|
+
return Promise.resolve(this.currentSnapshot);
|
|
120
|
+
if (!this.running || !this.enabled) {
|
|
121
|
+
return Promise.reject(new Error("snapshot cache is not running"));
|
|
122
|
+
}
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const onShot = (shot) => {
|
|
125
|
+
cleanup();
|
|
126
|
+
resolve(shot);
|
|
127
|
+
};
|
|
128
|
+
const onError = (e) => {
|
|
129
|
+
cleanup();
|
|
130
|
+
reject(e);
|
|
131
|
+
};
|
|
132
|
+
const timer = setTimeout(() => {
|
|
133
|
+
cleanup();
|
|
134
|
+
reject(new Error(`snapshot not available within ${timeoutMs}ms`));
|
|
135
|
+
}, timeoutMs);
|
|
136
|
+
timer.unref?.();
|
|
137
|
+
const cleanup = () => {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
this.removeListener("snapshot", onShot);
|
|
140
|
+
this.removeListener("error", onError);
|
|
141
|
+
};
|
|
142
|
+
this.once("snapshot", onShot);
|
|
143
|
+
this.once("error", onError);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
86
146
|
buildFfmpegArgs() {
|
|
87
147
|
return [
|
|
88
148
|
"-hide_banner",
|
|
@@ -124,17 +184,45 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
124
184
|
}
|
|
125
185
|
}
|
|
126
186
|
catch (e) {
|
|
127
|
-
|
|
128
|
-
this.emit("error", e);
|
|
187
|
+
this.handlePumpError(e);
|
|
129
188
|
}
|
|
130
189
|
}
|
|
131
190
|
async writePacket(proc, packet) {
|
|
132
191
|
if (proc.stdin.write(packet.payload))
|
|
133
192
|
return;
|
|
134
|
-
await
|
|
193
|
+
await this.waitForDrain(proc);
|
|
194
|
+
}
|
|
195
|
+
waitForDrain(proc) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const onDrain = () => {
|
|
198
|
+
cleanup();
|
|
199
|
+
resolve();
|
|
200
|
+
};
|
|
201
|
+
const onError = (e) => {
|
|
202
|
+
cleanup();
|
|
203
|
+
reject(e);
|
|
204
|
+
};
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
cleanup();
|
|
207
|
+
reject(new Error(`ffmpeg stdin did not drain within ${this.drainTimeoutMs}ms`));
|
|
208
|
+
}, this.drainTimeoutMs);
|
|
209
|
+
timer.unref?.();
|
|
210
|
+
const cleanup = () => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
proc.stdin.removeListener("drain", onDrain);
|
|
213
|
+
proc.stdin.removeListener("error", onError);
|
|
214
|
+
};
|
|
215
|
+
proc.stdin.once("drain", onDrain);
|
|
216
|
+
proc.stdin.once("error", onError);
|
|
217
|
+
});
|
|
135
218
|
}
|
|
136
219
|
handleStdout(chunk) {
|
|
137
220
|
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
|
|
221
|
+
// Guard against a corrupt/headerless ffmpeg stream where no EOI ever
|
|
222
|
+
// arrives: without a cap the remainder would grow without bound.
|
|
223
|
+
if (this.stdoutBuffer.byteLength > this.maxStdoutBytes) {
|
|
224
|
+
this.stdoutBuffer = this.stdoutBuffer.subarray(-this.maxStdoutBytes);
|
|
225
|
+
}
|
|
138
226
|
const result = extractJpegFrames(this.stdoutBuffer);
|
|
139
227
|
this.stdoutBuffer = result.remainder;
|
|
140
228
|
for (const frame of result.frames) {
|
|
@@ -143,6 +231,7 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
143
231
|
data: frame,
|
|
144
232
|
timestampMs: Date.now(),
|
|
145
233
|
};
|
|
234
|
+
this.staleEmitted = false;
|
|
146
235
|
this.emit("snapshot", this.currentSnapshot);
|
|
147
236
|
}
|
|
148
237
|
}
|
|
@@ -157,8 +246,44 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
157
246
|
this.cleanup({ clearSnapshot: true, killProcess: false });
|
|
158
247
|
this.emit("error", e);
|
|
159
248
|
}
|
|
249
|
+
handlePumpError(e) {
|
|
250
|
+
if (!this.running)
|
|
251
|
+
return;
|
|
252
|
+
// A pump failure (e.g. stdin stalled past the drain timeout) means ffmpeg
|
|
253
|
+
// is wedged; kill it so the cache can be restarted cleanly.
|
|
254
|
+
this.cleanup({ clearSnapshot: true, killProcess: true });
|
|
255
|
+
this.emit("error", e);
|
|
256
|
+
}
|
|
257
|
+
startStaleWatchdog() {
|
|
258
|
+
if (this.staleTimeoutMs <= 0)
|
|
259
|
+
return;
|
|
260
|
+
const timer = setInterval(() => {
|
|
261
|
+
if (!this.running || !this.currentSnapshot || this.staleEmitted)
|
|
262
|
+
return;
|
|
263
|
+
const age = Date.now() - this.currentSnapshot.timestampMs;
|
|
264
|
+
if (age > this.staleTimeoutMs) {
|
|
265
|
+
this.staleEmitted = true;
|
|
266
|
+
this.emit("stale", { ageMs: age, snapshot: this.currentSnapshot });
|
|
267
|
+
}
|
|
268
|
+
}, STALE_CHECK_INTERVAL_MS);
|
|
269
|
+
timer.unref?.();
|
|
270
|
+
this.staleTimer = timer;
|
|
271
|
+
}
|
|
272
|
+
bindServiceState() {
|
|
273
|
+
const emitter = this.service;
|
|
274
|
+
emitter.on?.("state", this.onServiceState);
|
|
275
|
+
}
|
|
276
|
+
unbindServiceState() {
|
|
277
|
+
const emitter = this.service;
|
|
278
|
+
emitter.removeListener?.("state", this.onServiceState);
|
|
279
|
+
}
|
|
160
280
|
cleanup(options) {
|
|
161
281
|
this.running = false;
|
|
282
|
+
this.unbindServiceState();
|
|
283
|
+
if (this.staleTimer) {
|
|
284
|
+
clearInterval(this.staleTimer);
|
|
285
|
+
this.staleTimer = null;
|
|
286
|
+
}
|
|
162
287
|
this.subscription?.return?.();
|
|
163
288
|
this.subscription = null;
|
|
164
289
|
const proc = this.process;
|
|
@@ -166,13 +291,27 @@ export class FfmpegSnapshotCache extends EventEmitter {
|
|
|
166
291
|
if (proc) {
|
|
167
292
|
proc.stdin.end();
|
|
168
293
|
if (options.killProcess)
|
|
169
|
-
|
|
294
|
+
this.killProcess(proc);
|
|
170
295
|
}
|
|
171
296
|
this.stdoutBuffer = Buffer.alloc(0);
|
|
172
297
|
this.stderrTail = "";
|
|
173
298
|
if (options.clearSnapshot)
|
|
174
299
|
this.currentSnapshot = null;
|
|
175
300
|
}
|
|
301
|
+
killProcess(proc) {
|
|
302
|
+
proc.kill();
|
|
303
|
+
// Escalate to SIGKILL if ffmpeg ignores the polite signal.
|
|
304
|
+
const timer = setTimeout(() => {
|
|
305
|
+
try {
|
|
306
|
+
proc.kill("SIGKILL");
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
// process already gone
|
|
310
|
+
}
|
|
311
|
+
}, this.killTimeoutMs);
|
|
312
|
+
timer.unref?.();
|
|
313
|
+
proc.once("exit", () => clearTimeout(timer));
|
|
314
|
+
}
|
|
176
315
|
formatStderrTail() {
|
|
177
316
|
const tail = this.stderrTail.trim();
|
|
178
317
|
return tail ? ` stderr=${tail}` : "";
|
package/dist/snapshot/types.d.ts
CHANGED
|
@@ -9,6 +9,27 @@ export interface SnapshotCacheOptions {
|
|
|
9
9
|
fps?: number;
|
|
10
10
|
quality?: number;
|
|
11
11
|
ffmpegPath?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Max time to wait for ffmpeg stdin to drain before treating the process as
|
|
14
|
+
* stalled and tearing it down. Defaults to 5000ms.
|
|
15
|
+
*/
|
|
16
|
+
drainTimeoutMs?: number;
|
|
17
|
+
/**
|
|
18
|
+
* Grace period after SIGTERM before escalating to SIGKILL on stop. Defaults
|
|
19
|
+
* to 2000ms.
|
|
20
|
+
*/
|
|
21
|
+
killTimeoutMs?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Emit a `stale` event when the newest snapshot is older than this many ms
|
|
24
|
+
* (e.g. the device stopped producing frames). 0 disables. Defaults to
|
|
25
|
+
* 10000ms.
|
|
26
|
+
*/
|
|
27
|
+
staleTimeoutMs?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Cap on the internal ffmpeg stdout reassembly buffer, guarding against a
|
|
30
|
+
* corrupt stream with no JPEG end marker. Defaults to 16MiB.
|
|
31
|
+
*/
|
|
32
|
+
maxStdoutBytes?: number;
|
|
12
33
|
spawnProcess?: FfmpegProcessFactory;
|
|
13
34
|
}
|
|
14
35
|
export type FfmpegProcess = Pick<ChildProcessWithoutNullStreams, "stdin" | "stdout" | "stderr" | "kill" | "on" | "once">;
|
|
@@ -3,8 +3,10 @@ import { WebSocketBridgeOptions } from "./types.js";
|
|
|
3
3
|
export declare class ScrcpyWebSocketBridge {
|
|
4
4
|
private service;
|
|
5
5
|
private wss;
|
|
6
|
+
private readonly maxBufferedBytes;
|
|
6
7
|
constructor(service: ScrcpyStreamService, options?: WebSocketBridgeOptions);
|
|
7
8
|
close(): void;
|
|
8
9
|
private handleConnection;
|
|
10
|
+
private isDroppable;
|
|
9
11
|
private createInitMessage;
|
|
10
12
|
}
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { WebSocket, WebSocketServer } from "ws";
|
|
2
|
+
import { MediaKind } from "../service/types.js";
|
|
2
3
|
import { serializeMediaPacket } from "./binary-packet.js";
|
|
3
4
|
import { parseControlJson } from "./control-json.js";
|
|
4
5
|
import { WEBSOCKET_HEADER_SIZE, WEBSOCKET_KIND_AUDIO, WEBSOCKET_KIND_SESSION, WEBSOCKET_KIND_VIDEO, } from "./types.js";
|
|
6
|
+
const DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
|
|
5
7
|
export class ScrcpyWebSocketBridge {
|
|
6
8
|
service;
|
|
7
9
|
wss;
|
|
10
|
+
maxBufferedBytes;
|
|
8
11
|
constructor(service, options = {}) {
|
|
9
12
|
this.service = service;
|
|
13
|
+
this.maxBufferedBytes =
|
|
14
|
+
options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
|
|
10
15
|
this.wss = new WebSocketServer({
|
|
11
16
|
port: options.port,
|
|
12
17
|
server: options.server,
|
|
@@ -38,6 +43,14 @@ export class ScrcpyWebSocketBridge {
|
|
|
38
43
|
for await (const packet of subscription) {
|
|
39
44
|
if (ws.readyState !== WebSocket.OPEN)
|
|
40
45
|
break;
|
|
46
|
+
// Apply backpressure: when the client falls behind, drop droppable
|
|
47
|
+
// frames instead of letting ws buffer them without bound. Config,
|
|
48
|
+
// key frames and session packets are kept so the decoder can recover.
|
|
49
|
+
if (this.maxBufferedBytes > 0 &&
|
|
50
|
+
ws.bufferedAmount > this.maxBufferedBytes &&
|
|
51
|
+
this.isDroppable(packet)) {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
41
54
|
ws.send(serializeMediaPacket(packet));
|
|
42
55
|
}
|
|
43
56
|
}
|
|
@@ -45,6 +58,9 @@ export class ScrcpyWebSocketBridge {
|
|
|
45
58
|
ws.close();
|
|
46
59
|
}
|
|
47
60
|
}
|
|
61
|
+
isDroppable(packet) {
|
|
62
|
+
return (packet.kind === MediaKind.VIDEO && !packet.config && !packet.keyFrame);
|
|
63
|
+
}
|
|
48
64
|
createInitMessage() {
|
|
49
65
|
const meta = this.service.currentMeta;
|
|
50
66
|
return {
|
|
@@ -2,6 +2,12 @@ export interface WebSocketBridgeOptions {
|
|
|
2
2
|
port?: number;
|
|
3
3
|
server?: any;
|
|
4
4
|
path?: string;
|
|
5
|
+
/**
|
|
6
|
+
* Drop media frames for a connection whose outbound buffer exceeds this many
|
|
7
|
+
* bytes. Protects the process from unbounded memory growth when a client is
|
|
8
|
+
* slower than the stream. 0 disables. Defaults to 8MiB.
|
|
9
|
+
*/
|
|
10
|
+
maxBufferedBytes?: number;
|
|
5
11
|
}
|
|
6
12
|
export declare const WEBSOCKET_HEADER_SIZE = 16;
|
|
7
13
|
export declare const WEBSOCKET_KIND_VIDEO = 1;
|