mirror of
https://github.com/hanzoai/video.git
synced 2026-10-30 18:18:27 +00:00
New capture layer for the screen-demo pipeline: FFmpeg for quick CLI-driven recording, Cap integration for polished recordings with webcam overlay and cursor effects. Selector presents both options and routes based on availability.
395 lines
13 KiB
Python
395 lines
13 KiB
Python
"""FFmpeg-based screen recorder.
|
|
|
|
Cross-platform screen capture using FFmpeg's native capture devices.
|
|
Records screen + optional audio to MP4. Designed as the "quick start"
|
|
option — no install beyond FFmpeg, works everywhere, CLI-driven.
|
|
|
|
Platform capture devices:
|
|
Windows: gdigrab (screen) + dshow (audio)
|
|
macOS: avfoundation (screen + audio)
|
|
Linux: x11grab (screen) + pulse (audio)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from tools.base_tool import (
|
|
BaseTool,
|
|
Determinism,
|
|
ExecutionMode,
|
|
ResourceProfile,
|
|
ToolResult,
|
|
ToolRuntime,
|
|
ToolStability,
|
|
ToolTier,
|
|
)
|
|
|
|
|
|
def _detect_audio_device_windows() -> str | None:
|
|
"""Find a working audio input device on Windows via dshow."""
|
|
try:
|
|
result = subprocess.run(
|
|
["ffmpeg", "-list_devices", "true", "-f", "dshow", "-i", "dummy"],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
# dshow lists devices in stderr
|
|
output = result.stderr
|
|
lines = output.splitlines()
|
|
for i, line in enumerate(lines):
|
|
if "audio" in line.lower() and "DirectShow audio" in line:
|
|
# Next line(s) contain actual device names
|
|
for j in range(i + 1, min(i + 10, len(lines))):
|
|
if '"' in lines[j] and "Alternative name" not in lines[j]:
|
|
name = lines[j].split('"')[1]
|
|
return name
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _detect_audio_device_mac() -> str | None:
|
|
"""Find the default audio input index on macOS via avfoundation."""
|
|
try:
|
|
result = subprocess.run(
|
|
["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
output = result.stderr
|
|
in_audio = False
|
|
for line in output.splitlines():
|
|
if "AVFoundation audio devices" in line:
|
|
in_audio = True
|
|
continue
|
|
if in_audio and "[" in line and "]" in line:
|
|
# Return first audio device index
|
|
idx = line.split("[")[1].split("]")[0].strip()
|
|
if idx.isdigit():
|
|
return idx
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
class ScreenRecorder(BaseTool):
|
|
name = "screen_recorder"
|
|
version = "0.1.0"
|
|
tier = ToolTier.SOURCE
|
|
capability = "screen_capture"
|
|
provider = "ffmpeg"
|
|
stability = ToolStability.BETA
|
|
execution_mode = ExecutionMode.SYNC
|
|
determinism = Determinism.DETERMINISTIC
|
|
runtime = ToolRuntime.LOCAL
|
|
|
|
dependencies = ["binary:ffmpeg"]
|
|
install_instructions = (
|
|
"Install ffmpeg:\n"
|
|
" Windows: winget install ffmpeg\n"
|
|
" macOS: brew install ffmpeg\n"
|
|
" Linux: sudo apt install ffmpeg"
|
|
)
|
|
|
|
capabilities = [
|
|
"record_screen",
|
|
"record_screen_with_audio",
|
|
"record_region",
|
|
]
|
|
|
|
best_for = [
|
|
"Quick screen recording without additional software",
|
|
"Automated screen capture for demo pipelines",
|
|
"Recording specific screen regions for tutorials",
|
|
]
|
|
|
|
not_good_for = [
|
|
"Webcam overlay (PiP) — use Cap for that",
|
|
"Cursor highlight effects — use Cap for that",
|
|
"Interactive recording with pause/resume UI",
|
|
]
|
|
|
|
input_schema = {
|
|
"type": "object",
|
|
"required": ["output_path"],
|
|
"properties": {
|
|
"output_path": {
|
|
"type": "string",
|
|
"description": "Path for the output MP4 file",
|
|
},
|
|
"duration_seconds": {
|
|
"type": "integer",
|
|
"default": 60,
|
|
"description": "Recording duration in seconds (default: 60, max: 600)",
|
|
},
|
|
"fps": {
|
|
"type": "integer",
|
|
"default": 30,
|
|
"description": "Frames per second (15, 24, 30, or 60)",
|
|
},
|
|
"capture_audio": {
|
|
"type": "boolean",
|
|
"default": True,
|
|
"description": "Whether to capture system/microphone audio",
|
|
},
|
|
"region": {
|
|
"type": "object",
|
|
"description": "Optional screen region to capture (full screen if omitted)",
|
|
"properties": {
|
|
"x": {"type": "integer", "description": "Left offset in pixels"},
|
|
"y": {"type": "integer", "description": "Top offset in pixels"},
|
|
"width": {"type": "integer", "description": "Width in pixels"},
|
|
"height": {"type": "integer", "description": "Height in pixels"},
|
|
},
|
|
},
|
|
"screen_index": {
|
|
"type": "integer",
|
|
"default": 0,
|
|
"description": "Monitor index for multi-monitor setups (0 = primary)",
|
|
},
|
|
},
|
|
}
|
|
|
|
output_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"output_path": {"type": "string"},
|
|
"duration_seconds": {"type": "number"},
|
|
"resolution": {"type": "string"},
|
|
"has_audio": {"type": "boolean"},
|
|
"file_size_mb": {"type": "number"},
|
|
},
|
|
}
|
|
|
|
resource_profile = ResourceProfile(
|
|
cpu_cores=2, ram_mb=512, vram_mb=0, disk_mb=500, network_required=False,
|
|
)
|
|
|
|
side_effects = ["creates_file"]
|
|
fallback_tools = ["cap_recorder"]
|
|
|
|
def execute(self, inputs: dict[str, Any]) -> ToolResult:
|
|
output_path = Path(inputs["output_path"])
|
|
duration = min(inputs.get("duration_seconds", 60), 600)
|
|
fps = inputs.get("fps", 30)
|
|
capture_audio = inputs.get("capture_audio", True)
|
|
region = inputs.get("region")
|
|
screen_index = inputs.get("screen_index", 0)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
sys_platform = platform.system()
|
|
cmd = self._build_command(
|
|
sys_platform, str(output_path), duration, fps,
|
|
capture_audio, region, screen_index,
|
|
)
|
|
|
|
if cmd is None:
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Screen recording not supported on {sys_platform}. "
|
|
f"Supported: Windows, macOS, Linux.",
|
|
)
|
|
|
|
start_time = time.time()
|
|
try:
|
|
proc = subprocess.run(
|
|
cmd, capture_output=True, text=True,
|
|
timeout=duration + 30, # grace period
|
|
)
|
|
elapsed = time.time() - start_time
|
|
|
|
if not output_path.exists():
|
|
return ToolResult(
|
|
success=False,
|
|
error=f"Recording failed — no output file. FFmpeg stderr: {proc.stderr[-500:]}",
|
|
)
|
|
|
|
file_size_mb = output_path.stat().st_size / (1024 * 1024)
|
|
|
|
# Probe the output to get actual resolution
|
|
resolution = self._probe_resolution(str(output_path))
|
|
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"output_path": str(output_path),
|
|
"duration_seconds": round(elapsed, 1),
|
|
"resolution": resolution,
|
|
"has_audio": capture_audio,
|
|
"file_size_mb": round(file_size_mb, 1),
|
|
"platform": sys_platform,
|
|
"capture_method": "ffmpeg",
|
|
},
|
|
artifacts=[str(output_path)],
|
|
duration_seconds=elapsed,
|
|
)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
# Recording completed by timeout — this is expected behavior
|
|
if output_path.exists():
|
|
file_size_mb = output_path.stat().st_size / (1024 * 1024)
|
|
return ToolResult(
|
|
success=True,
|
|
data={
|
|
"output_path": str(output_path),
|
|
"duration_seconds": duration,
|
|
"has_audio": capture_audio,
|
|
"file_size_mb": round(file_size_mb, 1),
|
|
"platform": sys_platform,
|
|
"capture_method": "ffmpeg",
|
|
},
|
|
artifacts=[str(output_path)],
|
|
duration_seconds=duration,
|
|
)
|
|
return ToolResult(success=False, error="Recording timed out with no output")
|
|
|
|
except Exception as exc:
|
|
return ToolResult(success=False, error=str(exc))
|
|
|
|
def _build_command(
|
|
self,
|
|
sys_platform: str,
|
|
output_path: str,
|
|
duration: int,
|
|
fps: int,
|
|
capture_audio: bool,
|
|
region: dict | None,
|
|
screen_index: int,
|
|
) -> list[str] | None:
|
|
"""Build platform-specific FFmpeg capture command."""
|
|
|
|
if sys_platform == "Windows":
|
|
return self._build_windows_cmd(
|
|
output_path, duration, fps, capture_audio, region,
|
|
)
|
|
elif sys_platform == "Darwin":
|
|
return self._build_mac_cmd(
|
|
output_path, duration, fps, capture_audio, region, screen_index,
|
|
)
|
|
elif sys_platform == "Linux":
|
|
return self._build_linux_cmd(
|
|
output_path, duration, fps, capture_audio, region,
|
|
)
|
|
return None
|
|
|
|
def _build_windows_cmd(
|
|
self, output_path: str, duration: int, fps: int,
|
|
capture_audio: bool, region: dict | None,
|
|
) -> list[str]:
|
|
cmd = ["ffmpeg", "-y"]
|
|
|
|
# Video input: gdigrab
|
|
cmd += ["-f", "gdigrab"]
|
|
cmd += ["-framerate", str(fps)]
|
|
cmd += ["-t", str(duration)]
|
|
|
|
if region:
|
|
cmd += ["-offset_x", str(region.get("x", 0))]
|
|
cmd += ["-offset_y", str(region.get("y", 0))]
|
|
cmd += ["-video_size", f"{region['width']}x{region['height']}"]
|
|
|
|
cmd += ["-i", "desktop"]
|
|
|
|
# Audio input: dshow
|
|
if capture_audio:
|
|
audio_device = _detect_audio_device_windows()
|
|
if audio_device:
|
|
cmd += ["-f", "dshow", "-i", f"audio={audio_device}"]
|
|
|
|
# Output encoding
|
|
cmd += ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
|
|
if capture_audio:
|
|
cmd += ["-c:a", "aac", "-b:a", "128k"]
|
|
cmd += ["-pix_fmt", "yuv420p"]
|
|
cmd += [output_path]
|
|
|
|
return cmd
|
|
|
|
def _build_mac_cmd(
|
|
self, output_path: str, duration: int, fps: int,
|
|
capture_audio: bool, region: dict | None, screen_index: int,
|
|
) -> list[str]:
|
|
cmd = ["ffmpeg", "-y"]
|
|
|
|
# avfoundation: "screen_index:audio_index" or "screen_index:none"
|
|
audio_idx = "none"
|
|
if capture_audio:
|
|
detected = _detect_audio_device_mac()
|
|
if detected:
|
|
audio_idx = detected
|
|
|
|
cmd += ["-f", "avfoundation"]
|
|
cmd += ["-framerate", str(fps)]
|
|
cmd += ["-t", str(duration)]
|
|
|
|
if region:
|
|
# avfoundation doesn't support region directly — we crop in post
|
|
cmd += ["-i", f"{screen_index}:{audio_idx}"]
|
|
cmd += ["-vf", f"crop={region['width']}:{region['height']}:{region.get('x', 0)}:{region.get('y', 0)}"]
|
|
else:
|
|
cmd += ["-i", f"{screen_index}:{audio_idx}"]
|
|
|
|
cmd += ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
|
|
if capture_audio and audio_idx != "none":
|
|
cmd += ["-c:a", "aac", "-b:a", "128k"]
|
|
cmd += ["-pix_fmt", "yuv420p"]
|
|
cmd += [output_path]
|
|
|
|
return cmd
|
|
|
|
def _build_linux_cmd(
|
|
self, output_path: str, duration: int, fps: int,
|
|
capture_audio: bool, region: dict | None,
|
|
) -> list[str]:
|
|
cmd = ["ffmpeg", "-y"]
|
|
|
|
# x11grab
|
|
display = os.environ.get("DISPLAY", ":0.0")
|
|
cmd += ["-f", "x11grab"]
|
|
cmd += ["-framerate", str(fps)]
|
|
cmd += ["-t", str(duration)]
|
|
|
|
if region:
|
|
cmd += ["-video_size", f"{region['width']}x{region['height']}"]
|
|
cmd += ["-i", f"{display}+{region.get('x', 0)},{region.get('y', 0)}"]
|
|
else:
|
|
# Full screen — need to detect resolution
|
|
cmd += ["-i", display]
|
|
|
|
if capture_audio:
|
|
cmd += ["-f", "pulse", "-i", "default"]
|
|
|
|
cmd += ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "23"]
|
|
if capture_audio:
|
|
cmd += ["-c:a", "aac", "-b:a", "128k"]
|
|
cmd += ["-pix_fmt", "yuv420p"]
|
|
cmd += [output_path]
|
|
|
|
return cmd
|
|
|
|
def _probe_resolution(self, path: str) -> str:
|
|
"""Get video resolution via ffprobe."""
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"ffprobe", "-v", "error",
|
|
"-select_streams", "v:0",
|
|
"-show_entries", "stream=width,height",
|
|
"-of", "csv=p=0",
|
|
path,
|
|
],
|
|
capture_output=True, text=True, timeout=10,
|
|
)
|
|
parts = result.stdout.strip().split(",")
|
|
if len(parts) == 2:
|
|
return f"{parts[0]}x{parts[1]}"
|
|
except Exception:
|
|
pass
|
|
return "unknown"
|