autoglm-gui 0.4.9__py3-none-any.whl → 0.4.12__py3-none-any.whl
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.
- AutoGLM_GUI/__init__.py +8 -0
- AutoGLM_GUI/__main__.py +64 -21
- AutoGLM_GUI/adb_plus/__init__.py +8 -0
- AutoGLM_GUI/adb_plus/device.py +50 -0
- AutoGLM_GUI/adb_plus/ip.py +78 -0
- AutoGLM_GUI/adb_plus/keyboard_installer.py +380 -0
- AutoGLM_GUI/adb_plus/serial.py +35 -0
- AutoGLM_GUI/api/__init__.py +8 -0
- AutoGLM_GUI/api/agents.py +132 -1
- AutoGLM_GUI/api/devices.py +96 -6
- AutoGLM_GUI/api/media.py +13 -243
- AutoGLM_GUI/config_manager.py +565 -0
- AutoGLM_GUI/exceptions.py +7 -0
- AutoGLM_GUI/logger.py +85 -0
- AutoGLM_GUI/platform_utils.py +30 -5
- AutoGLM_GUI/schemas.py +50 -0
- AutoGLM_GUI/scrcpy_protocol.py +46 -0
- AutoGLM_GUI/scrcpy_stream.py +208 -327
- AutoGLM_GUI/server.py +7 -2
- AutoGLM_GUI/socketio_server.py +125 -0
- AutoGLM_GUI/state.py +2 -1
- AutoGLM_GUI/static/assets/{about-BI6OV6gm.js → about-kgOkkOWe.js} +1 -1
- AutoGLM_GUI/static/assets/chat-CZV3RByK.js +149 -0
- AutoGLM_GUI/static/assets/{index-Do7ha9Kf.js → index-BPYHsweG.js} +1 -1
- AutoGLM_GUI/static/assets/index-Beu9cbSy.css +1 -0
- AutoGLM_GUI/static/assets/index-DfI_Z1Cx.js +10 -0
- AutoGLM_GUI/static/assets/worker-D6BRitjy.js +1 -0
- AutoGLM_GUI/static/index.html +2 -2
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/METADATA +15 -2
- autoglm_gui-0.4.12.dist-info/RECORD +56 -0
- AutoGLM_GUI/static/assets/chat-C_2Cot0q.js +0 -25
- AutoGLM_GUI/static/assets/index-DCrxTz-A.css +0 -1
- AutoGLM_GUI/static/assets/index-Dn3vR6uV.js +0 -10
- autoglm_gui-0.4.9.dist-info/RECORD +0 -46
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/WHEEL +0 -0
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/entry_points.txt +0 -0
- {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/scrcpy_stream.py
CHANGED
|
@@ -1,17 +1,47 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Scrcpy video streaming implementation (ya-webadb protocol aligned)."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import os
|
|
5
5
|
import socket
|
|
6
6
|
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import dataclass
|
|
7
9
|
from pathlib import Path
|
|
8
10
|
from typing import Any
|
|
9
11
|
|
|
12
|
+
from AutoGLM_GUI.adb_plus import check_device_available
|
|
13
|
+
from AutoGLM_GUI.logger import logger
|
|
10
14
|
from AutoGLM_GUI.platform_utils import is_windows, run_cmd_silently, spawn_process
|
|
15
|
+
from AutoGLM_GUI.scrcpy_protocol import (
|
|
16
|
+
PTS_CONFIG,
|
|
17
|
+
PTS_KEYFRAME,
|
|
18
|
+
SCRCPY_CODEC_NAME_TO_ID,
|
|
19
|
+
SCRCPY_KNOWN_CODECS,
|
|
20
|
+
ScrcpyMediaStreamPacket,
|
|
21
|
+
ScrcpyVideoStreamMetadata,
|
|
22
|
+
ScrcpyVideoStreamOptions,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ScrcpyServerOptions:
|
|
28
|
+
max_size: int
|
|
29
|
+
bit_rate: int
|
|
30
|
+
max_fps: int
|
|
31
|
+
tunnel_forward: bool
|
|
32
|
+
audio: bool
|
|
33
|
+
control: bool
|
|
34
|
+
cleanup: bool
|
|
35
|
+
video_codec: str
|
|
36
|
+
send_frame_meta: bool
|
|
37
|
+
send_device_meta: bool
|
|
38
|
+
send_codec_meta: bool
|
|
39
|
+
send_dummy_byte: bool
|
|
40
|
+
video_codec_options: str | None
|
|
11
41
|
|
|
12
42
|
|
|
13
43
|
class ScrcpyStreamer:
|
|
14
|
-
"""Manages scrcpy server lifecycle and
|
|
44
|
+
"""Manages scrcpy server lifecycle and video stream parsing."""
|
|
15
45
|
|
|
16
46
|
def __init__(
|
|
17
47
|
self,
|
|
@@ -20,6 +50,7 @@ class ScrcpyStreamer:
|
|
|
20
50
|
bit_rate: int = 1_000_000,
|
|
21
51
|
port: int = 27183,
|
|
22
52
|
idr_interval_s: int = 1,
|
|
53
|
+
stream_options: ScrcpyVideoStreamOptions | None = None,
|
|
23
54
|
):
|
|
24
55
|
"""Initialize ScrcpyStreamer.
|
|
25
56
|
|
|
@@ -29,48 +60,49 @@ class ScrcpyStreamer:
|
|
|
29
60
|
bit_rate: Video bitrate in bps
|
|
30
61
|
port: TCP port for scrcpy socket
|
|
31
62
|
idr_interval_s: Seconds between IDR frames (controls GOP length)
|
|
63
|
+
stream_options: Scrcpy protocol options for metadata/frame parsing
|
|
32
64
|
"""
|
|
33
65
|
self.device_id = device_id
|
|
34
66
|
self.max_size = max_size
|
|
35
67
|
self.bit_rate = bit_rate
|
|
36
68
|
self.port = port
|
|
37
69
|
self.idr_interval_s = idr_interval_s
|
|
70
|
+
self.stream_options = stream_options or ScrcpyVideoStreamOptions()
|
|
38
71
|
|
|
39
72
|
self.scrcpy_process: Any | None = None
|
|
40
73
|
self.tcp_socket: socket.socket | None = None
|
|
41
74
|
self.forward_cleanup_needed = False
|
|
42
75
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
self.cached_sps: bytes | None = None
|
|
47
|
-
self.cached_pps: bytes | None = None
|
|
48
|
-
self.cached_idr: bytes | None = None # Last IDR frame for immediate playback
|
|
49
|
-
self.sps_pps_locked = False # Lock SPS/PPS after initial complete capture
|
|
50
|
-
# Note: IDR is NOT locked - we keep updating to the latest frame
|
|
51
|
-
|
|
52
|
-
# NAL unit reading buffer (for read_nal_unit method)
|
|
53
|
-
self._nal_read_buffer = bytearray()
|
|
76
|
+
self._read_buffer = bytearray()
|
|
77
|
+
self._metadata: ScrcpyVideoStreamMetadata | None = None
|
|
78
|
+
self._dummy_byte_skipped = False
|
|
54
79
|
|
|
55
80
|
# Find scrcpy-server location
|
|
56
81
|
self.scrcpy_server_path = self._find_scrcpy_server()
|
|
57
82
|
|
|
58
83
|
def _find_scrcpy_server(self) -> str:
|
|
59
84
|
"""Find scrcpy-server binary path."""
|
|
60
|
-
# Priority 1:
|
|
85
|
+
# Priority 1: PyInstaller bundled path (for packaged executable)
|
|
86
|
+
if getattr(sys, "_MEIPASS", None):
|
|
87
|
+
bundled_server = Path(sys._MEIPASS) / "scrcpy-server-v3.3.3"
|
|
88
|
+
if bundled_server.exists():
|
|
89
|
+
logger.info(f"Using bundled scrcpy-server: {bundled_server}")
|
|
90
|
+
return str(bundled_server)
|
|
91
|
+
|
|
92
|
+
# Priority 2: Project root directory (for repository version)
|
|
61
93
|
project_root = Path(__file__).parent.parent
|
|
62
94
|
project_server = project_root / "scrcpy-server-v3.3.3"
|
|
63
95
|
if project_server.exists():
|
|
64
|
-
|
|
96
|
+
logger.info(f"Using project scrcpy-server: {project_server}")
|
|
65
97
|
return str(project_server)
|
|
66
98
|
|
|
67
|
-
# Priority
|
|
99
|
+
# Priority 3: Environment variable
|
|
68
100
|
scrcpy_server = os.getenv("SCRCPY_SERVER_PATH")
|
|
69
101
|
if scrcpy_server and os.path.exists(scrcpy_server):
|
|
70
|
-
|
|
102
|
+
logger.info(f"Using env scrcpy-server: {scrcpy_server}")
|
|
71
103
|
return scrcpy_server
|
|
72
104
|
|
|
73
|
-
# Priority
|
|
105
|
+
# Priority 4: Common system locations
|
|
74
106
|
paths = [
|
|
75
107
|
"/opt/homebrew/Cellar/scrcpy/3.3.3/share/scrcpy/scrcpy-server",
|
|
76
108
|
"/usr/local/share/scrcpy/scrcpy-server",
|
|
@@ -79,7 +111,7 @@ class ScrcpyStreamer:
|
|
|
79
111
|
|
|
80
112
|
for path in paths:
|
|
81
113
|
if os.path.exists(path):
|
|
82
|
-
|
|
114
|
+
logger.info(f"Using system scrcpy-server: {path}")
|
|
83
115
|
return path
|
|
84
116
|
|
|
85
117
|
raise FileNotFoundError(
|
|
@@ -88,37 +120,40 @@ class ScrcpyStreamer:
|
|
|
88
120
|
|
|
89
121
|
async def start(self) -> None:
|
|
90
122
|
"""Start scrcpy server and establish connection."""
|
|
91
|
-
|
|
92
|
-
self.
|
|
93
|
-
|
|
123
|
+
self._read_buffer.clear()
|
|
124
|
+
self._metadata = None
|
|
125
|
+
self._dummy_byte_skipped = False
|
|
126
|
+
logger.debug("Reset stream state")
|
|
94
127
|
|
|
95
128
|
try:
|
|
96
|
-
# 0.
|
|
97
|
-
|
|
129
|
+
# 0. Check device availability first
|
|
130
|
+
logger.info(f"Checking device {self.device_id} availability...")
|
|
131
|
+
await check_device_available(self.device_id)
|
|
132
|
+
logger.info(f"Device {self.device_id} is available")
|
|
133
|
+
|
|
134
|
+
# 1. Kill existing scrcpy server processes on device
|
|
135
|
+
logger.info("Cleaning up existing scrcpy processes...")
|
|
98
136
|
await self._cleanup_existing_server()
|
|
99
137
|
|
|
100
|
-
#
|
|
101
|
-
|
|
138
|
+
# 2. Push scrcpy-server to device
|
|
139
|
+
logger.info("Pushing server to device...")
|
|
102
140
|
await self._push_server()
|
|
103
141
|
|
|
104
|
-
#
|
|
105
|
-
|
|
142
|
+
# 3. Setup port forwarding
|
|
143
|
+
logger.info(f"Setting up port forwarding on port {self.port}...")
|
|
106
144
|
await self._setup_port_forward()
|
|
107
145
|
|
|
108
|
-
#
|
|
109
|
-
|
|
146
|
+
# 4. Start scrcpy server
|
|
147
|
+
logger.info("Starting scrcpy server...")
|
|
110
148
|
await self._start_server()
|
|
111
149
|
|
|
112
|
-
#
|
|
113
|
-
|
|
150
|
+
# 5. Connect TCP socket
|
|
151
|
+
logger.info("Connecting to TCP socket...")
|
|
114
152
|
await self._connect_socket()
|
|
115
|
-
|
|
153
|
+
logger.info("Successfully connected!")
|
|
116
154
|
|
|
117
155
|
except Exception as e:
|
|
118
|
-
|
|
119
|
-
import traceback
|
|
120
|
-
|
|
121
|
-
traceback.print_exc()
|
|
156
|
+
logger.exception(f"Failed to start: {e}")
|
|
122
157
|
self.stop()
|
|
123
158
|
raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
|
|
124
159
|
|
|
@@ -143,8 +178,8 @@ class ScrcpyStreamer:
|
|
|
143
178
|
cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
|
|
144
179
|
await run_cmd_silently(cmd_remove_forward)
|
|
145
180
|
|
|
146
|
-
# Wait
|
|
147
|
-
|
|
181
|
+
# Wait for resources to be released
|
|
182
|
+
logger.debug("Waiting for cleanup to complete...")
|
|
148
183
|
await asyncio.sleep(2)
|
|
149
184
|
|
|
150
185
|
async def _push_server(self) -> None:
|
|
@@ -166,38 +201,60 @@ class ScrcpyStreamer:
|
|
|
166
201
|
await run_cmd_silently(cmd)
|
|
167
202
|
self.forward_cleanup_needed = True
|
|
168
203
|
|
|
204
|
+
def _build_server_options(self) -> ScrcpyServerOptions:
|
|
205
|
+
codec_options = f"i-frame-interval={self.idr_interval_s}"
|
|
206
|
+
return ScrcpyServerOptions(
|
|
207
|
+
max_size=self.max_size,
|
|
208
|
+
bit_rate=self.bit_rate,
|
|
209
|
+
max_fps=20,
|
|
210
|
+
tunnel_forward=True,
|
|
211
|
+
audio=False,
|
|
212
|
+
control=False,
|
|
213
|
+
cleanup=False,
|
|
214
|
+
video_codec=self.stream_options.video_codec,
|
|
215
|
+
send_frame_meta=self.stream_options.send_frame_meta,
|
|
216
|
+
send_device_meta=self.stream_options.send_device_meta,
|
|
217
|
+
send_codec_meta=self.stream_options.send_codec_meta,
|
|
218
|
+
send_dummy_byte=self.stream_options.send_dummy_byte,
|
|
219
|
+
video_codec_options=codec_options,
|
|
220
|
+
)
|
|
221
|
+
|
|
169
222
|
async def _start_server(self) -> None:
|
|
170
223
|
"""Start scrcpy server on device with retry on address conflict."""
|
|
171
224
|
max_retries = 3
|
|
172
225
|
retry_delay = 2
|
|
173
226
|
|
|
227
|
+
options = self._build_server_options()
|
|
228
|
+
|
|
174
229
|
for attempt in range(max_retries):
|
|
175
230
|
cmd = ["adb"]
|
|
176
231
|
if self.device_id:
|
|
177
232
|
cmd.extend(["-s", self.device_id])
|
|
178
233
|
|
|
179
234
|
# Build server command
|
|
180
|
-
# Note: scrcpy 3.3+ uses different parameter format
|
|
181
235
|
server_args = [
|
|
182
236
|
"shell",
|
|
183
237
|
"CLASSPATH=/data/local/tmp/scrcpy-server",
|
|
184
238
|
"app_process",
|
|
185
239
|
"/",
|
|
186
240
|
"com.genymobile.scrcpy.Server",
|
|
187
|
-
"3.3.3",
|
|
188
|
-
f"max_size={
|
|
189
|
-
f"video_bit_rate={
|
|
190
|
-
"max_fps=
|
|
191
|
-
"tunnel_forward=
|
|
192
|
-
"audio=
|
|
193
|
-
"control=
|
|
194
|
-
"cleanup=
|
|
195
|
-
|
|
196
|
-
f"
|
|
241
|
+
"3.3.3",
|
|
242
|
+
f"max_size={options.max_size}",
|
|
243
|
+
f"video_bit_rate={options.bit_rate}",
|
|
244
|
+
f"max_fps={options.max_fps}",
|
|
245
|
+
f"tunnel_forward={str(options.tunnel_forward).lower()}",
|
|
246
|
+
f"audio={str(options.audio).lower()}",
|
|
247
|
+
f"control={str(options.control).lower()}",
|
|
248
|
+
f"cleanup={str(options.cleanup).lower()}",
|
|
249
|
+
f"video_codec={options.video_codec}",
|
|
250
|
+
f"send_frame_meta={str(options.send_frame_meta).lower()}",
|
|
251
|
+
f"send_device_meta={str(options.send_device_meta).lower()}",
|
|
252
|
+
f"send_codec_meta={str(options.send_codec_meta).lower()}",
|
|
253
|
+
f"send_dummy_byte={str(options.send_dummy_byte).lower()}",
|
|
254
|
+
f"video_codec_options={options.video_codec_options}",
|
|
197
255
|
]
|
|
198
256
|
cmd.extend(server_args)
|
|
199
257
|
|
|
200
|
-
# Capture stderr to see error messages
|
|
201
258
|
self.scrcpy_process = await spawn_process(cmd, capture_output=True)
|
|
202
259
|
|
|
203
260
|
# Wait for server to start
|
|
@@ -206,36 +263,28 @@ class ScrcpyStreamer:
|
|
|
206
263
|
# Check if process is still running
|
|
207
264
|
error_msg = None
|
|
208
265
|
if is_windows():
|
|
209
|
-
# For Windows Popen, check returncode directly
|
|
210
266
|
if self.scrcpy_process.poll() is not None:
|
|
211
|
-
# Process has exited
|
|
212
267
|
stdout, stderr = self.scrcpy_process.communicate()
|
|
213
268
|
error_msg = stderr.decode() if stderr else stdout.decode()
|
|
214
269
|
else:
|
|
215
|
-
# For asyncio subprocess
|
|
216
270
|
if self.scrcpy_process.returncode is not None:
|
|
217
|
-
# Process has exited
|
|
218
271
|
stdout, stderr = await self.scrcpy_process.communicate()
|
|
219
272
|
error_msg = stderr.decode() if stderr else stdout.decode()
|
|
220
273
|
|
|
221
274
|
if error_msg is not None:
|
|
222
|
-
# Check if it's an "Address already in use" error
|
|
223
275
|
if "Address already in use" in error_msg:
|
|
224
276
|
if attempt < max_retries - 1:
|
|
225
|
-
|
|
226
|
-
f"
|
|
277
|
+
logger.warning(
|
|
278
|
+
f"Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..."
|
|
227
279
|
)
|
|
228
280
|
await self._cleanup_existing_server()
|
|
229
281
|
await asyncio.sleep(retry_delay)
|
|
230
282
|
continue
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
else:
|
|
236
|
-
raise RuntimeError(f"scrcpy server exited immediately: {error_msg}")
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"scrcpy server failed after {max_retries} attempts: {error_msg}"
|
|
285
|
+
)
|
|
286
|
+
raise RuntimeError(f"scrcpy server exited immediately: {error_msg}")
|
|
237
287
|
|
|
238
|
-
# Server started successfully
|
|
239
288
|
return
|
|
240
289
|
|
|
241
290
|
raise RuntimeError("Failed to start scrcpy server after maximum retries")
|
|
@@ -245,301 +294,133 @@ class ScrcpyStreamer:
|
|
|
245
294
|
self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
246
295
|
self.tcp_socket.settimeout(5)
|
|
247
296
|
|
|
248
|
-
# Increase socket buffer size for high-resolution video
|
|
249
|
-
# Default is often 64KB, but complex frames can be 200-500KB
|
|
250
297
|
try:
|
|
251
298
|
self.tcp_socket.setsockopt(
|
|
252
299
|
socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024
|
|
253
|
-
)
|
|
254
|
-
|
|
300
|
+
)
|
|
301
|
+
logger.debug("Set socket receive buffer to 2MB")
|
|
255
302
|
except OSError as e:
|
|
256
|
-
|
|
303
|
+
logger.warning(f"Failed to set socket buffer size: {e}")
|
|
257
304
|
|
|
258
|
-
# Retry connection
|
|
259
305
|
for _ in range(5):
|
|
260
306
|
try:
|
|
261
307
|
self.tcp_socket.connect(("localhost", self.port))
|
|
262
|
-
self.tcp_socket.settimeout(None)
|
|
308
|
+
self.tcp_socket.settimeout(None)
|
|
263
309
|
return
|
|
264
310
|
except (ConnectionRefusedError, OSError):
|
|
265
311
|
await asyncio.sleep(0.5)
|
|
266
312
|
|
|
267
313
|
raise ConnectionError("Failed to connect to scrcpy server")
|
|
268
314
|
|
|
269
|
-
def
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
Returns:
|
|
273
|
-
List of (start_pos, nal_type, nal_size, is_complete) tuples
|
|
274
|
-
is_complete=False if NAL unit extends to chunk boundary (may be truncated)
|
|
275
|
-
"""
|
|
276
|
-
nal_units = []
|
|
277
|
-
i = 0
|
|
278
|
-
data_len = len(data)
|
|
279
|
-
|
|
280
|
-
while i < data_len - 4:
|
|
281
|
-
# Look for start codes: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
|
|
282
|
-
if data[i : i + 4] == b"\x00\x00\x00\x01":
|
|
283
|
-
start_code_len = 4
|
|
284
|
-
elif data[i : i + 3] == b"\x00\x00\x01":
|
|
285
|
-
start_code_len = 3
|
|
286
|
-
else:
|
|
287
|
-
i += 1
|
|
288
|
-
continue
|
|
289
|
-
|
|
290
|
-
# NAL unit type is in lower 5 bits of first byte after start code
|
|
291
|
-
nal_start = i + start_code_len
|
|
292
|
-
if nal_start >= data_len:
|
|
293
|
-
break
|
|
294
|
-
|
|
295
|
-
nal_type = data[nal_start] & 0x1F
|
|
296
|
-
|
|
297
|
-
# Find next start code to determine NAL unit size
|
|
298
|
-
next_start = nal_start + 1
|
|
299
|
-
found_next = False
|
|
300
|
-
while next_start < data_len - 3:
|
|
301
|
-
if (
|
|
302
|
-
data[next_start : next_start + 4] == b"\x00\x00\x00\x01"
|
|
303
|
-
or data[next_start : next_start + 3] == b"\x00\x00\x01"
|
|
304
|
-
):
|
|
305
|
-
found_next = True
|
|
306
|
-
break
|
|
307
|
-
next_start += 1
|
|
308
|
-
else:
|
|
309
|
-
next_start = data_len
|
|
310
|
-
|
|
311
|
-
nal_size = next_start - i
|
|
312
|
-
# NAL unit is complete only if we found the next start code
|
|
313
|
-
is_complete = found_next
|
|
314
|
-
nal_units.append((i, nal_type, nal_size, is_complete))
|
|
315
|
-
|
|
316
|
-
i = next_start
|
|
317
|
-
|
|
318
|
-
return nal_units
|
|
319
|
-
|
|
320
|
-
def _cache_nal_units(self, data: bytes) -> None:
|
|
321
|
-
"""Parse and cache INITIAL complete NAL units (SPS, PPS, IDR).
|
|
322
|
-
|
|
323
|
-
IMPORTANT: Caches NAL units with size validation.
|
|
324
|
-
For small NAL units (SPS/PPS), we cache even if at chunk boundary.
|
|
325
|
-
For large NAL units (IDR), we require minimum size to ensure completeness.
|
|
326
|
-
"""
|
|
327
|
-
nal_units = self._find_nal_units(data)
|
|
328
|
-
|
|
329
|
-
for start, nal_type, size, is_complete in nal_units:
|
|
330
|
-
nal_data = data[start : start + size]
|
|
331
|
-
|
|
332
|
-
if nal_type == 7: # SPS
|
|
333
|
-
# Only cache SPS if not yet locked
|
|
334
|
-
if not self.sps_pps_locked:
|
|
335
|
-
# Validate: SPS should be at least 10 bytes
|
|
336
|
-
if size >= 10 and not self.cached_sps:
|
|
337
|
-
self.cached_sps = nal_data
|
|
338
|
-
hex_preview = " ".join(
|
|
339
|
-
f"{b:02x}" for b in nal_data[: min(12, len(nal_data))]
|
|
340
|
-
)
|
|
341
|
-
print(
|
|
342
|
-
f"[ScrcpyStreamer] ✓ Cached SPS ({size} bytes, complete={is_complete}): {hex_preview}..."
|
|
343
|
-
)
|
|
344
|
-
elif size < 10:
|
|
345
|
-
print(f"[ScrcpyStreamer] ✗ Skipped short SPS ({size} bytes)")
|
|
346
|
-
|
|
347
|
-
elif nal_type == 8: # PPS
|
|
348
|
-
# Only cache PPS if not yet locked
|
|
349
|
-
if not self.sps_pps_locked:
|
|
350
|
-
# Validate: PPS should be at least 6 bytes
|
|
351
|
-
if size >= 6 and not self.cached_pps:
|
|
352
|
-
self.cached_pps = nal_data
|
|
353
|
-
hex_preview = " ".join(
|
|
354
|
-
f"{b:02x}" for b in nal_data[: min(12, len(nal_data))]
|
|
355
|
-
)
|
|
356
|
-
print(
|
|
357
|
-
f"[ScrcpyStreamer] ✓ Cached PPS ({size} bytes, complete={is_complete}): {hex_preview}..."
|
|
358
|
-
)
|
|
359
|
-
elif size < 6:
|
|
360
|
-
print(f"[ScrcpyStreamer] ✗ Skipped short PPS ({size} bytes)")
|
|
361
|
-
|
|
362
|
-
elif nal_type == 5: # IDR frame
|
|
363
|
-
# Cache IDR if it's large enough (size check is sufficient)
|
|
364
|
-
# Note: When called from read_nal_unit(), the NAL is guaranteed complete
|
|
365
|
-
# because we extract it between two start codes. The is_complete flag
|
|
366
|
-
# is only False because the NAL is isolated (no next start code in buffer).
|
|
367
|
-
if self.cached_sps and self.cached_pps and size >= 1024:
|
|
368
|
-
is_first = self.cached_idr is None
|
|
369
|
-
self.cached_idr = nal_data
|
|
370
|
-
if is_first:
|
|
371
|
-
print(f"[ScrcpyStreamer] ✓ Cached IDR frame ({size} bytes)")
|
|
372
|
-
# Don't log every IDR update (too verbose)
|
|
373
|
-
elif size < 1024:
|
|
374
|
-
print(
|
|
375
|
-
f"[ScrcpyStreamer] ✗ Skipped small IDR ({size} bytes, likely incomplete)"
|
|
376
|
-
)
|
|
377
|
-
|
|
378
|
-
# Lock SPS/PPS once we have complete initial parameters
|
|
379
|
-
if self.cached_sps and self.cached_pps and not self.sps_pps_locked:
|
|
380
|
-
self.sps_pps_locked = True
|
|
381
|
-
print("[ScrcpyStreamer] 🔒 SPS/PPS locked (IDR will continue updating)")
|
|
382
|
-
|
|
383
|
-
def get_initialization_data(self) -> bytes | None:
|
|
384
|
-
"""Get cached SPS/PPS/IDR for initializing new connections.
|
|
315
|
+
async def _read_exactly(self, size: int) -> bytes:
|
|
316
|
+
if not self.tcp_socket:
|
|
317
|
+
raise ConnectionError("Socket not connected")
|
|
385
318
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if self.cached_sps and self.cached_pps:
|
|
390
|
-
# Return SPS + PPS (+ IDR if available)
|
|
391
|
-
init_data = self.cached_sps + self.cached_pps
|
|
392
|
-
if self.cached_idr:
|
|
393
|
-
init_data += self.cached_idr
|
|
394
|
-
|
|
395
|
-
# Validate data integrity
|
|
396
|
-
print("[ScrcpyStreamer] Returning init data:")
|
|
397
|
-
print(
|
|
398
|
-
f" - SPS: {len(self.cached_sps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_sps[:8])}"
|
|
399
|
-
)
|
|
400
|
-
print(
|
|
401
|
-
f" - PPS: {len(self.cached_pps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_pps[:8])}"
|
|
319
|
+
while len(self._read_buffer) < size:
|
|
320
|
+
chunk = await asyncio.to_thread(
|
|
321
|
+
self.tcp_socket.recv, max(4096, size - len(self._read_buffer))
|
|
402
322
|
)
|
|
403
|
-
if
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
)
|
|
407
|
-
print(f" - Total: {len(init_data)} bytes")
|
|
408
|
-
|
|
409
|
-
return init_data
|
|
410
|
-
return None
|
|
323
|
+
if not chunk:
|
|
324
|
+
raise ConnectionError("Socket closed by remote")
|
|
325
|
+
self._read_buffer.extend(chunk)
|
|
411
326
|
|
|
412
|
-
|
|
413
|
-
|
|
327
|
+
data = bytes(self._read_buffer[:size])
|
|
328
|
+
del self._read_buffer[:size]
|
|
329
|
+
return data
|
|
414
330
|
|
|
415
|
-
|
|
416
|
-
|
|
331
|
+
async def _read_u16(self) -> int:
|
|
332
|
+
return int.from_bytes(await self._read_exactly(2), "big")
|
|
417
333
|
|
|
418
|
-
|
|
419
|
-
|
|
334
|
+
async def _read_u32(self) -> int:
|
|
335
|
+
return int.from_bytes(await self._read_exactly(4), "big")
|
|
420
336
|
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
"""
|
|
424
|
-
if not self.tcp_socket:
|
|
425
|
-
raise ConnectionError("Socket not connected")
|
|
337
|
+
async def _read_u64(self) -> int:
|
|
338
|
+
return int.from_bytes(await self._read_exactly(8), "big")
|
|
426
339
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
data = await loop.run_in_executor(None, self.tcp_socket.recv, 512 * 1024)
|
|
340
|
+
async def read_video_metadata(self) -> ScrcpyVideoStreamMetadata:
|
|
341
|
+
"""Read and cache video stream metadata from scrcpy."""
|
|
342
|
+
if self._metadata is not None:
|
|
343
|
+
return self._metadata
|
|
432
344
|
|
|
433
|
-
|
|
434
|
-
|
|
345
|
+
if self.stream_options.send_dummy_byte and not self._dummy_byte_skipped:
|
|
346
|
+
await self._read_exactly(1)
|
|
347
|
+
self._dummy_byte_skipped = True
|
|
435
348
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
349
|
+
device_name = None
|
|
350
|
+
width = None
|
|
351
|
+
height = None
|
|
352
|
+
codec = SCRCPY_CODEC_NAME_TO_ID.get(
|
|
353
|
+
self.stream_options.video_codec, SCRCPY_CODEC_NAME_TO_ID["h264"]
|
|
354
|
+
)
|
|
441
355
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
356
|
+
if self.stream_options.send_device_meta:
|
|
357
|
+
raw_name = await self._read_exactly(64)
|
|
358
|
+
device_name = raw_name.split(b"\x00", 1)[0].decode(
|
|
359
|
+
"utf-8", errors="replace"
|
|
360
|
+
)
|
|
445
361
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
362
|
+
if self.stream_options.send_codec_meta:
|
|
363
|
+
codec_value = await self._read_u32()
|
|
364
|
+
if codec_value in SCRCPY_KNOWN_CODECS:
|
|
365
|
+
codec = codec_value
|
|
366
|
+
width = await self._read_u32()
|
|
367
|
+
height = await self._read_u32()
|
|
368
|
+
else:
|
|
369
|
+
# Legacy fallback: treat codec_value as width/height u16
|
|
370
|
+
width = (codec_value >> 16) & 0xFFFF
|
|
371
|
+
height = codec_value & 0xFFFF
|
|
372
|
+
else:
|
|
373
|
+
if self.stream_options.send_device_meta:
|
|
374
|
+
width = await self._read_u16()
|
|
375
|
+
height = await self._read_u16()
|
|
376
|
+
|
|
377
|
+
self._metadata = ScrcpyVideoStreamMetadata(
|
|
378
|
+
device_name=device_name,
|
|
379
|
+
width=width,
|
|
380
|
+
height=height,
|
|
381
|
+
codec=codec,
|
|
382
|
+
)
|
|
383
|
+
return self._metadata
|
|
450
384
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
f"[ScrcpyStreamer] Unexpected error in read_h264_chunk: {type(e).__name__}: {e}"
|
|
385
|
+
async def read_media_packet(self) -> ScrcpyMediaStreamPacket:
|
|
386
|
+
"""Read one Scrcpy media packet (configuration/data)."""
|
|
387
|
+
if not self.stream_options.send_frame_meta:
|
|
388
|
+
raise RuntimeError(
|
|
389
|
+
"send_frame_meta is disabled; packet parsing unavailable"
|
|
457
390
|
)
|
|
458
|
-
raise ConnectionError(f"Failed to read from socket: {e}") from e
|
|
459
391
|
|
|
460
|
-
|
|
461
|
-
|
|
392
|
+
if self._metadata is None:
|
|
393
|
+
await self.read_video_metadata()
|
|
462
394
|
|
|
463
|
-
|
|
464
|
-
|
|
395
|
+
pts = await self._read_u64()
|
|
396
|
+
data_length = await self._read_u32()
|
|
397
|
+
payload = await self._read_exactly(data_length)
|
|
465
398
|
|
|
466
|
-
|
|
467
|
-
|
|
399
|
+
if pts == PTS_CONFIG:
|
|
400
|
+
return ScrcpyMediaStreamPacket(type="configuration", data=payload)
|
|
468
401
|
|
|
469
|
-
|
|
470
|
-
|
|
402
|
+
if pts & PTS_KEYFRAME:
|
|
403
|
+
return ScrcpyMediaStreamPacket(
|
|
404
|
+
type="data",
|
|
405
|
+
data=payload,
|
|
406
|
+
keyframe=True,
|
|
407
|
+
pts=pts & ~PTS_KEYFRAME,
|
|
408
|
+
)
|
|
471
409
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
410
|
+
return ScrcpyMediaStreamPacket(
|
|
411
|
+
type="data",
|
|
412
|
+
data=payload,
|
|
413
|
+
keyframe=False,
|
|
414
|
+
pts=pts,
|
|
415
|
+
)
|
|
477
416
|
|
|
417
|
+
async def iter_packets(self):
|
|
418
|
+
"""Yield packets continuously from the scrcpy stream."""
|
|
478
419
|
while True:
|
|
479
|
-
|
|
480
|
-
buffer = bytes(self._nal_read_buffer)
|
|
481
|
-
start_positions = []
|
|
482
|
-
|
|
483
|
-
# Find all start codes (0x00 0x00 0x00 0x01 or 0x00 0x00 0x01)
|
|
484
|
-
i = 0
|
|
485
|
-
while i < len(buffer) - 3:
|
|
486
|
-
if buffer[i] == 0x00 and buffer[i + 1] == 0x00:
|
|
487
|
-
if buffer[i + 2] == 0x00 and buffer[i + 3] == 0x01:
|
|
488
|
-
start_positions.append(i)
|
|
489
|
-
i += 4
|
|
490
|
-
elif buffer[i + 2] == 0x01:
|
|
491
|
-
start_positions.append(i)
|
|
492
|
-
i += 3
|
|
493
|
-
else:
|
|
494
|
-
i += 1
|
|
495
|
-
else:
|
|
496
|
-
i += 1
|
|
497
|
-
|
|
498
|
-
# If we have at least 2 start codes, we can extract the first NAL unit
|
|
499
|
-
if len(start_positions) >= 2:
|
|
500
|
-
# Extract first complete NAL unit (from first start code to second start code)
|
|
501
|
-
nal_unit = buffer[start_positions[0] : start_positions[1]]
|
|
502
|
-
|
|
503
|
-
# Remove extracted NAL unit from buffer
|
|
504
|
-
self._nal_read_buffer = bytearray(buffer[start_positions[1] :])
|
|
505
|
-
|
|
506
|
-
# Cache parameter sets if enabled
|
|
507
|
-
if auto_cache:
|
|
508
|
-
self._cache_nal_units(nal_unit)
|
|
509
|
-
|
|
510
|
-
return nal_unit
|
|
511
|
-
|
|
512
|
-
# Need more data - read from socket
|
|
513
|
-
try:
|
|
514
|
-
loop = asyncio.get_event_loop()
|
|
515
|
-
chunk = await loop.run_in_executor(
|
|
516
|
-
None, self.tcp_socket.recv, 512 * 1024
|
|
517
|
-
)
|
|
518
|
-
|
|
519
|
-
if not chunk:
|
|
520
|
-
# Socket closed - return any remaining buffered data as final NAL unit
|
|
521
|
-
if len(self._nal_read_buffer) > 0:
|
|
522
|
-
final_nal = bytes(self._nal_read_buffer)
|
|
523
|
-
self._nal_read_buffer.clear()
|
|
524
|
-
if auto_cache:
|
|
525
|
-
self._cache_nal_units(final_nal)
|
|
526
|
-
return final_nal
|
|
527
|
-
raise ConnectionError("Socket closed by remote")
|
|
528
|
-
|
|
529
|
-
# Append new data to buffer
|
|
530
|
-
self._nal_read_buffer.extend(chunk)
|
|
531
|
-
|
|
532
|
-
except ConnectionError:
|
|
533
|
-
raise
|
|
534
|
-
except Exception as e:
|
|
535
|
-
print(
|
|
536
|
-
f"[ScrcpyStreamer] Unexpected error in read_nal_unit: {type(e).__name__}: {e}"
|
|
537
|
-
)
|
|
538
|
-
raise ConnectionError(f"Failed to read from socket: {e}") from e
|
|
420
|
+
yield await self.read_media_packet()
|
|
539
421
|
|
|
540
422
|
def stop(self) -> None:
|
|
541
423
|
"""Stop scrcpy server and cleanup resources."""
|
|
542
|
-
# Close socket
|
|
543
424
|
if self.tcp_socket:
|
|
544
425
|
try:
|
|
545
426
|
self.tcp_socket.close()
|
|
@@ -547,7 +428,6 @@ class ScrcpyStreamer:
|
|
|
547
428
|
pass
|
|
548
429
|
self.tcp_socket = None
|
|
549
430
|
|
|
550
|
-
# Kill server process
|
|
551
431
|
if self.scrcpy_process:
|
|
552
432
|
try:
|
|
553
433
|
self.scrcpy_process.terminate()
|
|
@@ -559,7 +439,6 @@ class ScrcpyStreamer:
|
|
|
559
439
|
pass
|
|
560
440
|
self.scrcpy_process = None
|
|
561
441
|
|
|
562
|
-
# Remove port forwarding
|
|
563
442
|
if self.forward_cleanup_needed:
|
|
564
443
|
try:
|
|
565
444
|
cmd = ["adb"]
|
|
@@ -567,12 +446,14 @@ class ScrcpyStreamer:
|
|
|
567
446
|
cmd.extend(["-s", self.device_id])
|
|
568
447
|
cmd.extend(["forward", "--remove", f"tcp:{self.port}"])
|
|
569
448
|
subprocess.run(
|
|
570
|
-
cmd,
|
|
449
|
+
cmd,
|
|
450
|
+
stdout=subprocess.DEVNULL,
|
|
451
|
+
stderr=subprocess.DEVNULL,
|
|
452
|
+
timeout=2,
|
|
571
453
|
)
|
|
572
454
|
except Exception:
|
|
573
455
|
pass
|
|
574
456
|
self.forward_cleanup_needed = False
|
|
575
457
|
|
|
576
458
|
def __del__(self):
|
|
577
|
-
"""Cleanup on destruction."""
|
|
578
459
|
self.stop()
|