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