516 lines
21 KiB
Python
516 lines
21 KiB
Python
"""Bounded Windows plugin recording, with one task-local manifest and no scheduler."""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
import os
|
||
from pathlib import Path
|
||
import shutil
|
||
import sys
|
||
import time
|
||
import uuid
|
||
|
||
import psutil
|
||
|
||
from artemis.config.agent import RecordingConfig
|
||
from artemis.plugins.models import EvidenceSource
|
||
from artemis.plugins.recording_process import CaptureProcessJob
|
||
from artemis.utils.video import get_ffmpeg_path, probe_video_segment
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def recover_interrupted_recordings(root: Path) -> bool:
|
||
"""Publish usable fragments after their owning worker died; never restart capture."""
|
||
manifest = root / "evidence" / "recordings" / "recordings.json"
|
||
if not manifest.is_file() or manifest.resolve() != manifest.absolute():
|
||
return False
|
||
try:
|
||
if manifest.stat().st_size > 4 * 1024 * 1024:
|
||
return False
|
||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||
if data.get("version") != 1 or not isinstance(data.get("sources"), list):
|
||
return False
|
||
changed = False
|
||
async with asyncio.timeout(30):
|
||
for track in data["sources"]:
|
||
if not isinstance(track, dict) or track.get("status") != "recording":
|
||
continue
|
||
try:
|
||
owner = psutil.Process(int(track["owner_pid"]))
|
||
if abs(owner.create_time() - float(track["owner_created_at"])) < 0.01:
|
||
continue
|
||
except psutil.AccessDenied:
|
||
continue # An inaccessible worker is not proof it has exited.
|
||
except (psutil.NoSuchProcess, KeyError, ValueError, TypeError):
|
||
pass
|
||
for segment in track.get("segments", []):
|
||
video = root / str(segment.get("file") or "")
|
||
if (
|
||
not video.is_file()
|
||
or video.resolve() != video.absolute()
|
||
or not video.resolve().is_relative_to(root.resolve())
|
||
):
|
||
continue
|
||
try:
|
||
metadata = await asyncio.wait_for(probe_video_segment(video), 10)
|
||
duration = max(0.0, float(metadata.get("duration") or 0))
|
||
segment.update(
|
||
duration=duration,
|
||
duration_ms=round(duration * 1000),
|
||
width=int(metadata.get("width") or 0),
|
||
height=int(metadata.get("height") or 0),
|
||
bytes=video.stat().st_size,
|
||
)
|
||
except (OSError, ValueError, RuntimeError, TimeoutError):
|
||
segment.update(duration=0, duration_ms=0)
|
||
track.update(
|
||
status="failed",
|
||
message="录制工作进程已退出;已保留可读取的片段,末尾可能缺失。",
|
||
)
|
||
changed = True
|
||
if changed:
|
||
temporary = manifest.with_suffix(".recovery.tmp")
|
||
temporary.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||
temporary.replace(manifest)
|
||
return changed
|
||
except (OSError, ValueError, TypeError, KeyError, TimeoutError):
|
||
logger.warning("无法恢复电脑录像索引,原始片段已保留")
|
||
return False
|
||
|
||
|
||
def recording_config(ctx) -> RecordingConfig:
|
||
value = getattr(getattr(ctx, "agent_config", None), "recording", None)
|
||
return value if isinstance(value, RecordingConfig) else RecordingConfig()
|
||
|
||
|
||
def capture_rectangle(source: EvidenceSource, scope: str) -> dict[str, int]:
|
||
value = source.window if scope == "window" else source.screen
|
||
if not value or set(value) != {"left", "top", "width", "height"}:
|
||
raise ValueError("插件未提供完整的物理屏幕/窗口范围")
|
||
if not 1 <= value["width"] <= 32768 or not 1 <= value["height"] <= 32768:
|
||
raise ValueError("插件返回了无效的录制范围")
|
||
if any(abs(value[key]) > 65536 for key in ("left", "top")):
|
||
raise ValueError("插件返回了无效的屏幕原点")
|
||
return value
|
||
|
||
|
||
def build_capture_command(
|
||
ffmpeg: str, source: EvidenceSource, config: RecordingConfig, output: Path, encoder: str
|
||
) -> list[str]:
|
||
area = capture_rectangle(source, config.computer_scope)
|
||
if config.computer_scope == "window":
|
||
# GDI/BitBlt can return black for GPU-rendered Chromium windows.
|
||
# Frames come from an isolated Windows Graphics Capture worker instead.
|
||
if not source.target_id.isdecimal():
|
||
raise ValueError("窗口录制需要真实 HWND")
|
||
capture = []
|
||
else:
|
||
capture = [
|
||
"-offset_x",
|
||
str(area["left"]),
|
||
"-offset_y",
|
||
str(area["top"]),
|
||
"-video_size",
|
||
f"{area['width']}x{area['height']}",
|
||
"-i",
|
||
"desktop",
|
||
]
|
||
hevc = encoder.startswith("hevc")
|
||
bitrate = config.h265_kbps if hevc else config.h264_kbps
|
||
# Never upscale. Even dimensions are required by yuv420p encoders.
|
||
factor = min(1.0, config.max_long_edge / max(area["width"], area["height"]))
|
||
width = max(2, int(area["width"] * factor) // 2 * 2)
|
||
height = max(2, int(area["height"] * factor) // 2 * 2)
|
||
input_args = ["-f", "gdigrab", "-framerate", str(config.fps), "-draw_mouse", "1", *capture]
|
||
if config.computer_scope == "window":
|
||
input_args = [
|
||
"-f",
|
||
"rawvideo",
|
||
"-pixel_format",
|
||
"bgra",
|
||
"-video_size",
|
||
f"{width}x{height}",
|
||
"-framerate",
|
||
str(config.fps),
|
||
"-use_wallclock_as_timestamps",
|
||
"1",
|
||
"-i",
|
||
"pipe:0",
|
||
]
|
||
command = [
|
||
ffmpeg,
|
||
"-hide_banner",
|
||
"-loglevel",
|
||
"error",
|
||
"-y",
|
||
*input_args,
|
||
"-an",
|
||
"-vf",
|
||
f"scale={width}:{height}",
|
||
"-c:v",
|
||
encoder,
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-b:v",
|
||
f"{bitrate}k",
|
||
"-maxrate",
|
||
f"{bitrate * 2}k",
|
||
"-bufsize",
|
||
f"{bitrate * 2}k",
|
||
"-g",
|
||
str(config.fps * 2),
|
||
]
|
||
if encoder == "libx264":
|
||
# Live capture must produce a first encoded frame before readiness.
|
||
# Lookahead/thread buffering can exceed that deadline on slow desktops.
|
||
command += ["-preset", "veryfast", "-tune", "zerolatency"]
|
||
if hevc:
|
||
command += ["-tag:v", "hvc1"]
|
||
command += [
|
||
"-movflags",
|
||
"+frag_keyframe+empty_moov+default_base_moof",
|
||
"-progress",
|
||
"pipe:1",
|
||
"-stats_period",
|
||
"0.5",
|
||
str(output),
|
||
]
|
||
if config.computer_scope == "window":
|
||
return [
|
||
sys.executable,
|
||
"-m",
|
||
"artemis.plugins.window_capture",
|
||
"--hwnd",
|
||
source.target_id,
|
||
"--width",
|
||
str(width),
|
||
"--height",
|
||
str(height),
|
||
"--fps",
|
||
str(config.fps),
|
||
"--",
|
||
*command,
|
||
]
|
||
return command
|
||
|
||
|
||
class PluginRecordings:
|
||
def __init__(self, ctx, *, folder: Path | None = None, force: bool = False):
|
||
self.ctx = ctx
|
||
self.config = recording_config(ctx)
|
||
self.root = Path(ctx.data_engine.base_dir)
|
||
self.folder = folder or self.root / "evidence" / "recordings"
|
||
if not self.folder.resolve().is_relative_to(self.root.resolve()):
|
||
raise ValueError("录像目录必须属于当前任务")
|
||
self.force = force
|
||
self.sources: dict[str, dict] = {}
|
||
self.active: dict[str, tuple] = {}
|
||
self.failed: set[str] = set()
|
||
self._lock = asyncio.Lock()
|
||
self._watchdog = None
|
||
self._job = None
|
||
manifest = self.folder / "recordings.json"
|
||
if manifest.is_file():
|
||
# Resumed workers append; prior finalized segments are immutable.
|
||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||
if data.get("version") != 1 or not isinstance(data.get("sources"), list):
|
||
raise ValueError("已有电脑录像索引无效,已保留原文件")
|
||
if any(
|
||
not isinstance(item, dict)
|
||
or not isinstance(item.get("id"), str)
|
||
or not isinstance(item.get("segments"), list)
|
||
for item in data["sources"]
|
||
):
|
||
raise ValueError("已有电脑录像来源无效,已保留原文件")
|
||
self.sources = {item["id"]: item for item in data["sources"]}
|
||
|
||
@property
|
||
def enabled(self):
|
||
setup = getattr(self.ctx, "execution_setup", None)
|
||
return self.force or bool(setup and setup.record_video_enabled)
|
||
|
||
def _save(self):
|
||
self.folder.mkdir(parents=True, exist_ok=True)
|
||
target = self.folder / "recordings.json"
|
||
temporary = target.with_suffix(".tmp")
|
||
temporary.write_text(
|
||
json.dumps({"version": 1, "sources": list(self.sources.values())}, ensure_ascii=False),
|
||
encoding="utf-8",
|
||
)
|
||
temporary.replace(target)
|
||
|
||
async def observe(
|
||
self, source_id: str, plugin_id: str, source: EvidenceSource, *, exclusive: bool = False
|
||
):
|
||
if not self.enabled:
|
||
return
|
||
async with self._lock:
|
||
try:
|
||
area = capture_rectangle(source, self.config.computer_scope)
|
||
key = (
|
||
source.target_id + json.dumps(area, sort_keys=True)
|
||
if self.config.computer_scope == "window"
|
||
else json.dumps(area, sort_keys=True)
|
||
)
|
||
track_id = (
|
||
"computer-" + hashlib.sha256(f"{plugin_id}:{key}".encode()).hexdigest()[:16]
|
||
)
|
||
track = self.sources.setdefault(
|
||
track_id,
|
||
{
|
||
"id": track_id,
|
||
"kind": "computer",
|
||
"plugin_id": plugin_id,
|
||
"label": f"电脑 · {source.label or source.target_id}"
|
||
if self.config.computer_scope == "window"
|
||
else f"电脑屏幕 · {area['left']},{area['top']}",
|
||
"scope": self.config.computer_scope,
|
||
"area": area,
|
||
"target_ids": [],
|
||
"status": "unavailable",
|
||
"message": "",
|
||
"segments": [],
|
||
},
|
||
)
|
||
if source_id not in track["target_ids"]:
|
||
track["target_ids"].append(source_id)
|
||
if self.config.computer_scope == "window":
|
||
for other in list(self.active):
|
||
previous = self.sources[other]
|
||
if other != track_id and (
|
||
exclusive or previous.get("target_id") == source.target_id
|
||
):
|
||
await self._finish(other)
|
||
track["target_id"] = source.target_id
|
||
if track_id in self.active or track_id in self.failed:
|
||
self._save()
|
||
return
|
||
if os.name != "nt":
|
||
raise OSError("当前电脑录制仅支持 Windows")
|
||
self.folder.mkdir(parents=True, exist_ok=True)
|
||
if shutil.disk_usage(self.folder).free < self.config.minimum_disk_mb * 1024 * 1024:
|
||
raise OSError("磁盘可用空间不足,已停止录像")
|
||
await self._start(track_id, source)
|
||
if self._watchdog is None or self._watchdog.done():
|
||
self._watchdog = asyncio.create_task(self._watch())
|
||
except (OSError, ValueError, RuntimeError, TimeoutError) as exc:
|
||
# A recording failure never changes the result of a completed tool action.
|
||
failed_id = locals().get("track_id", source_id)
|
||
if failed_id not in self.failed:
|
||
logger.warning("电脑录像不可用:%s", exc)
|
||
self.failed.add(failed_id)
|
||
track = self.sources.setdefault(
|
||
failed_id,
|
||
{
|
||
"id": failed_id,
|
||
"kind": "computer",
|
||
"plugin_id": plugin_id,
|
||
"label": "电脑录像",
|
||
"target_ids": [source_id],
|
||
"segments": [],
|
||
},
|
||
)
|
||
track.update(status="failed", message=str(exc))
|
||
try:
|
||
self._save()
|
||
except OSError:
|
||
logger.warning("电脑录像状态无法写入任务目录")
|
||
|
||
async def _start(self, key, source):
|
||
ffmpeg = get_ffmpeg_path()
|
||
if not ffmpeg:
|
||
raise OSError("未找到 FFmpeg,无法录制电脑画面")
|
||
encoders = []
|
||
if self.config.codec == "h265" or (
|
||
self.config.codec == "auto" and self.config.hevc_playback
|
||
):
|
||
encoders += ["hevc_nvenc", "hevc_qsv", "hevc_amf"]
|
||
encoders += ["libx264"]
|
||
track = self.sources[key]
|
||
errors = []
|
||
if self._job is None:
|
||
self._job = CaptureProcessJob()
|
||
for encoder in encoders:
|
||
output = self.folder / f"{key}-{uuid.uuid4().hex[:12]}.mp4"
|
||
log_path = output.with_suffix(".log")
|
||
spawned = time.time()
|
||
with log_path.open("wb") as error_log:
|
||
process = await asyncio.create_subprocess_exec(
|
||
*build_capture_command(ffmpeg, source, self.config, output, encoder),
|
||
stdin=asyncio.subprocess.PIPE,
|
||
stdout=asyncio.subprocess.PIPE,
|
||
stderr=error_log,
|
||
creationflags=0x08000000,
|
||
)
|
||
try:
|
||
self._job.assign(process.pid)
|
||
if self.config.computer_scope == "window":
|
||
process.stdin.write(b"start\n")
|
||
await process.stdin.drain()
|
||
first_capture = spawned
|
||
async with asyncio.timeout(12):
|
||
while line := await process.stdout.readline():
|
||
if line.startswith(b"capture_started_at="):
|
||
first_capture = float(line.partition(b"=")[2])
|
||
if line.startswith(b"frame=") and int(line.partition(b"=")[2]) > 0:
|
||
break
|
||
else:
|
||
raise OSError(
|
||
log_path.read_text(errors="replace")[-1000:] or "录制进程未产生画面"
|
||
)
|
||
segment = {
|
||
"file": output.relative_to(self.root).as_posix(),
|
||
"offset_ms": max(
|
||
0, round((first_capture - self.ctx.data_engine.session_start_time) * 1000)
|
||
),
|
||
"duration": 0,
|
||
"duration_ms": 0,
|
||
"start": 0,
|
||
"codec": "h265" if encoder.startswith("hevc") else "h264",
|
||
"encoder": encoder,
|
||
"capture_backend": "windows_graphics_capture"
|
||
if self.config.computer_scope == "window"
|
||
else "gdigrab",
|
||
}
|
||
track["segments"].append(segment)
|
||
track.update(
|
||
status="recording",
|
||
codec=segment["codec"],
|
||
message=";".join(errors),
|
||
owner_pid=os.getpid(),
|
||
owner_created_at=psutil.Process().create_time(),
|
||
)
|
||
self.active[key] = (
|
||
process,
|
||
output,
|
||
segment,
|
||
asyncio.create_task(self._drain(process.stdout, segment)),
|
||
)
|
||
try:
|
||
self._save()
|
||
except OSError:
|
||
await self._finish(key, "录像索引写入失败")
|
||
# Retain the completed partial clip rather than deleting evidence.
|
||
raise
|
||
return
|
||
except (OSError, ValueError, TimeoutError) as exc:
|
||
await self._terminate(process)
|
||
if not any(
|
||
item["file"] == output.relative_to(self.root).as_posix()
|
||
for item in track["segments"]
|
||
):
|
||
output.unlink(missing_ok=True)
|
||
else:
|
||
raise
|
||
detail = "等待首帧超时" if isinstance(exc, TimeoutError) else str(exc)[:160]
|
||
errors.append(f"{encoder} 不可用,已尝试兼容编码:{detail}")
|
||
except asyncio.CancelledError:
|
||
await self._terminate(process)
|
||
raise
|
||
raise OSError(";".join(errors))
|
||
|
||
@staticmethod
|
||
async def _drain(stream, segment):
|
||
while line := await stream.readline():
|
||
if line.startswith(b"out_time_us="):
|
||
try:
|
||
duration = max(0, int(line.partition(b"=")[2])) / 1_000_000
|
||
except ValueError:
|
||
continue
|
||
segment.update(duration=duration, duration_ms=round(duration * 1000))
|
||
|
||
@staticmethod
|
||
async def _terminate(process):
|
||
if process.returncode is None:
|
||
try:
|
||
process.stdin.write(b"q\n")
|
||
await process.stdin.drain()
|
||
await asyncio.wait_for(process.wait(), 5)
|
||
except (OSError, ConnectionError, TimeoutError):
|
||
if process.returncode is None:
|
||
try:
|
||
process.kill()
|
||
except ProcessLookupError:
|
||
pass
|
||
await asyncio.wait_for(process.wait(), 5)
|
||
|
||
async def _finish(self, key, error=""):
|
||
process, output, segment, drain = self.active[key]
|
||
track = self.sources[key]
|
||
interrupted = False
|
||
try:
|
||
await self._terminate(process)
|
||
if process.returncode:
|
||
detail = output.with_suffix(".log").read_text(errors="replace")[-1000:]
|
||
error = error or f"录像进程异常退出 ({process.returncode}):{detail}"
|
||
await asyncio.wait_for(drain, 2)
|
||
metadata = await asyncio.wait_for(probe_video_segment(output), 10)
|
||
duration = float(metadata.get("duration") or 0)
|
||
segment.update(
|
||
duration=duration,
|
||
duration_ms=round(duration * 1000),
|
||
width=int(metadata.get("width") or 0),
|
||
height=int(metadata.get("height") or 0),
|
||
)
|
||
if duration > 0:
|
||
segment["bytes"] = output.stat().st_size
|
||
else:
|
||
error = error or "未取得有效的录像片段"
|
||
except asyncio.CancelledError:
|
||
interrupted = True
|
||
raise
|
||
except (OSError, ValueError, RuntimeError, TimeoutError) as exc:
|
||
error = error or f"录像收尾失败:{exc}"
|
||
finally:
|
||
# Cancellation while finalizing keeps ownership for stop() to retry.
|
||
if process.returncode is not None and not interrupted:
|
||
self.active.pop(key, None)
|
||
drain.cancel()
|
||
await asyncio.gather(drain, return_exceptions=True)
|
||
track.update(status="failed" if error else "ready", message=error or track["message"])
|
||
if error:
|
||
self.failed.add(key)
|
||
logger.warning("电脑录像中断:%s", error)
|
||
|
||
async def _watch(self):
|
||
while self.active:
|
||
await asyncio.sleep(5)
|
||
async with self._lock:
|
||
for key, (process, _, _, _) in list(self.active.items()):
|
||
reason = "录制进程意外退出" if process.returncode is not None else ""
|
||
try:
|
||
if (
|
||
shutil.disk_usage(self.folder).free
|
||
< self.config.minimum_disk_mb * 1024 * 1024
|
||
):
|
||
reason = "磁盘可用空间不足"
|
||
except OSError:
|
||
reason = "录像目录不可用"
|
||
if reason:
|
||
await self._finish(key, reason)
|
||
self._save_safely()
|
||
|
||
def _save_safely(self):
|
||
try:
|
||
self._save()
|
||
except OSError:
|
||
logger.warning("电脑录像状态无法写入任务目录")
|
||
|
||
async def stop(self):
|
||
if self._watchdog is not None:
|
||
self._watchdog.cancel()
|
||
await asyncio.gather(self._watchdog, return_exceptions=True)
|
||
self._watchdog = None
|
||
async with self._lock:
|
||
try:
|
||
for key in list(self.active):
|
||
await self._finish(key)
|
||
if self.sources:
|
||
self._save_safely()
|
||
finally:
|
||
if self._job is not None:
|
||
self._job.close()
|
||
self._job = None
|