Files
Artemis-Eapil/artemis/utils/video.py
T
somew1nd 206a9ce921 feat: initial release of ARTEMIS Android Agent & Automation Platform
ARTEMIS is an autonomous multimodal Android agent and mobile UI automation
platform engineered for physical devices and emulators. It combines state-of-the-art
multimodal Vision-Language Models (VLMs) with hierarchical closed-loop planning,
resilient self-healing, and deep Android subsystem integration.

Key Features & Architectural Highlights:
- Dual-Engine Orchestration:
  * Pro Engine: Multi-agent closed-loop (Planner -> Operator -> Validator/Failure Analyzer -> Summarizer) powered by LangGraph.
  * Flash Engine: Ultra-fast reactive loop for deterministic, low-latency UI execution (<35 steps).
- Multimodal Perception & Grounding:
  * Hybrid visual screenshot grounding, real-time OCR extraction, and XML accessibility hierarchy parsing.
  * Dynamic-First, Coordinate-Fallback locating strategy for maximum resilience against UI drift.
- Autonomous Self-Healing & Diagnostics:
  * Failure analyzer and safety net for popup dismissal, crash recovery, and auto-retry.
  * Diagnostic sub-agents for log analysis, video recording inspection, and object detection.
- Unified CLI & Python SDK:
  * Rich CLI with commands: init, doctor, run, trace, batch, bench, server, and mcp.
  * Fluent Python SDK with Pydantic structured output models and package locking.
- Multi-Provider LLM Architecture:
  * Support for Google Gemini, OpenAI, Anthropic Claude, Vertex AI, OpenRouter, xAI Grok, and local endpoints (Ollama/vLLM).
  * Centralized configuration in config/artemis.jsonc.
- Native MCP & Observability:
  * Model Context Protocol (MCP) server endpoints for AI IDEs.
  * Full trace recording with before/after screenshots, action overlays, and execution replays.

Change-Id: I430b93ee2b76b54cc5a3c6eeccd15d2cf29830a1
2026-08-13 11:46:47 -07:00

537 lines
16 KiB
Python

# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Video recording utilities for mobile devices.
Provides shared types and utilities for video recording across platforms.
"""
import asyncio
import importlib.util
from pathlib import Path
import platform
import shutil
import subprocess
from typing import Any
from uuid import UUID
import cv2
from pydantic import BaseModel, ConfigDict
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
DEFAULT_MAX_DURATION_SECONDS = 900 # 15 minutes
VIDEO_READY_DELAY_SECONDS = 1
ANDROID_DEVICE_VIDEO_PATH = "/sdcard/screen_recording.mp4"
ANDROID_MAX_RECORDING_DURATION_SECONDS = 180 # Android screenrecord limit
# Expanded for Gemini File API (Supports up to 2GB).
# Target 100MB to allow 3-5min crisp video and prevent blurring for long durations.
MAX_VIDEO_SIZE_MB = 500
MAX_VIDEO_SIZE_BYTES = MAX_VIDEO_SIZE_MB * 1024 * 1024
class _CyFunctionDetectorMeta(type):
def __instancecheck__(self, instance):
name = type(instance).__name__
return (
name
in (
"cyfunction",
"cython_function_or_method",
"builtin_function_or_method",
)
or "cyfunction" in name.lower()
)
class CyFunctionDetector(metaclass=_CyFunctionDetectorMeta):
pass
class RecordingSession(BaseModel):
"""Tracks an active video recording session."""
model_config = ConfigDict(arbitrary_types_allowed=True)
video_id: UUID
device_id: str
start_time: float
process: Any = None
data_engine_start_time: float | None = None
local_video_path: Path | 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
is_active: bool = True
errors: list[str] = []
class VideoRecordingResult(BaseModel):
"""Result of a video recording operation."""
success: bool
message: str
video_path: Path | None = None
file_size_mb: float | None = None
duration_seconds: float | None = None
actual_start_relative_time: float | None = None
warning: str | None = None
model_config = ConfigDict(arbitrary_types_allowed=True)
# Global session storage - keyed by device_id
_active_recordings: dict[str, RecordingSession] = {}
def get_active_session(device_id: str) -> RecordingSession | None:
"""Get the active recording session for a device."""
return _active_recordings.get(device_id)
def set_active_session(device_id: str, session: RecordingSession) -> None:
"""Set the active recording session for a device."""
_active_recordings[device_id] = session
def remove_active_session(device_id: str) -> RecordingSession | None:
"""Remove and return the active recording session for a device."""
return _active_recordings.pop(device_id, None)
def has_active_session(device_id: str) -> bool:
"""Check if there's an active recording session for a device."""
return device_id in _active_recordings
def is_ffmpeg_installed() -> bool:
"""Check if ffmpeg is available via imageio_ffmpeg or system PATH."""
if importlib.util.find_spec("imageio_ffmpeg") is not None:
return True
return shutil.which("ffmpeg") is not None
def get_ffmpeg_path() -> str:
"""Get the path to the ffmpeg executable."""
try:
import imageio_ffmpeg
return imageio_ffmpeg.get_ffmpeg_exe()
except ImportError:
return "ffmpeg"
def is_scrcpy_installed() -> bool:
"""Check if scrcpy is available in the system PATH."""
return shutil.which("scrcpy") is not None
def detect_video_tools_enabled() -> bool:
"""Check if both scrcpy and ffmpeg are available to enable automated video features."""
return is_ffmpeg_installed() and is_scrcpy_installed()
class FFmpegNotInstalledError(Exception):
"""Raised when ffmpeg is required but not installed."""
def __init__(self):
os_name = platform.system().lower()
if os_name == "darwin": # macOS
install_instructions = "brew install ffmpeg"
elif os_name == "windows":
install_instructions = "Download from https://www.ffmpeg.org/download.html"
else: # Linux and others
install_instructions = (
"Install via your package manager (e.g., apt install ffmpeg,"
" dnf install ffmpeg) or download from"
" https://www.ffmpeg.org/download.html"
)
message = (
"\n\n❌ ffmpeg is required for video recording but is not"
" installed.\n\nPlease install ffmpeg first:\n →"
f" {install_instructions}\n\nAfter installation, restart Artemis.\n"
)
super().__init__(message)
def check_ffmpeg_available() -> None:
"""Check if ffmpeg is installed and raise an error if not.
Raises:
FFmpegNotInstalledError: If ffmpeg is not found in PATH.
"""
if not is_ffmpeg_installed():
raise FFmpegNotInstalledError()
async def concatenate_videos(segments: list[Path], output_path: Path) -> bool:
"""Concatenate multiple video segments using ffmpeg."""
if not segments:
return False
if len(segments) == 1:
shutil.move(segments[0], output_path)
return True
list_file = output_path.parent / "segments.txt"
with open(list_file, "w") as f:
for segment in segments:
f.write(f"file '{segment}'\n")
try:
process = await asyncio.create_subprocess_exec(
get_ffmpeg_path(),
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(list_file),
"-c",
"copy",
str(output_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
await process.wait()
return output_path.exists()
except Exception as e:
logger.error(f"Failed to concatenate videos: {e}")
return False
finally:
if list_file.exists():
list_file.unlink()
_drawtext_supported: bool | None = None
def is_ffmpeg_drawtext_supported() -> bool:
"""Check if ffmpeg supports the drawtext filter (requires libfreetype/libharfbuzz)."""
global _drawtext_supported
if _drawtext_supported is not None:
return _drawtext_supported
try:
res = subprocess.run(
[get_ffmpeg_path(), "-filters"],
capture_output=True,
text=True,
timeout=3,
)
_drawtext_supported = " drawtext " in res.stdout
except Exception:
_drawtext_supported = False
return _drawtext_supported
async def trim_video(
input_path: Path,
start_time: float,
end_time: float | None,
output_path: Path,
fast_copy: bool = True,
) -> bool:
"""Trim a video file using ffmpeg.
Uses fast stream copy (-c copy) by default with fallback to re-encoding.
"""
try:
cmd = [
get_ffmpeg_path(),
"-y",
"-ss",
str(start_time),
"-i",
str(input_path),
]
if end_time is not None:
duration = end_time - start_time
cmd.extend(["-t", str(duration)])
if fast_copy:
cmd.extend(["-c", "copy", str(output_path)])
else:
cmd.extend(
[
"-vf",
"setpts=PTS-STARTPTS",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
str(output_path),
]
)
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await process.communicate()
if process.returncode == 0 and output_path.exists():
return True
if fast_copy:
logger.warning(
f"ffmpeg fast copy trim failed (code {process.returncode}),"
f" falling back to re-encoding: {stderr.decode()[:200]}"
)
return await trim_video(input_path, start_time, end_time, output_path, fast_copy=False)
logger.error(f"ffmpeg trim failed (code {process.returncode}): {stderr.decode()}")
return False
except Exception as e:
logger.error(f"Failed to trim video: {e}")
return False
def cleanup_video_segments(segments: list[Path], keep_path: Path | None = None) -> None:
"""Clean up temporary video segments, optionally keeping one path."""
for segment in segments:
try:
if segment.exists() and segment != keep_path:
segment.unlink()
if segment.parent.exists() and not any(segment.parent.iterdir()):
segment.parent.rmdir()
except Exception:
pass
async def compress_video_for_api(
input_path: Path,
target_size_bytes: int = MAX_VIDEO_SIZE_BYTES,
force_compress: bool = False,
start_offset_seconds: float = 0.0,
slowdown_factor: float = 1.0,
) -> Path:
"""Compress a video to fit within API size limits using ffmpeg.
Uses a two-pass approach:
1. First check if video is already small enough
2. If not, compress with reduced resolution and bitrate
Args:
input_path: Path to the input video file
target_size_bytes: Target maximum file size in bytes
force_compress: If True, always perform compression (e.g., to extract
frames at 15fps)
start_offset_seconds: Offset to add to the burned-in timestamp
slowdown_factor: Factor to slow down the video by (e.g. 5.0 for 5x
slower) to increase API frame sampling rate
Returns:
Path to the compressed video (may be same as input if no compression
needed)
"""
if not input_path.exists():
raise FileNotFoundError(f"Video file not found: {input_path}")
current_size = input_path.stat().st_size
logger.info(f"Video size: {current_size / 1024 / 1024:.2f} MB")
if current_size <= target_size_bytes and not force_compress and slowdown_factor == 1.0:
logger.info(
"Video already within size limit and force_compress=False, no compression needed"
)
return input_path
logger.info(
f"Compressing video to fit within {target_size_bytes / 1024 / 1024:.1f}"
f" MB (slowdown={slowdown_factor})"
)
output_path = input_path.parent / f"compressed_{input_path.name}"
# Use Constant Rate Factor (CRF) for high-fidelity UI text readability.
# CRF dynamically allocates bitrates depending on scene motion.
logger.info("Compressing video using CRF=26 for high-fidelity UI text readability.")
# Compress with ffmpeg: reduce resolution to 720p max, use CRF
# and check if drawtext is supported
vf_parts = ["setpts=PTS-STARTPTS", "scale='min(720,iw)':'-2'"]
if is_ffmpeg_drawtext_supported():
vf_parts.append(
"drawtext=text='TS\\:"
f" %{{expr_int_format\\:trunc(t+{start_offset_seconds})\\:d}}"
" s':x=w-tw-30:y=120+th+20:fontcolor=white@0.6:fontsize=44:borderw=4:"
"bordercolor=red@0.6:box=1:boxcolor=yellow@0.4:boxborderw=10:font='Sans"
" Bold'"
)
else:
logger.warning(
"ffmpeg 'drawtext' filter not supported on this system. Skipping"
" burned-in timestamp overlay."
)
if slowdown_factor != 1.0:
vf_parts.append(f"setpts={slowdown_factor}*PTS")
vf_filter = ",".join(vf_parts)
compress_cmd = [
get_ffmpeg_path(),
"-y",
"-i",
str(input_path),
"-vf",
vf_filter,
"-r",
"15", # Set framerate to 15 fps
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"26", # High quality for UI elements
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-b:a",
"64k",
str(output_path),
]
try:
proc = await asyncio.create_subprocess_exec(
*compress_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
err_msg = stderr.decode().strip()
logger.error(f"ffmpeg compression failed (code {proc.returncode}): {err_msg}")
if force_compress or slowdown_factor != 1.0:
raise RuntimeError(
f"Video compression/slowdown failed (code {proc.returncode}): {err_msg}"
)
return input_path # Return original if compression fails and was optional
new_size = output_path.stat().st_size
logger.info(
f"Compressed: {current_size / 1024 / 1024:.2f} MB -> {new_size / 1024 / 1024:.2f} MB"
)
return output_path
except Exception as e:
logger.error(f"Video compression failed: {e}")
if force_compress or slowdown_factor != 1.0:
raise RuntimeError(f"Video compression/slowdown failed: {e}")
return input_path # Return original if compression fails and was optional
async def extract_audio_from_video(input_path: Path) -> Path:
"""Extract audio from a video file using ffmpeg.
Saves as .mp3.
"""
if not input_path.exists():
raise FileNotFoundError(f"Video file not found: {input_path}")
output_path = input_path.parent / f"audio_{input_path.stem}.mp3"
logger.info(f"Extracting audio from {input_path} to {output_path}")
cmd = [
get_ffmpeg_path(),
"-y",
"-i",
str(input_path),
"-vn",
"-c:a",
"libmp3lame",
"-b:a",
"128k",
str(output_path),
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
err_msg = stderr.decode().strip()
logger.error(f"ffmpeg audio extraction failed: {err_msg}")
raise RuntimeError(
f"ffmpeg audio extraction failed (code {proc.returncode}): {err_msg}"
)
return output_path
except Exception as e:
logger.error(f"Audio extraction failed: {e}")
raise e
def extract_keyframes_from_video(
video_path: Path | str,
fps: float = 1.0,
max_frames: int = 45,
max_dimension: int = 1080,
) -> list[tuple[float, bytes]]:
"""Extracts keyframes sampled at `fps` intervals.
Returns:
List of tuples: (timestamp_seconds, jpeg_bytes).
"""
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
logger.error(f"Failed to open video file for keyframe extraction: {video_path}")
return []
video_fps = cap.get(cv2.CAP_PROP_FPS) or 30.0
step = max(1, int(round(video_fps / fps)))
frames: list[tuple[float, bytes]] = []
frame_idx = 0
while cap.isOpened() and len(frames) < max_frames:
ret, frame = cap.read()
if not ret:
break
if frame_idx % step == 0:
timestamp_sec = frame_idx / video_fps
h, w = frame.shape[:2]
if max(h, w) > max_dimension:
scale = max_dimension / max(h, w)
frame = cv2.resize(
frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA
)
_, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
frames.append((timestamp_sec, buf.tobytes()))
frame_idx += 1
cap.release()
return frames