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.
Files changed (37) hide show
  1. AutoGLM_GUI/__init__.py +8 -0
  2. AutoGLM_GUI/__main__.py +64 -21
  3. AutoGLM_GUI/adb_plus/__init__.py +8 -0
  4. AutoGLM_GUI/adb_plus/device.py +50 -0
  5. AutoGLM_GUI/adb_plus/ip.py +78 -0
  6. AutoGLM_GUI/adb_plus/keyboard_installer.py +380 -0
  7. AutoGLM_GUI/adb_plus/serial.py +35 -0
  8. AutoGLM_GUI/api/__init__.py +8 -0
  9. AutoGLM_GUI/api/agents.py +132 -1
  10. AutoGLM_GUI/api/devices.py +96 -6
  11. AutoGLM_GUI/api/media.py +13 -243
  12. AutoGLM_GUI/config_manager.py +565 -0
  13. AutoGLM_GUI/exceptions.py +7 -0
  14. AutoGLM_GUI/logger.py +85 -0
  15. AutoGLM_GUI/platform_utils.py +30 -5
  16. AutoGLM_GUI/schemas.py +50 -0
  17. AutoGLM_GUI/scrcpy_protocol.py +46 -0
  18. AutoGLM_GUI/scrcpy_stream.py +208 -327
  19. AutoGLM_GUI/server.py +7 -2
  20. AutoGLM_GUI/socketio_server.py +125 -0
  21. AutoGLM_GUI/state.py +2 -1
  22. AutoGLM_GUI/static/assets/{about-BI6OV6gm.js → about-kgOkkOWe.js} +1 -1
  23. AutoGLM_GUI/static/assets/chat-CZV3RByK.js +149 -0
  24. AutoGLM_GUI/static/assets/{index-Do7ha9Kf.js → index-BPYHsweG.js} +1 -1
  25. AutoGLM_GUI/static/assets/index-Beu9cbSy.css +1 -0
  26. AutoGLM_GUI/static/assets/index-DfI_Z1Cx.js +10 -0
  27. AutoGLM_GUI/static/assets/worker-D6BRitjy.js +1 -0
  28. AutoGLM_GUI/static/index.html +2 -2
  29. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/METADATA +15 -2
  30. autoglm_gui-0.4.12.dist-info/RECORD +56 -0
  31. AutoGLM_GUI/static/assets/chat-C_2Cot0q.js +0 -25
  32. AutoGLM_GUI/static/assets/index-DCrxTz-A.css +0 -1
  33. AutoGLM_GUI/static/assets/index-Dn3vR6uV.js +0 -10
  34. autoglm_gui-0.4.9.dist-info/RECORD +0 -46
  35. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/WHEEL +0 -0
  36. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/entry_points.txt +0 -0
  37. {autoglm_gui-0.4.9.dist-info → autoglm_gui-0.4.12.dist-info}/licenses/LICENSE +0 -0
