Skip to content

Commit 2145c54

Browse files
renegadelinkclaude
andcommitted
services/cli: add ValeriaScreenCapture API + valeria CLI
Adds the public Python API for iOS H.264 screen capture and the `pymobiledevice3 valeria` CLI on top of it (macOS only): pymobiledevice3.services.valeria ValeriaScreenCapture abstract base; create(udid) factory H264Frame one access unit (AVCC NAL data + SPS/PPS) {Device,Multiple,...}Error specific exceptions pymobiledevice3 valeria Annex-B-framed H.264 to file or stdout -o stream.h264 --duration 10 -o - | ffmpeg -i - -c:v copy out.mp4 -o - | ffplay -f h264 - Args: --output (required, '-' for stdout), --udid, --duration (0 = until interrupt). The macOS CoreMediaIO backend arrives in the next commit. With no backend installed, ValeriaScreenCapture.create() raises BackendUnavailableError. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 41526a9 commit 2145c54

4 files changed

Lines changed: 336 additions & 0 deletions

File tree

pymobiledevice3/__main__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@
121121
"usbmux": "usbmux",
122122
"webinspector": "webinspector",
123123
"idam": "idam",
124+
"valeria": "valeria",
124125
"version": "version",
125126
}
126127

pymobiledevice3/cli/valeria.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""``pymobiledevice3 valeria`` -- H.264 screen capture over USB (macOS).
2+
3+
Exposes the unified :class:`pymobiledevice3.services.valeria.ValeriaScreenCapture`
4+
service: enable iOS screen capture, write Annex-B-framed H.264 to a file or
5+
stdout. Decode/render is the consumer's job; pipe the output to ``ffmpeg``
6+
to render or transcode.
7+
8+
Examples
9+
--------
10+
11+
pymobiledevice3 valeria -o /tmp/out.h264
12+
pymobiledevice3 valeria -o - --duration 10 | ffplay -f h264 -
13+
"""
14+
from __future__ import annotations
15+
16+
import logging
17+
import sys
18+
import time
19+
from typing import Annotated, Optional
20+
21+
import typer
22+
from typer_injector import InjectingTyper
23+
24+
from pymobiledevice3.services.valeria import (
25+
BackendUnavailableError,
26+
DeviceNotFoundError,
27+
ValeriaScreenCapture,
28+
MultipleDevicesError,
29+
ScreenRecordingPermissionError,
30+
)
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
cli = InjectingTyper(
36+
name="valeria",
37+
help="iOS screen capture (H.264 over USB).",
38+
no_args_is_help=True,
39+
)
40+
41+
42+
def _open_sink(output: str) -> tuple:
43+
if output == "-":
44+
return sys.stdout.buffer, False
45+
return open(output, "wb"), True
46+
47+
48+
@cli.callback(invoke_without_command=True)
49+
def capture(
50+
output: Annotated[str, typer.Option(
51+
"--output", "-o",
52+
help="Output file path, or '-' for stdout (e.g. for piping into ffmpeg).",
53+
)],
54+
udid: Annotated[Optional[str], typer.Option(
55+
"--udid",
56+
help="Match a specific device by UDID (required when multiple devices "
57+
"are attached).",
58+
)] = None,
59+
duration: Annotated[int, typer.Option(
60+
"--duration",
61+
help="Stop after N seconds (0 = run until interrupted).",
62+
)] = 0,
63+
) -> None:
64+
"""Capture the iOS screen as Annex-B H.264 and write to OUTPUT."""
65+
try:
66+
cap = ValeriaScreenCapture.create(udid=udid)
67+
except (BackendUnavailableError, ValueError) as exc:
68+
typer.echo(f"error: {exc}", err=True)
69+
raise typer.Exit(code=2)
70+
71+
try:
72+
cap.start()
73+
except (DeviceNotFoundError, MultipleDevicesError,
74+
ScreenRecordingPermissionError) as exc:
75+
typer.echo(f"error: {exc}", err=True)
76+
raise typer.Exit(code=1)
77+
except Exception as exc:
78+
typer.echo(f"error: failed to start capture: {exc}", err=True)
79+
raise typer.Exit(code=1)
80+
81+
sink, close_sink = _open_sink(output)
82+
n_frames = 0
83+
n_bytes = 0
84+
85+
def consume() -> None:
86+
nonlocal n_frames, n_bytes
87+
deadline = time.monotonic() + duration if duration > 0 else None
88+
try:
89+
for frame in cap.frames():
90+
data = frame.to_annex_b()
91+
sink.write(data)
92+
sink.flush()
93+
n_frames += 1
94+
n_bytes += len(data)
95+
if deadline is not None and time.monotonic() >= deadline:
96+
break
97+
except KeyboardInterrupt:
98+
pass
99+
100+
try:
101+
# cap.run() drives the main thread's CFRunLoop on the macOS CMIO
102+
# backend so callbacks dispatch event-driven to *consume* on a
103+
# worker thread.
104+
cap.run(consume)
105+
except KeyboardInterrupt:
106+
pass
107+
finally:
108+
if close_sink:
109+
sink.close()
110+
cap.stop()
111+
typer.echo(
112+
f"wrote {n_frames} frames ({n_bytes / 1024:.1f} KiB) "
113+
f"from {cap.device_name} ({cap.width}x{cap.height})",
114+
err=True,
115+
)

pymobiledevice3/exceptions.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,3 +529,24 @@ class WdaError(PyMobileDevice3Exception):
529529
def __init__(self, message: str, status_code: Optional[int] = None):
530530
super().__init__(message)
531531
self.status_code = status_code
532+
533+
534+
class MultipleDevicesError(PyMobileDevice3Exception):
535+
"""Multiple iOS devices are present and the requested one cannot be
536+
disambiguated. Common on macOS without root when two same-model devices
537+
are attached."""
538+
539+
pass
540+
541+
542+
class ScreenRecordingPermissionError(PyMobileDevice3Exception):
543+
"""The macOS Screen Recording TCC privilege is not granted to the parent
544+
process. Required for CoreMediaIO to enumerate iOS capture devices."""
545+
546+
pass
547+
548+
549+
class BackendUnavailableError(PyMobileDevice3Exception):
550+
"""The requested capture backend cannot run on this platform."""
551+
552+
pass
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
"""Public Valeria capture service - iOS H.264 screen capture over USB.
2+
3+
Currently macOS-only via :mod:`valeria_cmio` (CoreMediaIO, pure ctypes - no
4+
PyObjC). Speaks the QuickTime USB protocol the device exposes and emits
5+
:class:`H264Frame` objects. Decode/render is the consumer's responsibility.
6+
"""
7+
from __future__ import annotations
8+
9+
import sys
10+
from abc import ABC, abstractmethod
11+
from typing import Any, AsyncIterator, Callable, Iterator, Literal, Optional
12+
13+
from pymobiledevice3.exceptions import (
14+
BackendUnavailableError,
15+
DeviceNotFoundError,
16+
MultipleDevicesError,
17+
ScreenRecordingPermissionError,
18+
)
19+
20+
# Re-exported for callers that import them via the service module.
21+
__all__ = [
22+
"BackendUnavailableError",
23+
"DeviceNotFoundError",
24+
"H264Frame",
25+
"ValeriaScreenCapture",
26+
"MultipleDevicesError",
27+
"ScreenRecordingPermissionError",
28+
]
29+
30+
31+
class H264Frame:
32+
"""One H.264 access unit as it came off the device.
33+
34+
``nalu_data`` is a concatenation of AVCC-framed NAL units (each
35+
prefixed with a 4-byte big-endian length). ``sps`` / ``pps`` are
36+
carried on keyframes so the decoder can re-init mid-stream.
37+
"""
38+
39+
__slots__ = ("nalu_data", "sps", "pps", "width", "height",
40+
"pts_value", "pts_scale")
41+
42+
def __init__(self) -> None:
43+
self.nalu_data: bytes = b""
44+
self.sps: bytes = b""
45+
self.pps: bytes = b""
46+
self.width: int = 0
47+
self.height: int = 0
48+
self.pts_value: int = 0
49+
self.pts_scale: int = 0
50+
51+
@property
52+
def is_keyframe(self) -> bool:
53+
"""A frame is treated as a keyframe iff both SPS and PPS are present
54+
(the iOS encoder emits parameter sets only with IDR frames)."""
55+
return bool(self.sps and self.pps)
56+
57+
@property
58+
def pts_ns(self) -> int:
59+
"""Presentation timestamp in nanoseconds, derived from the CMTime
60+
value/scale pair. Returns 0 if scale is unset."""
61+
if self.pts_scale == 0:
62+
return 0
63+
return self.pts_value * 1_000_000_000 // self.pts_scale
64+
65+
def to_annex_b(self) -> bytes:
66+
"""Return Annex-B-framed bytes (``0x00000001`` start codes), with
67+
SPS/PPS prepended when present. Suitable for piping to FFmpeg or
68+
feeding a hardware decoder."""
69+
start_code = b"\x00\x00\x00\x01"
70+
parts: list[bytes] = []
71+
if self.sps:
72+
parts.append(start_code + self.sps)
73+
if self.pps:
74+
parts.append(start_code + self.pps)
75+
pos = 0
76+
data = self.nalu_data
77+
while pos + 4 <= len(data):
78+
nalu_len = int.from_bytes(data[pos:pos + 4], "big")
79+
pos += 4
80+
if nalu_len <= 0 or pos + nalu_len > len(data):
81+
break
82+
parts.append(start_code + data[pos:pos + nalu_len])
83+
pos += nalu_len
84+
return b"".join(parts)
85+
86+
87+
Backend = Literal["auto", "cmio"]
88+
89+
90+
class ValeriaScreenCapture(ABC):
91+
"""Abstract base class. The macOS implementation lives in
92+
:mod:`valeria_cmio`.
93+
94+
Concrete implementations push :class:`H264Frame` objects onto an
95+
internal bounded queue (capacity 90) and drain the queue on overflow
96+
so consumers resync at the next IDR.
97+
"""
98+
99+
@classmethod
100+
def create(cls, udid: Optional[str] = None,
101+
backend: Backend = "auto") -> "ValeriaScreenCapture":
102+
"""Construct the appropriate backend.
103+
104+
:param udid: Match a specific iDevice by UDID. ``None`` selects
105+
the sole attached device (raises :class:`MultipleDevicesError`
106+
if there are multiple).
107+
:param backend: ``"auto"`` (default) or ``"cmio"``. Both currently
108+
resolve to the CoreMediaIO backend; the parameter is reserved
109+
for forward-compat as additional backends are added.
110+
111+
Raises :class:`BackendUnavailableError` on non-macOS platforms.
112+
"""
113+
if backend not in ("auto", "cmio"):
114+
raise ValueError(
115+
f"backend must be one of 'auto', 'cmio'; got {backend!r}"
116+
)
117+
118+
if sys.platform != "darwin":
119+
raise BackendUnavailableError(
120+
"ValeriaScreenCapture currently requires macOS "
121+
"(CoreMediaIO is Apple-only)"
122+
)
123+
try:
124+
from pymobiledevice3.services.valeria_cmio import ValeriaCMIO
125+
except ImportError as exc:
126+
raise BackendUnavailableError(
127+
f"cmio backend module not available: {exc}"
128+
) from exc
129+
return ValeriaCMIO(udid=udid)
130+
131+
@abstractmethod
132+
def start(self) -> None:
133+
"""Open the device, negotiate the H.264 stream, begin capture.
134+
135+
Raises :class:`DeviceNotFoundError`, :class:`MultipleDevicesError`,
136+
or :class:`ScreenRecordingPermissionError` on the relevant failures.
137+
138+
On macOS the CoreMediaIO backend blocks the calling thread for up to
139+
~10 s the first time it runs (DAL plugin load). When calling from
140+
inside an asyncio context, use :meth:`astart` so the event loop
141+
stays responsive during the wait."""
142+
143+
async def astart(self) -> None:
144+
"""Async-friendly :meth:`start`. The default just calls
145+
:meth:`start` synchronously - backends with a long blocking startup
146+
(CMIO) override this to yield to the event loop between internal
147+
waits so other coroutines (lockdown keep-alives etc.) keep running."""
148+
self.start()
149+
150+
@abstractmethod
151+
def stop(self) -> None:
152+
"""Close the stream and release device resources."""
153+
154+
@property
155+
@abstractmethod
156+
def width(self) -> int:
157+
"""Stream width in pixels. 0 until the first frame is parsed."""
158+
159+
@property
160+
@abstractmethod
161+
def height(self) -> int:
162+
"""Stream height in pixels. 0 until the first frame is parsed."""
163+
164+
@property
165+
@abstractmethod
166+
def device_name(self) -> str:
167+
"""Human-readable device name."""
168+
169+
@abstractmethod
170+
def frames(self) -> Iterator[H264Frame]:
171+
"""Blocking iterator that yields frames until :meth:`stop` is called.
172+
173+
``frames()`` and :meth:`aframes` drain the same internal queue - use
174+
one or the other per session, not both."""
175+
176+
@abstractmethod
177+
def aframes(self) -> AsyncIterator[H264Frame]:
178+
"""Async iterator counterpart of :meth:`frames`. Same queue rules apply."""
179+
180+
def run(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
181+
"""Invoke ``fn(*args, **kwargs)`` under whatever threading context
182+
this backend prefers, returning ``fn``'s return value.
183+
184+
The CoreMediaIO backend on macOS commandeers the calling thread to
185+
drive ``CFRunLoopRun()`` continuously while ``fn`` runs on a worker
186+
thread - required because the iOS DAL plugin dispatches its
187+
callbacks only via the main thread's CFRunLoop. ``fn`` can iterate
188+
:meth:`frames` / :meth:`aframes` event-driven (no polling) inside
189+
this wrapper.
190+
191+
Use it any time you'd loop over frames in a long-running program::
192+
193+
cap.start()
194+
try:
195+
cap.run(lambda: [process(f) for f in cap.frames()])
196+
finally:
197+
cap.stop()
198+
"""
199+
return fn(*args, **kwargs)

0 commit comments

Comments
 (0)