Skip to content

Implement scrcpy screenshot backend in adb_vision#44

Open
Coldaine wants to merge 1 commit intomasterfrom
feat/scrcpy-screenshot-backend-9657118203258962835
Open

Implement scrcpy screenshot backend in adb_vision#44
Coldaine wants to merge 1 commit intomasterfrom
feat/scrcpy-screenshot-backend-9657118203258962835

Conversation

@Coldaine
Copy link
Owner

@Coldaine Coldaine commented Mar 3, 2026

This change implements a new screenshot backend using scrcpy in the adb_vision package. This backend is critical for environments like MEmu or VirtualBox where standard adb shell screencap returns blank images.

The implementation:

  1. Pushes the scrcpy-server-v1.20.jar to the Android device.
  2. Starts the server via app_process.
  3. Establishes a connection to the server's abstract socket via ADB port forwarding.
  4. Reads the H.264 video stream.
  5. Decodes the first available frame using av (PyAV) and converts it to a base64-encoded PNG.
  6. Ensures all resources (processes, sockets, port forwards) are cleaned up.

The new package adb_vision is designed to be standalone and does not import any ALAS internals.

Tests cover:

  • Happy path (successful capture and decode).
  • Server failure (process exit with error).
  • Decode failure (stream disconnection or no frames).

Fixes #41


PR created automatically by Jules for task 9657118203258962835 started by @Coldaine

- Created `adb_vision` package for standalone ADB vision tools.
- Implemented `_capture_scrcpy` in `adb_vision/screenshot.py` using `scrcpy-server-v1.20.jar`.
- Added high-performance H.264 stream decoding via PyAV.
- Added comprehensive unit tests with mocked ADB and socket interactions in `adb_vision/test_scrcpy.py`.
- Ensured proper cleanup of server processes and port forwarding.
- Followed strict modularity by avoiding any ALAS internal imports.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 3, 2026 04:58
@google-labs-jules
Copy link

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

import struct
import base64
import io
import os
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: Importing struct like it's going to a party, but it never shows up to work. The invite is right there on line 5, but the function body is ghosting it harder than my ex.

🩹 The Fix:

Suggested change
import os
import asyncio
import base64
import io
import re
from pathlib import Path
from typing import Protocol

📏 Severity: nitpick

import base64
import io
import os
import re
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: os is the plus-one that didn't even make it into the venue. Imported but never referenced — it's the os in 'gloss over unused imports.'

🩹 The Fix: Remove this import.

📏 Severity: nitpick

import os
import re
from pathlib import Path
from typing import Protocol, Optional
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: Optional is sitting on the bench with its helmet on, ready to play, but the coach never calls its name. Classic premature optimization for a type hint that never materialized.

🩹 The Fix: Remove Optional from the import.

📏 Severity: nitpick

if writer:
writer.close()
try:
await writer.wait_closed()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: This except Exception: pass is the debugging equivalent of a 'nothing to see here' sign after a car crash. Sure, wait_closed() might fail on a half-dead connection, but swallowing ALL exceptions means legitimate bugs go to die silently. I've debugged issues that took hours because someone thought 'eh, what could go wrong?'

🩹 The Fix:

Suggested change
await writer.wait_closed()
try:
await writer.wait_closed()
except (ConnectionResetError, BrokenPipeError):
pass # Expected on already-closed connections

📏 Severity: warning

except Exception:
pass

finally:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: A triple-nested try-except that ends in pass. This cleanup code is like a Russian nesting doll of exception suppression — by the time you get to the center, you've forgotten what error you were trying to handle. If kill() fails, you're truly out of options, but at least log it so we know something went sideways.

🩹 The Fix:

Suggested change
finally:
finally:
# 7. Cleanup
if server_process:
try:
server_process.terminate()
await asyncio.wait_for(server_process.wait(), timeout=2.0)
except ProcessLookupError:
pass # Already gone
except asyncio.TimeoutError:
try:
server_process.kill()
await server_process.wait()
except ProcessLookupError:
pass # Already gone

📏 Severity: warning

pass