@@ -1,17 +1,47 @@
1
- """scrcpy video streaming implementation."""
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 H.264 video streaming."""
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
- # H.264 parameter sets cache (for new connections to join mid-stream)
44
- # IMPORTANT: Only cache INITIAL complete SPS/PPS from stream start
45
- # Later SPS/PPS may be truncated across chunks
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: Project root directory (for repository version)
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
- print(f"[ScrcpyStreamer] Using project scrcpy-server: {project_server}")
96
+ logger.info(f"Using project scrcpy-server: {project_server}")
65
97
  return str(project_server)
66
98
 
67
- # Priority 2: Environment variable
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
- print(f"[ScrcpyStreamer] Using env scrcpy-server: {scrcpy_server}")
102
+ logger.info(f"Using env scrcpy-server: {scrcpy_server}")
71
103
  return scrcpy_server
72
104
 
73
- # Priority 3: Common system locations
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
- print(f"[ScrcpyStreamer] Using system scrcpy-server: {path}")
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
- # Clear NAL reading buffer to ensure clean state
92
- self._nal_read_buffer.clear()
93
- print("[ScrcpyStreamer] Cleared NAL read buffer")
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. Kill existing scrcpy server processes on device
97
- print("[ScrcpyStreamer] Cleaning up existing scrcpy processes...")
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
- # 1. Push scrcpy-server to device
101
- print("[ScrcpyStreamer] Pushing server to device...")
138
+ # 2. Push scrcpy-server to device
139
+ logger.info("Pushing server to device...")
102
140
  await self._push_server()
103
141
 
104
- # 2. Setup port forwarding
105
- print(f"[ScrcpyStreamer] Setting up port forwarding on port {self.port}...")
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
- # 3. Start scrcpy server
109
- print("[ScrcpyStreamer] Starting scrcpy server...")
146
+ # 4. Start scrcpy server
147
+ logger.info("Starting scrcpy server...")
110
148
  await self._start_server()
111
149
 
112
- # 4. Connect TCP socket
113
- print("[ScrcpyStreamer] Connecting to TCP socket...")
150
+ # 5. Connect TCP socket
151
+ logger.info("Connecting to TCP socket...")
114
152
  await self._connect_socket()
115
- print("[ScrcpyStreamer] Successfully connected!")
153
+ logger.info("Successfully connected!")
116
154
 
117
155
  except Exception as e:
118
- print(f"[ScrcpyStreamer] Failed to start: {e}")
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 longer for resources to be released
147
- print("[ScrcpyStreamer] Waiting for cleanup to complete...")
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", # scrcpy version - must match installed version
188
- f"max_size={self.max_size}",
189
- f"video_bit_rate={self.bit_rate}",
190
- "max_fps=20", # ✅ Limit to 20fps to reduce data volume
191
- "tunnel_forward=true",
192
- "audio=false",
193
- "control=false",
194
- "cleanup=false",
195
- # Force I-frame (IDR) at fixed interval (GOP length) for reliable reconnection
196
- f"video_codec_options=i-frame-interval={self.idr_interval_s}",
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
- print(
226
- f"[ScrcpyStreamer] Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})..."
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
- else:
232
- raise RuntimeError(
233
- f"scrcpy server failed after {max_retries} attempts: {error_msg}"
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
- ) # 2MB
254
- print("[ScrcpyStreamer] Set socket receive buffer to 2MB")
300
+ )
301
+ logger.debug("Set socket receive buffer to 2MB")
255
302
  except OSError as e:
256
- print(f"[ScrcpyStreamer] Warning: Failed to set socket buffer size: {e}")
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) # Non-blocking for async
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 _find_nal_units(self, data: bytes) -> list[tuple[int, int, int, bool]]:
270
- """Find NAL units in H.264 data.
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
- Returns:
387
- Concatenated SPS + PPS + IDR, or None if not available
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 self.cached_idr:
404
- print(
405
- f" - IDR: {len(self.cached_idr)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_idr[:8])}"
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
- async def read_h264_chunk(self, auto_cache: bool = True) -> bytes:
413
- """Read H.264 data chunk from socket.
327
+ data = bytes(self._read_buffer[:size])
328
+ del self._read_buffer[:size]
329
+ return data
414
330
 
415
- Args:
416
- auto_cache: If True, automatically cache SPS/PPS/IDR from this chunk
331
+ async def _read_u16(self) -> int:
332
+ return int.from_bytes(await self._read_exactly(2), "big")
417
333
 
418
- Returns:
419
- bytes: Raw H.264 data
334
+ async def _read_u32(self) -> int:
335
+ return int.from_bytes(await self._read_exactly(4), "big")
420
336
 
421
- Raises:
422
- ConnectionError: If socket is closed or error occurs
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
- try:
428
- # Use asyncio to make socket read non-blocking
429
- # Read up to 512KB at once for high-quality frames
430
- loop = asyncio.get_event_loop()
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
- if not data:
434
- raise ConnectionError("Socket closed by remote")
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
- # Log large chunks (might indicate complex frames)
437
- if len(data) > 200 * 1024: # > 200KB
438
- print(
439
- f"[ScrcpyStreamer] Large chunk received: {len(data) / 1024:.1f} KB"
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
- # Optionally cache SPS/PPS/IDR from this chunk
443
- if auto_cache:
444
- self._cache_nal_units(data)
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
- # NOTE: We don't automatically prepend SPS/PPS here because:
447
- # 1. NAL units may be truncated across chunks
448
- # 2. Prepending truncated SPS/PPS causes decoding errors
449
- # 3. Instead, we send cached complete SPS/PPS when new connections join
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
- return data
452
- except ConnectionError:
453
- raise
454
- except Exception as e:
455
- print(
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
- async def read_nal_unit(self, auto_cache: bool = True) -> bytes:
461
- """Read one complete NAL unit from socket.
392
+ if self._metadata is None:
393
+ await self.read_video_metadata()
462
394
 
463
- This method ensures each returned chunk is a complete, self-contained NAL unit.
464
- WebSocket messages will have clear semantic boundaries (one message = one NAL unit).
395
+ pts = await self._read_u64()
396
+ data_length = await self._read_u32()
397
+ payload = await self._read_exactly(data_length)
465
398
 
466
- Args:
467
- auto_cache: If True, automatically cache SPS/PPS/IDR from this NAL unit
399
+ if pts == PTS_CONFIG:
400
+ return ScrcpyMediaStreamPacket(type="configuration", data=payload)
468
401
 
469
- Returns:
470
- bytes: Complete NAL unit (including start code)
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
- Raises:
473
- ConnectionError: If socket is closed or error occurs
474
- """
475
- if not self.tcp_socket:
476
- raise ConnectionError("Socket not connected")
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
- # Look for start codes in buffer
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, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2
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()