@9b9387/android-stream-scrcpy 0.1.1 → 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/LICENSE +21 -0
- package/README.md +121 -9
- package/dist/backend/io/buffered-stream-reader.d.ts +2 -1
- package/dist/backend/io/buffered-stream-reader.js +13 -1
- 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 +39 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 9b9387
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ TypeScript scrcpy client library for Node.js and Electron. It starts `scrcpy-ser
|
|
|
4
4
|
|
|
5
5
|
The bundled protocol implementation targets scrcpy `4.0`. Protocol code is versioned so future versions, such as `4.1`, can be added side by side without rewriting the backend or service layers.
|
|
6
6
|
|
|
7
|
+
> Runtime note: the root package is Node-only. Use it from a Node.js process, an Electron main process, a Next.js Node runtime route/server, or another long-lived backend process. Do not import the root package from browser code, Next.js Client Components, or Edge Runtime handlers.
|
|
8
|
+
|
|
7
9
|
## Project Structure
|
|
8
10
|
|
|
9
11
|
```text
|
|
@@ -45,6 +47,10 @@ node-lib/
|
|
|
45
47
|
scrcpy-stream-service.ts
|
|
46
48
|
types.ts
|
|
47
49
|
index.ts
|
|
50
|
+
snapshot/
|
|
51
|
+
ffmpeg-snapshot-cache.ts
|
|
52
|
+
types.ts
|
|
53
|
+
index.ts
|
|
48
54
|
websocket/
|
|
49
55
|
binary-packet.ts
|
|
50
56
|
control-json.ts
|
|
@@ -59,18 +65,19 @@ node-lib/
|
|
|
59
65
|
- `src/protocol/`: scrcpy wire protocol implementation. `core/` contains shared binary helpers and protocol interfaces; `registry.ts` selects a protocol adapter by version; `v4_0/` contains scrcpy 4.0 frame, control, device, codec, and server-option logic.
|
|
60
66
|
- `src/backend/`: adbkit-only device integration. `adb/` wraps adbkit operations, `io/` contains socket reading utilities, `server/` builds and normalizes scrcpy server launch details, and `scrcpy-backend.ts` orchestrates the streaming connection.
|
|
61
67
|
- `src/service/`: public stream service. It manages state, caches decoder config/session snapshots, and exposes `for await...of` media packet subscriptions.
|
|
68
|
+
- `src/snapshot/`: optional Node-side screenshot cache. It pipes H.264 packets into ffmpeg and stores the latest JPEG frame.
|
|
62
69
|
- `src/websocket/`: optional `ws` bridge for browser clients. It is exported from the `./websocket` subpath and is not part of the root API.
|
|
63
70
|
- `examples/`: runnable examples kept outside the library build.
|
|
64
71
|
|
|
65
|
-
## Install
|
|
72
|
+
## Install
|
|
73
|
+
|
|
74
|
+
Install the package in your application:
|
|
66
75
|
|
|
67
76
|
```bash
|
|
68
|
-
|
|
69
|
-
npm install
|
|
70
|
-
npm run build
|
|
77
|
+
npm install @9b9387/android-stream-scrcpy
|
|
71
78
|
```
|
|
72
79
|
|
|
73
|
-
Core runtime dependency is
|
|
80
|
+
Core runtime dependency is `@devicefarmer/adbkit`. The library does not call the `adb` system command directly.
|
|
74
81
|
|
|
75
82
|
If your application uses the optional WebSocket bridge, install `ws` in the application:
|
|
76
83
|
|
|
@@ -78,6 +85,25 @@ If your application uses the optional WebSocket bridge, install `ws` in the appl
|
|
|
78
85
|
npm install ws
|
|
79
86
|
```
|
|
80
87
|
|
|
88
|
+
If your application uses the optional screenshot cache, make sure `ffmpeg` is available on `PATH`, or pass `ffmpegPath` to `FfmpegSnapshotCache`.
|
|
89
|
+
|
|
90
|
+
## Build From Source
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
cd node-lib
|
|
94
|
+
npm install
|
|
95
|
+
npm run build
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Package Entrypoints
|
|
99
|
+
|
|
100
|
+
- `@9b9387/android-stream-scrcpy`: Node-only service/backend API.
|
|
101
|
+
- `@9b9387/android-stream-scrcpy/protocol`: protocol adapters and types. This is the safest entrypoint for code that only needs scrcpy protocol constants, codecs, and message types.
|
|
102
|
+
- `@9b9387/android-stream-scrcpy/websocket`: optional Node-only `ws` bridge.
|
|
103
|
+
- `@9b9387/android-stream-scrcpy/snapshot`: optional Node-only ffmpeg screenshot cache.
|
|
104
|
+
|
|
105
|
+
The package is ESM-only and requires Node.js 20 or newer.
|
|
106
|
+
|
|
81
107
|
## Run Examples
|
|
82
108
|
|
|
83
109
|
Run the console stream demo:
|
|
@@ -225,22 +251,82 @@ const snapshots = new FfmpegSnapshotCache(service, {
|
|
|
225
251
|
enabled: true,
|
|
226
252
|
fps: 2,
|
|
227
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
|
|
228
259
|
});
|
|
229
260
|
|
|
230
261
|
await service.start();
|
|
231
|
-
snapshots.start();
|
|
262
|
+
snapshots.start(); // must be called after service.start()
|
|
232
263
|
|
|
264
|
+
// Non-blocking read of the most recent frame (may be null before the first):
|
|
233
265
|
const latest = snapshots.latest();
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
237
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
|
+
});
|
|
238
279
|
```
|
|
239
280
|
|
|
240
281
|
The first implementation supports H.264 input and JPEG output. The configured
|
|
241
282
|
`fps` limits how often ffmpeg emits JPEG frames; ffmpeg still receives the
|
|
242
283
|
continuous H.264 stream so inter-frame decoding remains correct.
|
|
243
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
|
+
|
|
302
|
+
## Next.js And Electron Usage
|
|
303
|
+
|
|
304
|
+
### Next.js
|
|
305
|
+
|
|
306
|
+
Use this package only from the server side of a self-hosted or long-lived Node.js runtime:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
export const runtime = "nodejs";
|
|
310
|
+
|
|
311
|
+
import { ScrcpyStreamService } from "@9b9387/android-stream-scrcpy";
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Do not import the root package from Client Components, browser bundles, Middleware,
|
|
315
|
+
or Edge Runtime route handlers. The backend opens ADB sockets and long-lived media
|
|
316
|
+
streams, so a custom Node.js server or separate local daemon is usually a better
|
|
317
|
+
fit than a short-lived serverless function.
|
|
318
|
+
|
|
319
|
+
### Electron
|
|
320
|
+
|
|
321
|
+
Create and manage `ScrcpyStreamService` in the main process. Renderer processes
|
|
322
|
+
should communicate with the main process through IPC or connect to the optional
|
|
323
|
+
WebSocket bridge.
|
|
324
|
+
|
|
325
|
+
When packaging Electron apps, make sure `assets/scrcpy-server-v4.0.jar` is copied
|
|
326
|
+
as a runtime resource. If your packager moves or packs assets into an archive,
|
|
327
|
+
pass an explicit `serverJarPath` to `ScrcpyStreamService`. If screenshot caching
|
|
328
|
+
is enabled, also ship `ffmpeg` yourself or pass `ffmpegPath`.
|
|
329
|
+
|
|
244
330
|
## Protocol Versioning
|
|
245
331
|
|
|
246
332
|
`protocolVersion` defaults to `"4.0"`:
|
|
@@ -266,6 +352,32 @@ npm run lint
|
|
|
266
352
|
npm run typecheck
|
|
267
353
|
npm test
|
|
268
354
|
npm run build
|
|
355
|
+
npm pack --dry-run
|
|
269
356
|
```
|
|
270
357
|
|
|
271
358
|
End-to-end streaming requires a connected Android device. Unit tests cover protocol serialization/parsing, backend option normalization, subscriber queue behavior, and WebSocket packet encoding.
|
|
359
|
+
|
|
360
|
+
## Publish
|
|
361
|
+
|
|
362
|
+
Before publishing, make sure you are logged in to the npm account that owns the
|
|
363
|
+
`@9b9387` scope:
|
|
364
|
+
|
|
365
|
+
```bash
|
|
366
|
+
npm whoami
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
Run the release checks and inspect the tarball contents:
|
|
370
|
+
|
|
371
|
+
```bash
|
|
372
|
+
npm run typecheck
|
|
373
|
+
npm run test
|
|
374
|
+
npm run lint
|
|
375
|
+
npm run build
|
|
376
|
+
npm pack --dry-run
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Publish the public scoped package:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
npm publish --access public
|
|
383
|
+
```
|
|
@@ -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) => {
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@9b9387/android-stream-scrcpy",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Versioned scrcpy protocol client for Node.js/Electron",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
6
8
|
"exports": {
|
|
7
9
|
".": {
|
|
8
10
|
"import": "./dist/index.js",
|
|
9
11
|
"types": "./dist/index.d.ts"
|
|
10
12
|
},
|
|
13
|
+
"./protocol": {
|
|
14
|
+
"import": "./dist/protocol/index.js",
|
|
15
|
+
"types": "./dist/protocol/index.d.ts"
|
|
16
|
+
},
|
|
11
17
|
"./websocket": {
|
|
12
18
|
"import": "./dist/websocket/index.js",
|
|
13
19
|
"types": "./dist/websocket/index.d.ts"
|
|
@@ -15,12 +21,18 @@
|
|
|
15
21
|
"./snapshot": {
|
|
16
22
|
"import": "./dist/snapshot/index.js",
|
|
17
23
|
"types": "./dist/snapshot/index.d.ts"
|
|
18
|
-
}
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
19
26
|
},
|
|
20
27
|
"files": [
|
|
21
28
|
"dist",
|
|
22
|
-
"assets"
|
|
29
|
+
"assets",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
23
32
|
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=20"
|
|
35
|
+
},
|
|
24
36
|
"scripts": {
|
|
25
37
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
26
38
|
"build": "npm run clean && tsc",
|
|
@@ -28,9 +40,33 @@
|
|
|
28
40
|
"test": "vitest run",
|
|
29
41
|
"lint": "prettier --check .",
|
|
30
42
|
"format": "prettier --write .",
|
|
43
|
+
"prepack": "npm run build",
|
|
44
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run lint && npm run build",
|
|
31
45
|
"example:demo": "npm run build && node --loader ts-node/esm examples/demo.ts",
|
|
32
46
|
"example:server": "npm run build && node --loader ts-node/esm examples/server.ts"
|
|
33
47
|
},
|
|
48
|
+
"keywords": [
|
|
49
|
+
"scrcpy",
|
|
50
|
+
"android",
|
|
51
|
+
"adb",
|
|
52
|
+
"streaming",
|
|
53
|
+
"electron",
|
|
54
|
+
"nextjs",
|
|
55
|
+
"typescript"
|
|
56
|
+
],
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+ssh://git@github.com/9b9387/android-stream.git",
|
|
61
|
+
"directory": "node-lib"
|
|
62
|
+
},
|
|
63
|
+
"bugs": {
|
|
64
|
+
"url": "https://github.com/9b9387/android-stream/issues"
|
|
65
|
+
},
|
|
66
|
+
"homepage": "https://github.com/9b9387/android-stream/tree/main/node-lib#readme",
|
|
67
|
+
"publishConfig": {
|
|
68
|
+
"access": "public"
|
|
69
|
+
},
|
|
34
70
|
"dependencies": {
|
|
35
71
|
"@devicefarmer/adbkit": "^3.2.3"
|
|
36
72
|
},
|