autoglm-gui 0.1.8__tar.gz → 0.1.10__tar.gz
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-0.1.8 → autoglm_gui-0.1.10}/.gitignore +1 -0
- autoglm_gui-0.1.10/AutoGLM_GUI/adb_plus/__init__.py +5 -0
- autoglm_gui-0.1.10/AutoGLM_GUI/adb_plus/screenshot.py +115 -0
- autoglm_gui-0.1.10/AutoGLM_GUI/scrcpy_stream.py +499 -0
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/AutoGLM_GUI/server.py +147 -15
- autoglm_gui-0.1.8/AutoGLM_GUI/static/assets/about-Chyx0gun.js → autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/about-uuv-AkSr.js +1 -1
- autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/chat-Bl1mU48-.js +4 -0
- autoglm_gui-0.1.8/AutoGLM_GUI/static/assets/index-CLji_9bZ.js → autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-B6TfcGH7.js +1 -1
- autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-BCzw2xc6.css +1 -0
- autoglm_gui-0.1.8/AutoGLM_GUI/static/assets/index--j1A-Pvm.js → autoglm_gui-0.1.10/AutoGLM_GUI/static/assets/index-BhEqSAe_.js +5 -5
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/AutoGLM_GUI/static/index.html +2 -2
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/PKG-INFO +2 -2
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/pyproject.toml +4 -4
- autoglm_gui-0.1.8/AutoGLM_GUI/static/assets/chat-Buzg0Ulm.js +0 -1
- autoglm_gui-0.1.8/AutoGLM_GUI/static/assets/index-DJf9qMan.css +0 -1
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/AutoGLM_GUI/__init__.py +0 -0
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/AutoGLM_GUI/__main__.py +0 -0
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/LICENSE +0 -0
- {autoglm_gui-0.1.8 → autoglm_gui-0.1.10}/README.md +0 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Robust screenshot helper using `adb exec-out screencap -p`.
|
|
2
|
+
|
|
3
|
+
Features:
|
|
4
|
+
- Avoids temp files and uses exec-out to reduce corruption.
|
|
5
|
+
- Normalizes CRLF issues from some devices.
|
|
6
|
+
- Validates PNG signature/size and retries before falling back.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import subprocess
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from io import BytesIO
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Screenshot:
|
|
23
|
+
"""Represents a captured screenshot."""
|
|
24
|
+
|
|
25
|
+
base64_data: str
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
is_sensitive: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def capture_screenshot(
|
|
32
|
+
device_id: str | None = None,
|
|
33
|
+
adb_path: str = "adb",
|
|
34
|
+
timeout: int = 10,
|
|
35
|
+
retries: int = 1,
|
|
36
|
+
) -> Screenshot:
|
|
37
|
+
"""
|
|
38
|
+
Capture a screenshot using adb exec-out.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
device_id: Optional device serial.
|
|
42
|
+
adb_path: Path to adb binary.
|
|
43
|
+
timeout: Per-attempt timeout in seconds.
|
|
44
|
+
retries: Extra attempts after the first try.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Screenshot object; falls back to a black image on failure.
|
|
48
|
+
"""
|
|
49
|
+
attempts = max(1, retries + 1)
|
|
50
|
+
for _ in range(attempts):
|
|
51
|
+
data = _try_capture(device_id=device_id, adb_path=adb_path, timeout=timeout)
|
|
52
|
+
if not data:
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
# NOTE: Do NOT do CRLF normalization for binary PNG data from exec-out
|
|
56
|
+
# The PNG signature contains \r\n bytes that must be preserved
|
|
57
|
+
|
|
58
|
+
if not _is_valid_png(data):
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
img = Image.open(BytesIO(data))
|
|
63
|
+
width, height = img.size
|
|
64
|
+
buffered = BytesIO()
|
|
65
|
+
img.save(buffered, format="PNG")
|
|
66
|
+
base64_data = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
|
67
|
+
return Screenshot(base64_data=base64_data, width=width, height=height)
|
|
68
|
+
except Exception:
|
|
69
|
+
# Try next attempt
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
return _fallback_screenshot()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _try_capture(
|
|
76
|
+
device_id: str | None, adb_path: str, timeout: int
|
|
77
|
+
) -> bytes | None:
|
|
78
|
+
"""Run exec-out screencap and return raw bytes or None on failure."""
|
|
79
|
+
cmd: list[str | bytes] = [adb_path]
|
|
80
|
+
if device_id:
|
|
81
|
+
cmd.extend(["-s", device_id])
|
|
82
|
+
cmd.extend(["exec-out", "screencap", "-p"])
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
result = subprocess.run(
|
|
86
|
+
cmd,
|
|
87
|
+
capture_output=True,
|
|
88
|
+
timeout=timeout,
|
|
89
|
+
)
|
|
90
|
+
if result.returncode != 0:
|
|
91
|
+
return None
|
|
92
|
+
# stdout should hold the PNG data
|
|
93
|
+
return result.stdout if isinstance(result.stdout, (bytes, bytearray)) else None
|
|
94
|
+
except Exception:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_valid_png(data: bytes) -> bool:
|
|
99
|
+
"""Basic PNG validation (signature + minimal length)."""
|
|
100
|
+
return (
|
|
101
|
+
len(data) > len(PNG_SIGNATURE) + 8 # header + IHDR length
|
|
102
|
+
and data.startswith(PNG_SIGNATURE)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _fallback_screenshot() -> Screenshot:
|
|
107
|
+
"""Return a black fallback image."""
|
|
108
|
+
width, height = 1080, 2400
|
|
109
|
+
img = Image.new("RGB", (width, height), color="black")
|
|
110
|
+
buffered = BytesIO()
|
|
111
|
+
img.save(buffered, format="PNG")
|
|
112
|
+
base64_data = base64.b64encode(buffered.getvalue()).decode("utf-8")
|
|
113
|
+
return Screenshot(
|
|
114
|
+
base64_data=base64_data, width=width, height=height, is_sensitive=False
|
|
115
|
+
)
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""scrcpy video streaming implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ScrcpyStreamer:
|
|
11
|
+
"""Manages scrcpy server lifecycle and H.264 video streaming."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
device_id: str | None = None,
|
|
16
|
+
max_size: int = 1280,
|
|
17
|
+
bit_rate: int = 1_000_000,
|
|
18
|
+
port: int = 27183,
|
|
19
|
+
):
|
|
20
|
+
"""Initialize ScrcpyStreamer.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
device_id: ADB device serial (None for default device)
|
|
24
|
+
max_size: Maximum video dimension
|
|
25
|
+
bit_rate: Video bitrate in bps
|
|
26
|
+
port: TCP port for scrcpy socket
|
|
27
|
+
"""
|
|
28
|
+
self.device_id = device_id
|
|
29
|
+
self.max_size = max_size
|
|
30
|
+
self.bit_rate = bit_rate
|
|
31
|
+
self.port = port
|
|
32
|
+
|
|
33
|
+
self.scrcpy_process: subprocess.Popen | None = None
|
|
34
|
+
self.tcp_socket: socket.socket | None = None
|
|
35
|
+
self.forward_cleanup_needed = False
|
|
36
|
+
|
|
37
|
+
# H.264 parameter sets cache (for new connections to join mid-stream)
|
|
38
|
+
# IMPORTANT: Only cache INITIAL complete SPS/PPS from stream start
|
|
39
|
+
# Later SPS/PPS may be truncated across chunks
|
|
40
|
+
self.cached_sps: bytes | None = None
|
|
41
|
+
self.cached_pps: bytes | None = None
|
|
42
|
+
self.cached_idr: bytes | None = None # Last IDR frame for immediate playback
|
|
43
|
+
self.sps_pps_locked = False # Lock SPS/PPS after initial complete capture
|
|
44
|
+
# Note: IDR is NOT locked - we keep updating to the latest frame
|
|
45
|
+
|
|
46
|
+
# Find scrcpy-server location
|
|
47
|
+
self.scrcpy_server_path = self._find_scrcpy_server()
|
|
48
|
+
|
|
49
|
+
def _find_scrcpy_server(self) -> str:
|
|
50
|
+
"""Find scrcpy-server binary path."""
|
|
51
|
+
# Priority 1: Project root directory (for repository version)
|
|
52
|
+
project_root = Path(__file__).parent.parent
|
|
53
|
+
project_server = project_root / "scrcpy-server-v3.3.3"
|
|
54
|
+
if project_server.exists():
|
|
55
|
+
print(f"[ScrcpyStreamer] Using project scrcpy-server: {project_server}")
|
|
56
|
+
return str(project_server)
|
|
57
|
+
|
|
58
|
+
# Priority 2: Environment variable
|
|
59
|
+
scrcpy_server = os.getenv("SCRCPY_SERVER_PATH")
|
|
60
|
+
if scrcpy_server and os.path.exists(scrcpy_server):
|
|
61
|
+
print(f"[ScrcpyStreamer] Using env scrcpy-server: {scrcpy_server}")
|
|
62
|
+
return scrcpy_server
|
|
63
|
+
|
|
64
|
+
# Priority 3: Common system locations
|
|
65
|
+
paths = [
|
|
66
|
+
"/opt/homebrew/Cellar/scrcpy/3.3.3/share/scrcpy/scrcpy-server",
|
|
67
|
+
"/usr/local/share/scrcpy/scrcpy-server",
|
|
68
|
+
"/usr/share/scrcpy/scrcpy-server",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
for path in paths:
|
|
72
|
+
if os.path.exists(path):
|
|
73
|
+
print(f"[ScrcpyStreamer] Using system scrcpy-server: {path}")
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
raise FileNotFoundError(
|
|
77
|
+
"scrcpy-server not found. Please put scrcpy-server-v3.3.3 in project root or set SCRCPY_SERVER_PATH."
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
async def start(self) -> None:
|
|
81
|
+
"""Start scrcpy server and establish connection."""
|
|
82
|
+
try:
|
|
83
|
+
# 0. Kill existing scrcpy server processes on device
|
|
84
|
+
print("[ScrcpyStreamer] Cleaning up existing scrcpy processes...")
|
|
85
|
+
await self._cleanup_existing_server()
|
|
86
|
+
|
|
87
|
+
# 1. Push scrcpy-server to device
|
|
88
|
+
print("[ScrcpyStreamer] Pushing server to device...")
|
|
89
|
+
await self._push_server()
|
|
90
|
+
|
|
91
|
+
# 2. Setup port forwarding
|
|
92
|
+
print(f"[ScrcpyStreamer] Setting up port forwarding on port {self.port}...")
|
|
93
|
+
await self._setup_port_forward()
|
|
94
|
+
|
|
95
|
+
# 3. Start scrcpy server
|
|
96
|
+
print("[ScrcpyStreamer] Starting scrcpy server...")
|
|
97
|
+
await self._start_server()
|
|
98
|
+
|
|
99
|
+
# 4. Connect TCP socket
|
|
100
|
+
print("[ScrcpyStreamer] Connecting to TCP socket...")
|
|
101
|
+
await self._connect_socket()
|
|
102
|
+
print("[ScrcpyStreamer] Successfully connected!")
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"[ScrcpyStreamer] Failed to start: {e}")
|
|
106
|
+
import traceback
|
|
107
|
+
traceback.print_exc()
|
|
108
|
+
self.stop()
|
|
109
|
+
raise RuntimeError(f"Failed to start scrcpy server: {e}") from e
|
|
110
|
+
|
|
111
|
+
async def _cleanup_existing_server(self) -> None:
|
|
112
|
+
"""Kill existing scrcpy server processes on device."""
|
|
113
|
+
cmd_base = ["adb"]
|
|
114
|
+
if self.device_id:
|
|
115
|
+
cmd_base.extend(["-s", self.device_id])
|
|
116
|
+
|
|
117
|
+
# Method 1: Try pkill
|
|
118
|
+
cmd = cmd_base + ["shell", "pkill", "-9", "-f", "app_process.*scrcpy"]
|
|
119
|
+
process = await asyncio.create_subprocess_exec(
|
|
120
|
+
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
121
|
+
)
|
|
122
|
+
await process.wait()
|
|
123
|
+
|
|
124
|
+
# Method 2: Find and kill by PID (more reliable)
|
|
125
|
+
cmd = cmd_base + [
|
|
126
|
+
"shell",
|
|
127
|
+
"ps -ef | grep 'app_process.*scrcpy' | grep -v grep | awk '{print $2}' | xargs kill -9"
|
|
128
|
+
]
|
|
129
|
+
process = await asyncio.create_subprocess_exec(
|
|
130
|
+
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
131
|
+
)
|
|
132
|
+
await process.wait()
|
|
133
|
+
|
|
134
|
+
# Method 3: Remove port forward if exists
|
|
135
|
+
cmd_remove_forward = cmd_base + ["forward", "--remove", f"tcp:{self.port}"]
|
|
136
|
+
process = await asyncio.create_subprocess_exec(
|
|
137
|
+
*cmd_remove_forward, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
138
|
+
)
|
|
139
|
+
await process.wait()
|
|
140
|
+
|
|
141
|
+
# Wait longer for resources to be released
|
|
142
|
+
print("[ScrcpyStreamer] Waiting for cleanup to complete...")
|
|
143
|
+
await asyncio.sleep(2)
|
|
144
|
+
|
|
145
|
+
async def _push_server(self) -> None:
|
|
146
|
+
"""Push scrcpy-server to device."""
|
|
147
|
+
cmd = ["adb"]
|
|
148
|
+
if self.device_id:
|
|
149
|
+
cmd.extend(["-s", self.device_id])
|
|
150
|
+
cmd.extend(["push", self.scrcpy_server_path, "/data/local/tmp/scrcpy-server"])
|
|
151
|
+
|
|
152
|
+
process = await asyncio.create_subprocess_exec(
|
|
153
|
+
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
154
|
+
)
|
|
155
|
+
await process.wait()
|
|
156
|
+
|
|
157
|
+
async def _setup_port_forward(self) -> None:
|
|
158
|
+
"""Setup ADB port forwarding."""
|
|
159
|
+
cmd = ["adb"]
|
|
160
|
+
if self.device_id:
|
|
161
|
+
cmd.extend(["-s", self.device_id])
|
|
162
|
+
cmd.extend(["forward", f"tcp:{self.port}", "localabstract:scrcpy"])
|
|
163
|
+
|
|
164
|
+
process = await asyncio.create_subprocess_exec(
|
|
165
|
+
*cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
166
|
+
)
|
|
167
|
+
await process.wait()
|
|
168
|
+
self.forward_cleanup_needed = True
|
|
169
|
+
|
|
170
|
+
async def _start_server(self) -> None:
|
|
171
|
+
"""Start scrcpy server on device with retry on address conflict."""
|
|
172
|
+
max_retries = 3
|
|
173
|
+
retry_delay = 2
|
|
174
|
+
|
|
175
|
+
for attempt in range(max_retries):
|
|
176
|
+
cmd = ["adb"]
|
|
177
|
+
if self.device_id:
|
|
178
|
+
cmd.extend(["-s", self.device_id])
|
|
179
|
+
|
|
180
|
+
# Build server command
|
|
181
|
+
# Note: scrcpy 3.3+ uses different parameter format
|
|
182
|
+
server_args = [
|
|
183
|
+
"shell",
|
|
184
|
+
"CLASSPATH=/data/local/tmp/scrcpy-server",
|
|
185
|
+
"app_process",
|
|
186
|
+
"/",
|
|
187
|
+
"com.genymobile.scrcpy.Server",
|
|
188
|
+
"3.3.3", # scrcpy version - must match installed version
|
|
189
|
+
f"max_size={self.max_size}",
|
|
190
|
+
f"video_bit_rate={self.bit_rate}",
|
|
191
|
+
"max_fps=20", # ✅ Limit to 20fps to reduce data volume
|
|
192
|
+
"tunnel_forward=true",
|
|
193
|
+
"audio=false",
|
|
194
|
+
"control=false",
|
|
195
|
+
"cleanup=false",
|
|
196
|
+
# Force I-frame (IDR) every 1 second for reliable reconnection
|
|
197
|
+
"video_codec_options=i-frame-interval=1",
|
|
198
|
+
]
|
|
199
|
+
cmd.extend(server_args)
|
|
200
|
+
|
|
201
|
+
# Capture stderr to see error messages
|
|
202
|
+
self.scrcpy_process = await asyncio.create_subprocess_exec(
|
|
203
|
+
*cmd,
|
|
204
|
+
stdout=subprocess.PIPE,
|
|
205
|
+
stderr=subprocess.PIPE
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# Wait for server to start
|
|
209
|
+
await asyncio.sleep(2)
|
|
210
|
+
|
|
211
|
+
# Check if process is still running
|
|
212
|
+
if self.scrcpy_process.returncode is not None:
|
|
213
|
+
# Process has exited
|
|
214
|
+
stdout, stderr = await self.scrcpy_process.communicate()
|
|
215
|
+
error_msg = stderr.decode() if stderr else stdout.decode()
|
|
216
|
+
|
|
217
|
+
# Check if it's an "Address already in use" error
|
|
218
|
+
if "Address already in use" in error_msg:
|
|
219
|
+
if attempt < max_retries - 1:
|
|
220
|
+
print(f"[ScrcpyStreamer] Address in use, retrying in {retry_delay}s (attempt {attempt + 1}/{max_retries})...")
|
|
221
|
+
await self._cleanup_existing_server()
|
|
222
|
+
await asyncio.sleep(retry_delay)
|
|
223
|
+
continue
|
|
224
|
+
else:
|
|
225
|
+
raise RuntimeError(f"scrcpy server failed after {max_retries} attempts: {error_msg}")
|
|
226
|
+
else:
|
|
227
|
+
raise RuntimeError(f"scrcpy server exited immediately: {error_msg}")
|
|
228
|
+
|
|
229
|
+
# Server started successfully
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
raise RuntimeError("Failed to start scrcpy server after maximum retries")
|
|
233
|
+
|
|
234
|
+
async def _connect_socket(self) -> None:
|
|
235
|
+
"""Connect to scrcpy TCP socket."""
|
|
236
|
+
self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
237
|
+
self.tcp_socket.settimeout(5)
|
|
238
|
+
|
|
239
|
+
# Increase socket buffer size for high-resolution video
|
|
240
|
+
# Default is often 64KB, but complex frames can be 200-500KB
|
|
241
|
+
try:
|
|
242
|
+
self.tcp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2 * 1024 * 1024) # 2MB
|
|
243
|
+
print("[ScrcpyStreamer] Set socket receive buffer to 2MB")
|
|
244
|
+
except OSError as e:
|
|
245
|
+
print(f"[ScrcpyStreamer] Warning: Failed to set socket buffer size: {e}")
|
|
246
|
+
|
|
247
|
+
# Retry connection
|
|
248
|
+
for _ in range(5):
|
|
249
|
+
try:
|
|
250
|
+
self.tcp_socket.connect(("localhost", self.port))
|
|
251
|
+
self.tcp_socket.settimeout(None) # Non-blocking for async
|
|
252
|
+
return
|
|
253
|
+
except (ConnectionRefusedError, OSError):
|
|
254
|
+
await asyncio.sleep(0.5)
|
|
255
|
+
|
|
256
|
+
raise ConnectionError("Failed to connect to scrcpy server")
|
|
257
|
+
|
|
258
|
+
def _find_nal_units(self, data: bytes) -> list[tuple[int, int, int]]:
|
|
259
|
+
"""Find NAL units in H.264 data.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
List of (start_pos, nal_type, nal_size) tuples
|
|
263
|
+
"""
|
|
264
|
+
nal_units = []
|
|
265
|
+
i = 0
|
|
266
|
+
data_len = len(data)
|
|
267
|
+
|
|
268
|
+
while i < data_len - 4:
|
|
269
|
+
# Look for start codes: 0x00 0x00 0x00 0x01 or 0x00 0x00 0x01
|
|
270
|
+
if data[i:i+4] == b'\x00\x00\x00\x01':
|
|
271
|
+
start_code_len = 4
|
|
272
|
+
elif data[i:i+3] == b'\x00\x00\x01':
|
|
273
|
+
start_code_len = 3
|
|
274
|
+
else:
|
|
275
|
+
i += 1
|
|
276
|
+
continue
|
|
277
|
+
|
|
278
|
+
# NAL unit type is in lower 5 bits of first byte after start code
|
|
279
|
+
nal_start = i + start_code_len
|
|
280
|
+
if nal_start >= data_len:
|
|
281
|
+
break
|
|
282
|
+
|
|
283
|
+
nal_type = data[nal_start] & 0x1F
|
|
284
|
+
|
|
285
|
+
# Find next start code to determine NAL unit size
|
|
286
|
+
next_start = nal_start + 1
|
|
287
|
+
while next_start < data_len - 3:
|
|
288
|
+
if (data[next_start:next_start+4] == b'\x00\x00\x00\x01' or
|
|
289
|
+
data[next_start:next_start+3] == b'\x00\x00\x01'):
|
|
290
|
+
break
|
|
291
|
+
next_start += 1
|
|
292
|
+
else:
|
|
293
|
+
next_start = data_len
|
|
294
|
+
|
|
295
|
+
nal_size = next_start - i
|
|
296
|
+
nal_units.append((i, nal_type, nal_size))
|
|
297
|
+
|
|
298
|
+
i = next_start
|
|
299
|
+
|
|
300
|
+
return nal_units
|
|
301
|
+
|
|
302
|
+
def _cache_nal_units(self, data: bytes) -> None:
|
|
303
|
+
"""Parse and cache INITIAL complete NAL units (SPS, PPS, IDR).
|
|
304
|
+
|
|
305
|
+
IMPORTANT: Only caches complete SPS/PPS from stream start.
|
|
306
|
+
NAL units may be truncated across chunks, so we validate minimum sizes
|
|
307
|
+
and lock the cache after getting complete initial parameters.
|
|
308
|
+
"""
|
|
309
|
+
nal_units = self._find_nal_units(data)
|
|
310
|
+
|
|
311
|
+
for start, nal_type, size in nal_units:
|
|
312
|
+
nal_data = data[start:start+size]
|
|
313
|
+
|
|
314
|
+
if nal_type == 7: # SPS
|
|
315
|
+
# Only cache SPS if not yet locked
|
|
316
|
+
if not self.sps_pps_locked:
|
|
317
|
+
# Validate: SPS should be at least 10 bytes
|
|
318
|
+
if size >= 10 and not self.cached_sps:
|
|
319
|
+
self.cached_sps = nal_data
|
|
320
|
+
hex_preview = ' '.join(f'{b:02x}' for b in nal_data[:min(12, len(nal_data))])
|
|
321
|
+
print(f"[ScrcpyStreamer] ✓ Cached complete SPS ({size} bytes): {hex_preview}...")
|
|
322
|
+
elif size < 10:
|
|
323
|
+
print(f"[ScrcpyStreamer] ✗ Skipped truncated SPS ({size} bytes, too short)")
|
|
324
|
+
|
|
325
|
+
elif nal_type == 8: # PPS
|
|
326
|
+
# Only cache PPS if not yet locked
|
|
327
|
+
if not self.sps_pps_locked:
|
|
328
|
+
# Validate: PPS should be at least 6 bytes
|
|
329
|
+
if size >= 6 and not self.cached_pps:
|
|
330
|
+
self.cached_pps = nal_data
|
|
331
|
+
hex_preview = ' '.join(f'{b:02x}' for b in nal_data[:min(12, len(nal_data))])
|
|
332
|
+
print(f"[ScrcpyStreamer] ✓ Cached complete PPS ({size} bytes): {hex_preview}...")
|
|
333
|
+
elif size < 6:
|
|
334
|
+
print(f"[ScrcpyStreamer] ✗ Skipped truncated PPS ({size} bytes, too short)")
|
|
335
|
+
|
|
336
|
+
elif nal_type == 5: # IDR frame
|
|
337
|
+
# ✅ ALWAYS update IDR to keep the LATEST frame
|
|
338
|
+
# This gives better UX on reconnect (recent content, not stale startup frame)
|
|
339
|
+
if self.cached_sps and self.cached_pps:
|
|
340
|
+
is_first = self.cached_idr is None
|
|
341
|
+
self.cached_idr = nal_data
|
|
342
|
+
if is_first:
|
|
343
|
+
print(f"[ScrcpyStreamer] ✓ Cached initial IDR frame ({size} bytes)")
|
|
344
|
+
# Don't log every IDR update (too verbose)
|
|
345
|
+
|
|
346
|
+
# Lock SPS/PPS once we have complete initial parameters
|
|
347
|
+
if self.cached_sps and self.cached_pps and not self.sps_pps_locked:
|
|
348
|
+
self.sps_pps_locked = True
|
|
349
|
+
print("[ScrcpyStreamer] 🔒 SPS/PPS locked (IDR will continue updating)")
|
|
350
|
+
|
|
351
|
+
def _prepend_sps_pps_to_idr(self, data: bytes) -> bytes:
|
|
352
|
+
"""Prepend SPS/PPS before EVERY IDR frame unconditionally.
|
|
353
|
+
|
|
354
|
+
This ensures that clients can start decoding from any IDR frame,
|
|
355
|
+
even if they join mid-stream. We always prepend to guarantee
|
|
356
|
+
that every IDR is self-contained.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
Modified data with SPS/PPS prepended to all IDR frames
|
|
360
|
+
"""
|
|
361
|
+
if not self.cached_sps or not self.cached_pps:
|
|
362
|
+
return data
|
|
363
|
+
|
|
364
|
+
nal_units = self._find_nal_units(data)
|
|
365
|
+
if not nal_units:
|
|
366
|
+
return data
|
|
367
|
+
|
|
368
|
+
# Find all IDR frames
|
|
369
|
+
idr_positions = [(start, size) for start, nal_type, size in nal_units if nal_type == 5]
|
|
370
|
+
|
|
371
|
+
if not idr_positions:
|
|
372
|
+
return data
|
|
373
|
+
|
|
374
|
+
# Build modified data by prepending SPS/PPS before each IDR
|
|
375
|
+
result = bytearray()
|
|
376
|
+
last_pos = 0
|
|
377
|
+
sps_pps = self.cached_sps + self.cached_pps
|
|
378
|
+
|
|
379
|
+
for idr_start, idr_size in idr_positions:
|
|
380
|
+
# Add data before this IDR
|
|
381
|
+
result.extend(data[last_pos:idr_start])
|
|
382
|
+
|
|
383
|
+
# Check if SPS/PPS already exists right before this IDR
|
|
384
|
+
# (to avoid duplicating if scrcpy already sent them)
|
|
385
|
+
prepend_offset = max(0, idr_start - len(sps_pps))
|
|
386
|
+
if data[prepend_offset:idr_start] != sps_pps:
|
|
387
|
+
# Prepend SPS/PPS before this IDR
|
|
388
|
+
result.extend(sps_pps)
|
|
389
|
+
print(f"[ScrcpyStreamer] Prepended SPS/PPS before IDR at position {idr_start}")
|
|
390
|
+
|
|
391
|
+
# Update position to start of IDR
|
|
392
|
+
last_pos = idr_start
|
|
393
|
+
|
|
394
|
+
# Add remaining data (including all IDR frames and data after)
|
|
395
|
+
result.extend(data[last_pos:])
|
|
396
|
+
|
|
397
|
+
return bytes(result)
|
|
398
|
+
|
|
399
|
+
def get_initialization_data(self) -> bytes | None:
|
|
400
|
+
"""Get cached SPS/PPS/IDR for initializing new connections.
|
|
401
|
+
|
|
402
|
+
Returns:
|
|
403
|
+
Concatenated SPS + PPS + IDR, or None if not available
|
|
404
|
+
"""
|
|
405
|
+
if self.cached_sps and self.cached_pps:
|
|
406
|
+
# Return SPS + PPS (+ IDR if available)
|
|
407
|
+
init_data = self.cached_sps + self.cached_pps
|
|
408
|
+
if self.cached_idr:
|
|
409
|
+
init_data += self.cached_idr
|
|
410
|
+
|
|
411
|
+
# Validate data integrity
|
|
412
|
+
print(f"[ScrcpyStreamer] Returning init data:")
|
|
413
|
+
print(f" - SPS: {len(self.cached_sps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_sps[:8])}")
|
|
414
|
+
print(f" - PPS: {len(self.cached_pps)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_pps[:8])}")
|
|
415
|
+
if self.cached_idr:
|
|
416
|
+
print(f" - IDR: {len(self.cached_idr)} bytes, starts with {' '.join(f'{b:02x}' for b in self.cached_idr[:8])}")
|
|
417
|
+
print(f" - Total: {len(init_data)} bytes")
|
|
418
|
+
|
|
419
|
+
return init_data
|
|
420
|
+
return None
|
|
421
|
+
|
|
422
|
+
async def read_h264_chunk(self) -> bytes:
|
|
423
|
+
"""Read H.264 data chunk from socket.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
bytes: Raw H.264 data with SPS/PPS prepended to IDR frames
|
|
427
|
+
|
|
428
|
+
Raises:
|
|
429
|
+
ConnectionError: If socket is closed or error occurs
|
|
430
|
+
"""
|
|
431
|
+
if not self.tcp_socket:
|
|
432
|
+
raise ConnectionError("Socket not connected")
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
# Use asyncio to make socket read non-blocking
|
|
436
|
+
# Read up to 512KB at once for high-quality frames
|
|
437
|
+
loop = asyncio.get_event_loop()
|
|
438
|
+
data = await loop.run_in_executor(None, self.tcp_socket.recv, 512 * 1024)
|
|
439
|
+
|
|
440
|
+
if not data:
|
|
441
|
+
raise ConnectionError("Socket closed by remote")
|
|
442
|
+
|
|
443
|
+
# Log large chunks (might indicate complex frames)
|
|
444
|
+
if len(data) > 200 * 1024: # > 200KB
|
|
445
|
+
print(f"[ScrcpyStreamer] Large chunk received: {len(data) / 1024:.1f} KB")
|
|
446
|
+
|
|
447
|
+
# Cache INITIAL complete SPS/PPS/IDR for future use
|
|
448
|
+
# (Later chunks may have truncated NAL units, so we only cache once)
|
|
449
|
+
self._cache_nal_units(data)
|
|
450
|
+
|
|
451
|
+
# NOTE: We don't automatically prepend SPS/PPS here because:
|
|
452
|
+
# 1. NAL units may be truncated across chunks
|
|
453
|
+
# 2. Prepending truncated SPS/PPS causes decoding errors
|
|
454
|
+
# 3. Instead, we send cached complete SPS/PPS when new connections join
|
|
455
|
+
|
|
456
|
+
return data
|
|
457
|
+
except ConnectionError:
|
|
458
|
+
raise
|
|
459
|
+
except Exception as e:
|
|
460
|
+
print(f"[ScrcpyStreamer] Unexpected error in read_h264_chunk: {type(e).__name__}: {e}")
|
|
461
|
+
raise ConnectionError(f"Failed to read from socket: {e}") from e
|
|
462
|
+
|
|
463
|
+
def stop(self) -> None:
|
|
464
|
+
"""Stop scrcpy server and cleanup resources."""
|
|
465
|
+
# Close socket
|
|
466
|
+
if self.tcp_socket:
|
|
467
|
+
try:
|
|
468
|
+
self.tcp_socket.close()
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
self.tcp_socket = None
|
|
472
|
+
|
|
473
|
+
# Kill server process
|
|
474
|
+
if self.scrcpy_process:
|
|
475
|
+
try:
|
|
476
|
+
self.scrcpy_process.terminate()
|
|
477
|
+
self.scrcpy_process.wait(timeout=2)
|
|
478
|
+
except Exception:
|
|
479
|
+
try:
|
|
480
|
+
self.scrcpy_process.kill()
|
|
481
|
+
except Exception:
|
|
482
|
+
pass
|
|
483
|
+
self.scrcpy_process = None
|
|
484
|
+
|
|
485
|
+
# Remove port forwarding
|
|
486
|
+
if self.forward_cleanup_needed:
|
|
487
|
+
try:
|
|
488
|
+
cmd = ["adb"]
|
|
489
|
+
if self.device_id:
|
|
490
|
+
cmd.extend(["-s", self.device_id])
|
|
491
|
+
cmd.extend(["forward", "--remove", f"tcp:{self.port}"])
|
|
492
|
+
subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=2)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
self.forward_cleanup_needed = False
|
|
496
|
+
|
|
497
|
+
def __del__(self):
|
|
498
|
+
"""Cleanup on destruction."""
|
|
499
|
+
self.stop()
|