if local_port:
try:
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: Another except Exception: pass? This is like leaving a port forward dangling on the device because we couldn't be bothered to handle the error. If adb forward --remove fails, the port stays allocated until someone manually cleans it up or the device reboots. Hope you don't hit this 20 times in a row.

🩹 The Fix:

Suggested change
try:
if local_port:
try:
await adb_run("-s", serial, "forward", "--remove", f"tcp:{local_port}", timeout=5.0)
except Exception as e:
import warnings
warnings.warn(f"Failed to remove ADB forward for port {local_port}: {e}")

📏 Severity: warning

base64.b64decode(result)

# Verify ADB calls
assert adb_run.call_count >= 3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔥 The Roast: assert adb_run.call_count >= 3 is the unit test equivalent of 'I think the code ran, probably, maybe.' This assertion is so loose it would pass if the function called ADB 47 times. The subsequent assert_any_call is the only thing saving this test from being completely useless.

🩹 The Fix:

Suggested change
assert adb_run.call_count >= 3
assert adb_run.call_count == 3
adb_run.assert_any_call("-s", serial, "push", ANY, "/data/local/tmp/scrcpy-server.jar", timeout=15.0)
adb_run.assert_any_call("-s", serial, "forward", "tcp:0", "localabstract:scrcpy", timeout=5.0)
adb_run.assert_any_call("-s", serial, "forward", "--remove", "tcp:21504", timeout=5.0)

📏 Severity: suggestion

@kilo-code-bot
Copy link

kilo-code-bot bot commented Mar 3, 2026

Code Review Roast 🔥

Verdict: 7 Issues Found | Recommendation: Address warnings before merge

Overview

Severity Count
🚨 critical 0
⚠️ warning 3
💡 suggestion 1
🤏 nitpick 3
Issue Details (click to expand)
File Line Roast
adb_vision/screenshot.py 5 Unused struct import
adb_vision/screenshot.py 6 Unused os import
adb_vision/screenshot.py 8 Unused Optional import
adb_vision/screenshot.py 143 Silent exception swallowing during writer close
adb_vision/screenshot.py 147 Triple-nested exception suppression in cleanup
adb_vision/screenshot.py 161 Silent failure on port forward removal (leaks ports)
adb_vision/test_scrcpy.py 66 Overly loose assertion (>= 3 instead of == 3)

🏆 Best part: The scrcpy integration is actually well-researched — those positional arguments for v1.20 are documented better than most enterprise APIs I've seen. Someone did their homework on the wire protocol. I need to sit down.

💀 Worst part: Three layers of except Exception: pass in the cleanup code. This is like putting a rug over a hole in the floor — the room looks clean until someone falls through. Silent cleanup failures mean orphaned port forwards, zombie processes, and 3 AM debugging sessions that start with 'why is port 21504 still in use?'

📊 Overall: Like a house with a gorgeous facade but duct tape holding the pipes together. The core functionality is solid, but the error handling needs to graduate from 'hope for the best' to 'plan for the worst.'

