autoglm-gui 0.4.11__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 +29 -34
  3. AutoGLM_GUI/adb_plus/__init__.py +6 -0
  4. AutoGLM_GUI/adb_plus/device.py +50 -0
  5. AutoGLM_GUI/adb_plus/ip.py +78 -0
  6. AutoGLM_GUI/adb_plus/serial.py +35 -0
  7. AutoGLM_GUI/api/__init__.py +8 -0
  8. AutoGLM_GUI/api/agents.py +76 -67
  9. AutoGLM_GUI/api/devices.py +96 -6
  10. AutoGLM_GUI/api/media.py +12 -235
  11. AutoGLM_GUI/config_manager.py +538 -97
  12. AutoGLM_GUI/exceptions.py +7 -0
  13. AutoGLM_GUI/platform_utils.py +19 -0
  14. AutoGLM_GUI/schemas.py +35 -2
  15. AutoGLM_GUI/scrcpy_protocol.py +46 -0
  16. AutoGLM_GUI/scrcpy_stream.py +192 -307
  17. AutoGLM_GUI/server.py +7 -2
  18. AutoGLM_GUI/socketio_server.py +125 -0
  19. AutoGLM_GUI/static/assets/{about-wSo3UgQ-.js → about-kgOkkOWe.js} +1 -1
  20. AutoGLM_GUI/static/assets/chat-CZV3RByK.js +149 -0
  21. AutoGLM_GUI/static/assets/{index-B5u1xtK1.js → index-BPYHsweG.js} +1 -1
  22. AutoGLM_GUI/static/assets/index-Beu9cbSy.css +1 -0
  23. AutoGLM_GUI/static/assets/index-DfI_Z1Cx.js +10 -0
  24. AutoGLM_GUI/static/assets/worker-D6BRitjy.js +1 -0
  25. AutoGLM_GUI/static/index.html +2 -2
  26. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.12.dist-info}/METADATA +2 -1
  27. autoglm_gui-0.4.12.dist-info/RECORD +56 -0
  28. AutoGLM_GUI/resources/apks/ADBKeyBoard.LICENSE.txt +0 -339
  29. AutoGLM_GUI/resources/apks/ADBKeyBoard.README.txt +0 -1
  30. AutoGLM_GUI/resources/apks/ADBKeyboard.apk +0 -0
  31. AutoGLM_GUI/static/assets/chat-BcY2K0yj.js +0 -25
  32. AutoGLM_GUI/static/assets/index-CHrYo3Qj.css +0 -1
  33. AutoGLM_GUI/static/assets/index-D5BALRbT.js +0 -10
  34. autoglm_gui-0.4.11.dist-info/RECORD +0 -52
  35. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.12.dist-info}/WHEEL +0 -0
  36. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.12.dist-info}/entry_points.txt +0 -0
  37. {autoglm_gui-0.4.11.dist-info → autoglm_gui-0.4.12.dist-info}/licenses/LICENSE +0 -0
@@ -1,18 +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
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 H.264 video streaming."""
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
- # H.264 parameter sets cache (for new connections to join mid-stream)
45
- # IMPORTANT: Only cache INITIAL complete SPS/PPS from stream start
46
- # Later SPS/PPS may be truncated across chunks
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: 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)
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 2: Environment variable
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 3: Common system locations
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
- # Clear NAL reading buffer to ensure clean state
93
- self._nal_read_buffer.clear()
94
- logger.debug("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")
95
127
 
96
128
  try:
97
- # 0. Kill existing scrcpy server processes on device
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
- # 1. Push scrcpy-server to device
138
+ # 2. Push scrcpy-server to device
102
139
  logger.info("Pushing server to device...")
103
140
  await self._push_server()
104
141
 
105
- # 2. Setup port forwarding
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
- # 3. Start scrcpy server
146
+ # 4. Start scrcpy server
110
147
  logger.info("Starting scrcpy server...")
111
148
  await self._start_server()
112
149
 
113
- # 4. Connect TCP socket
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 longer for resources to be released
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", # scrcpy version - must match installed version
186
- f"max_size={self.max_size}",
187
- f"video_bit_rate={self.bit_rate}",
188
- "max_fps=20", # ✅ Limit to 20fps to reduce data volume
189
- "tunnel_forward=true",
190
- "audio=false",
191
- "control=false",
192
- "cleanup=false",
193
- # Force I-frame (IDR) at fixed interval (GOP length) for reliable reconnection
194
- 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}",
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
- else:
230
- raise RuntimeError(
231
- f"scrcpy server failed after {max_retries} attempts: {error_msg}"
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
- ) # 2MB
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) # Non-blocking for async
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 _find_nal_units(self, data: bytes) -> list[tuple[int, int, int, bool]]:
268
- """Find NAL units in H.264 data.
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
- Returns:
385
- Concatenated SPS + PPS + IDR, or None if not available
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 self.cached_idr:
402
- logger.debug(
403
- f" - IDR: {len(self.cached_idr)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_idr[:8])}"
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
- return init_data
408
- return None
327
+ data = bytes(self._read_buffer[:size])
328
+ del self._read_buffer[:size]
329
+ return data
409
330
 
410
- async def read_h264_chunk(self, auto_cache: bool = True) -> bytes:
411
- """Read H.264 data chunk from socket.
331
+ async def _read_u16(self) -> int:
332
+ return int.from_bytes(await self._read_exactly(2), "big")
412
333
 
413
- Args:
414
- auto_cache: If True, automatically cache SPS/PPS/IDR from this chunk
334
+ async def _read_u32(self) -> int:
335
+ return int.from_bytes(await self._read_exactly(4), "big")
415
336
 
416
- Returns:
417
- bytes: Raw H.264 data
337
+ async def _read_u64(self) -> int:
338
+ return int.from_bytes(await self._read_exactly(8), "big")
418
339
 
419
- Raises:
420
- ConnectionError: If socket is closed or error occurs
421
- """
422
- if not self.tcp_socket:
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
- if not data:
432
- 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
433
348
 
434
- # Log large chunks (might indicate complex frames)
435
- if len(data) > 200 * 1024: # > 200KB
436
- logger.debug(f"Large chunk received: {len(data) / 1024:.1f} KB")
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
- # Optionally cache SPS/PPS/IDR from this chunk
439
- if auto_cache:
440
- 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
+ )
441
361
 
442
- # NOTE: We don't automatically prepend SPS/PPS here because:
443
- # 1. NAL units may be truncated across chunks
444
- # 2. Prepending truncated SPS/PPS causes decoding errors
445
- # 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
446
384
 
447
- return data
448
- except ConnectionError:
449
- raise
450
- except Exception as e:
451
- logger.error(
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
- async def read_nal_unit(self, auto_cache: bool = True) -> bytes:
457
- """Read one complete NAL unit from socket.
392
+ if self._metadata is None:
393
+ await self.read_video_metadata()
458
394
 
459
- This method ensures each returned chunk is a complete, self-contained NAL unit.
460
- 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)
461
398
 
462
- Args:
463
- 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)
464
401
 
465
- Returns:
466
- 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
+ )
467
409
 
468
- Raises:
469
- ConnectionError: If socket is closed or error occurs
470
- """
471
- if not self.tcp_socket:
472
- raise ConnectionError("Socket not connected")
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
- # Look for start codes in buffer
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, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2
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()