feat: support orientation-aware recording and device wakefulness

Roll scrcpy recordings by device rotation and duration, and publish timeline manifests for playback and analysis.

Add seamless segmented playback in the showcase UI and configurable stay-awake strategies for Android automation.
This commit is contained in:
somew1nd committed 2026-08-22 19:45:02 -07:00
1 parent 160f43a74a
commit 474e56ef8e
14 files changed
+1150 -310

No files matched your search

+331
View File
@@ -19,8 +19,10 @@ Provides shared types and utilities for video recording across platforms.
import asyncio
import importlib.util
import json
from pathlib import Path
import platform
import re
import shutil
import subprocess
from typing import Any
@@ -34,6 +36,7 @@ from artemis.utils.logger import get_logger
logger = get_logger(__name__)
DEFAULT_MAX_DURATION_SECONDS = 900 # 15 minutes
ANDROID_RECORDING_SEGMENT_SECONDS = 1800
VIDEO_READY_DELAY_SECONDS = 1
ANDROID_DEVICE_VIDEO_PATH = "/sdcard/screen_recording.mp4"
ANDROID_MAX_RECORDING_DURATION_SECONDS = 180 # Android screenrecord limit
@@ -44,6 +47,37 @@ MAX_VIDEO_SIZE_MB = 500
MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024
def build_scrcpy_record_command(
scrcpy_executable: str,
device_id: str,
output_path: Path,
video_bit_rate: str = "2M",
lock_capture_orientation: bool = True,
) -> list[str]:
"""Build the shared scrcpy command used by all recording paths.
Each recording segment locks the orientation present when scrcpy starts.
The recording supervisor starts a new segment when Android rotates, so a
segment never contains multiple coded sizes while still displaying the app
in its natural orientation.
"""
command = [
scrcpy_executable,
"--serial",
device_id,
"--no-window",
"--record",
str(output_path),
"--record-format",
"mkv",
"--video-bit-rate",
video_bit_rate,
]
if lock_capture_orientation:
command.append("--capture-orientation=@")
return command
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
@@ -73,11 +107,17 @@ class RecordingSession(BaseModel):
process: Any = None
data_engine_start_time: float | None = None
local_video_path: Path | None = None
capture_width: int | None = None
capture_height: int | None = None
android_device_path: str = ANDROID_DEVICE_VIDEO_PATH
android_video_segments: list[Path] = []
android_segment_index: int = 0
android_restart_task: asyncio.Task | None = None
watchdog_task: asyncio.Task | None = None
android_rotation: int | None = None
android_segment_started_at: float | None = None
android_segment_records: list[dict[str, Any]] = []
android_conversion_tasks: list[asyncio.Task] = []
is_active: bool = True
errors: list[str] = []
@@ -138,6 +178,297 @@ def get_ffmpeg_path() -> str:
return "ffmpeg"
def get_ffprobe_path() -> str:
"""Get ffprobe for lightweight segment metadata inspection."""
return shutil.which("ffprobe") or "ffprobe"
async def normalize_recording_to_mp4(
source_path: Path,
output_path: Path,
capture_width: int | None,
capture_height: int | None,
) -> bool:
"""Re-encode a recording onto one fixed canvas for browser playback."""
if not source_path.exists():
return False
width = max(2, int(capture_width or 1080)) // 2 * 2
height = max(2, int(capture_height or 1920)) // 2 * 2
video_filter = (
f"scale={width}:{height}:force_original_aspect_ratio=decrease:"
f"force_divisible_by=2,pad={width}:{height}:"
"(ow-iw)/2:(oh-ih)/2:color=black,setsar=1"
)
try:
process = await asyncio.create_subprocess_exec(
get_ffmpeg_path(),
"-y",
"-fflags",
"+genpts",
"-i",
str(source_path),
"-map",
"0:v:0",
"-map",
"0:a?",
"-vf",
video_filter,
"-r",
"30",
"-fps_mode",
"cfr",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
str(output_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await process.communicate()
if process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
return True
error_detail = stderr.decode(errors="replace")[-4000:]
logger.error(
f"Failed to normalize recording to MP4 (code {process.returncode}): {error_detail}"
)
if output_path.exists():
output_path.unlink()
return False
except Exception as e:
logger.error(f"Failed to normalize recording to MP4: {e}")
if output_path.exists():
output_path.unlink()
return False
async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
"""Move a fixed-dimension recording segment into MP4 without re-encoding."""
if not source_path.exists() or source_path.stat().st_size == 0:
return False
try:
process = await asyncio.create_subprocess_exec(
get_ffmpeg_path(),
"-y",
"-fflags",
"+genpts",
"-i",
str(source_path),
"-map",
"0:v:0",
"-map",
"0:a?",
"-c",
"copy",
"-movflags",
"+faststart",
str(output_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await process.communicate()
valid = process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0
if valid:
return True
logger.error(
f"Failed to remux recording segment (code {process.returncode}): "
f"{stderr.decode(errors='replace')[-2000:]}"
)
except Exception as e:
logger.error(f"Failed to remux recording segment: {e}")
if output_path.exists():
output_path.unlink()
return False
async def probe_video_segment(video_path: Path) -> dict[str, float | int]:
"""Read duration and coded dimensions for a finalized segment."""
process = await asyncio.create_subprocess_exec(
get_ffprobe_path(),
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height:format=duration",
"-of",
"json",
str(video_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await process.communicate()
if process.returncode != 0:
return {}
try:
payload = json.loads(stdout)
stream = (payload.get("streams") or [{}])[0]
return {
"duration": float((payload.get("format") or {}).get("duration") or 0),
"width": int(stream.get("width") or 0),
"height": int(stream.get("height") or 0),
}
except (TypeError, ValueError, json.JSONDecodeError):
return {}
async def get_android_display_state(device_id: str) -> tuple[int, int, int] | None:
"""Return Android's current (rotation, width, height)."""
try:
process = await asyncio.create_subprocess_exec(
"adb",
"-s",
device_id,
"shell",
"dumpsys",
"window",
"displays",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
)
stdout, _stderr = await asyncio.wait_for(process.communicate(), timeout=3.0)
text = stdout.decode(errors="replace")
rotation_match = re.search(r"\bmRotation=(\d+)", text)
size_match = re.search(r"\bcur=(\d+)x(\d+)", text)
if rotation_match and size_match:
return (
int(rotation_match.group(1)),
int(size_match.group(1)),
int(size_match.group(2)),
)
except (OSError, TimeoutError):
pass
return None
async def write_recording_manifest(output_dir: Path, mp4_paths: list[Path]) -> Path | None:
"""Write the browser playlist used for orientation-aware playback."""
segments = []
timeline = 0.0
for path in mp4_paths:
if not path.exists():
continue
metadata = await probe_video_segment(path)
duration = float(metadata.get("duration", 0))
segments.append(
{
"file": path.name,
"start": round(timeline, 3),
"duration": round(duration, 3),
"width": int(metadata.get("width", 0)),
"height": int(metadata.get("height", 0)),
}
)
timeline += duration
if not segments:
return None
manifest_path = output_dir / "recording.json"
manifest_path.write_text(
json.dumps(
{"version": 1, "duration": round(timeline, 3), "segments": segments},
indent=2,
),
encoding="utf-8",
)
return manifest_path
async def render_timeline_clip(
segments: list[dict[str, Any]],
start_time: float,
end_time: float,
output_path: Path,
canvas_width: int = 720,
canvas_height: int = 1280,
) -> bool:
"""Render one continuous analyzer clip from orientation-aware segments.
Only the requested time window is decoded. This keeps the video analyzer's
single-MP4 contract without ever re-encoding the full recording.
"""
overlaps = []
for segment in segments:
path = Path(segment["path"])
seg_start = float(segment["start"])
seg_end = float(segment["end"])
overlap_start = max(start_time, seg_start)
overlap_end = min(end_time, seg_end)
if path.exists() and overlap_end > overlap_start:
overlaps.append((path, overlap_start - seg_start, overlap_end - seg_start))
if not overlaps:
return False
command = [get_ffmpeg_path(), "-y", "-fflags", "+genpts"]
for path, _start, _end in overlaps:
command.extend(["-i", str(path)])
filter_parts = []
labels = []
for index, (_path, local_start, local_end) in enumerate(overlaps):
label = f"v{index}"
filter_parts.append(
f"[{index}:v:0]trim=start={local_start:.6f}:end={local_end:.6f},"
"setpts=PTS-STARTPTS,"
f"scale={canvas_width}:{canvas_height}:force_original_aspect_ratio=decrease:"
"force_divisible_by=2,"
f"pad={canvas_width}:{canvas_height}:(ow-iw)/2:(oh-ih)/2:color=black,"
f"setsar=1,fps=15,format=yuv420p[{label}]"
)
labels.append(f"[{label}]")
if len(labels) == 1:
filter_parts.append(f"{labels[0]}null[outv]")
else:
filter_parts.append(f"{''.join(labels)}concat=n={len(labels)}:v=1:a=0[outv]")
command.extend(
[
"-filter_complex",
";".join(filter_parts),
"-map",
"[outv]",
"-c:v",
"libx264",
"-preset",
"veryfast",
"-crf",
"23",
"-movflags",
"+faststart",
str(output_path),
]
)
try:
process = await asyncio.create_subprocess_exec(
*command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await process.communicate()
if process.returncode == 0 and output_path.exists() and output_path.stat().st_size > 0:
return True
logger.error(
f"Failed to render analyzer timeline clip (code {process.returncode}): "
f"{stderr.decode(errors='replace')[-3000:]}"
)
except Exception as e:
logger.error(f"Failed to render analyzer timeline clip: {e}")
if output_path.exists():
output_path.unlink()
return False
def is_scrcpy_installed() -> bool:
"""Check if scrcpy is available in the system PATH."""