Files Reviewed (3 files)
  • adb_vision/__init__.py - 0 issues (it's literally empty, hard to mess up)
  • adb_vision/pyproject.toml - 0 issues
  • adb_vision/screenshot.py - 6 issues (mostly exception handling)
  • adb_vision/test_scrcpy.py - 1 issue (test quality)

Fix these issues in Kilo Cloud

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 83bfb2a91a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

try:
# 5. Read header (69 bytes)
# Dummy byte(1), Device name(64), Resolution(4)
await reader.readexactly(69)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard scrcpy socket reads with timeouts

This read path has no timeout (readexactly(69) here and later reader.read(...) in the decode loop), so if scrcpy accepts the TCP connection but stalls before emitting bytes, _capture_scrcpy() can hang forever and never reach the cleanup block. In practice that can wedge screenshot-dependent workflows until an external watchdog kills the task; wrapping these awaits in asyncio.wait_for(...) (or equivalent bounded read logic) avoids indefinite hangs on partial server failures.

Useful? React with 👍 / 👎.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new adb_vision standalone screenshot backend that captures a single frame via scrcpy-server and decodes it to a base64 PNG, targeting emulator environments where adb shell screencap produces blank images (Fixes #41).

Changes:

  • Implement _capture_scrcpy() to push/run scrcpy-server, forward the abstract socket, read H.264, decode the first frame via PyAV, and return base64 PNG.
  • Add mocked async tests for happy path, server failure, and decode failure scenarios.
  • Introduce adb_vision packaging metadata with av + pillow dependencies.

Reviewed changes

Copilot reviewed 3 out of 4 changed files in this pull request and generated 4 comments.

File Description
adb_vision/screenshot.py Implements the scrcpy-based single-frame screenshot capture and cleanup logic.
adb_vision/test_scrcpy.py Adds async unit tests with mocked ADB/subprocess/socket/decoder behavior.
adb_vision/pyproject.toml Declares the new package and runtime dependencies (av, pillow).
adb_vision/__init__.py Initializes the new package (currently empty).

Comment on lines +1 to +6
import pytest
import asyncio
import base64
import io
from unittest.mock import MagicMock, patch, AsyncMock, ANY
from adb_vision.screenshot import _capture_scrcpy
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests will not run under the repo’s default pytest configuration: pytest.ini restricts testpaths to agent_orchestrator/, so adb_vision/test_scrcpy.py is not collected in CI by default. If this package is meant to be tested automatically, either update the root pytest configuration / CI command, or relocate these tests under the collected test suite.

Copilot uses AI. Check for mistakes.
Comment on lines +2 to +5
import struct
import base64
import io
import os
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are unused imports here (e.g., struct and os), which adds noise and can confuse readers about what the implementation relies on. Please remove unused imports (or start using them if they’re intended for upcoming functionality).

Suggested change
import struct
import base64
import io
import os
import base64
import io

Copilot uses AI. Check for mistakes.
Comment on lines +71 to +72
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asyncio.create_subprocess_exec() is started with stdout/stderr set to PIPE, but the code never drains server_process.stdout (and only reads stderr on early exit). If scrcpy writes enough logs, the OS pipe buffer can fill and block the scrcpy process, potentially hanging screenshot capture. Consider redirecting output to DEVNULL, lowering log level, or continuously draining the pipes in background tasks while the server is running.

Suggested change
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL,

Copilot uses AI. Check for mistakes.
Comment on lines +32 to +38
local_jar = base_dir / "alas_wrapped" / "bin" / "scrcpy" / "scrcpy-server-v1.20.jar"

if not local_jar.exists():
# Fallback to current working directory relative path
local_jar = Path("alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar")
if not local_jar.exists():
raise FileNotFoundError(f"scrcpy-server-v1.20.jar not found. Checked: {local_jar.absolute()}")
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The FileNotFoundError message only reports the fallback path (alas_wrapped/bin/scrcpy/...) even though the function first checks a path relative to __file__. This makes debugging harder when the repo layout is unexpected. Consider including both attempted locations (or the originally computed path) in the error message.

Suggested change
local_jar = base_dir / "alas_wrapped" / "bin" / "scrcpy" / "scrcpy-server-v1.20.jar"
if not local_jar.exists():
# Fallback to current working directory relative path
local_jar = Path("alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar")
if not local_jar.exists():
raise FileNotFoundError(f"scrcpy-server-v1.20.jar not found. Checked: {local_jar.absolute()}")
primary_jar = base_dir / "alas_wrapped" / "bin" / "scrcpy" / "scrcpy-server-v1.20.jar"
if primary_jar.exists():
local_jar = primary_jar
else:
# Fallback to current working directory relative path
fallback_jar = Path("alas_wrapped/bin/scrcpy/scrcpy-server-v1.20.jar")
if fallback_jar.exists():
local_jar = fallback_jar
else:
raise FileNotFoundError(
"scrcpy-server-v1.20.jar not found. "
f"Checked: {primary_jar.resolve()} and {fallback_jar.resolve()}"
)

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Screenshot backend: scrcpy virtual display capture

2 participants