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:
1 parent
160f43a74a
commit
474e56ef8e
14 files changed
+1150
-310
No files matched your search
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user