fix: preserve cross-device evidence and render recorded reports

This commit is contained in:
chenmingxuan committed 2026-10-17 16:05:58 +08:00
1 parent a66803651a
commit 1440a1f508
28 files changed
+759 -128

No files matched your search

+53 -12
View File
@@ -7,6 +7,7 @@ import logging
import os
from pathlib import Path
import shutil
import sys
import time
import uuid
@@ -100,10 +101,11 @@ def build_capture_command(
) -> list[str]:
area = capture_rectangle(source, config.computer_scope)
if config.computer_scope == "window":
# HWND capture excludes other windows and follows the selected 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 = ["-i", f"hwnd={source.target_id}"]
capture = []
else:
capture = [
"-offset_x",
@@ -121,19 +123,29 @@ def build_capture_command(
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",
"-f",
"gdigrab",
"-framerate",
str(config.fps),
"-draw_mouse",
"1",
*capture,
*input_args,
"-an",
"-vf",
f"scale={width}:{height}",
@@ -156,7 +168,7 @@ def build_capture_command(
command += ["-preset", "veryfast", "-tune", "zerolatency"]
if hevc:
command += ["-tag:v", "hvc1"]
return command + [
command += [
"-movflags",
"+frag_keyframe+empty_moov+default_base_moof",
"-progress",
@@ -165,6 +177,23 @@ def build_capture_command(
"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:
@@ -313,8 +342,14 @@ class PluginRecordings:
)
try:
self._job.assign(process.pid)
async with asyncio.timeout(8):
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:
@@ -324,13 +359,16 @@ class PluginRecordings:
segment = {
"file": output.relative_to(self.root).as_posix(),
"offset_ms": max(
0, round((spawned - self.ctx.data_engine.session_start_time) * 1000)
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(
@@ -400,6 +438,9 @@ class PluginRecordings:
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)
+127
View File
@@ -0,0 +1,127 @@
"""Isolated Windows Graphics Capture producer for the existing FFmpeg recorder.
Never falls back to capturing the desktop: window scope remains window scope.
The parent places this worker in its kill-on-close job before sending `start`;
the encoder then inherits the job. Only FFmpeg progress goes to stdout.
"""
import argparse
import ctypes
from ctypes import wintypes
import subprocess
import sys
import threading
import time
def has_picture(frame) -> bool:
"""Reject a blank initial capture, including black frames with only a cursor."""
import numpy as np
sample = frame[:: max(1, frame.shape[0] // 64), :: max(1, frame.shape[1] // 64), :3]
return float(np.mean(np.max(sample, axis=2) > 8)) > 0.001
def record_window(hwnd: int, width: int, height: int, fps: int, command: list[str]) -> None:
import cv2
from windows_capture import WindowsCapture
ctypes.windll.user32.IsWindow.argtypes = [wintypes.HWND]
ctypes.windll.user32.IsIconic.argtypes = [wintypes.HWND]
if not ctypes.windll.user32.IsWindow(hwnd) or ctypes.windll.user32.IsIconic(hwnd):
raise RuntimeError("目标窗口不存在或已最小化,未开始录制")
stop = threading.Event()
ready = threading.Event()
lock = threading.Lock()
latest = [None]
error = []
capture = WindowsCapture(
window_hwnd=hwnd,
cursor_capture=True,
draw_border=False,
minimum_update_interval=max(1, 1000 // fps),
)
@capture.event
def on_frame_arrived(frame, control):
try:
resized = cv2.resize(frame.frame_buffer, (width, height), interpolation=cv2.INTER_AREA)
if not ready.is_set() and not has_picture(resized):
return
with lock:
latest[0] = resized.tobytes()
ready.set()
except (ValueError, RuntimeError, cv2.error) as exc:
error.append(str(exc))
stop.set()
control.stop()
@capture.event
def on_closed():
error.append("目标窗口已关闭,录像已停止")
stop.set()
def commands():
for line in sys.stdin:
if line.strip() == "q":
break
stop.set()
threading.Thread(target=commands, daemon=True).start()
control = capture.start_free_threaded()
encoder = None
try:
deadline = time.monotonic() + 5
while not ready.wait(0.05):
if stop.is_set() or time.monotonic() >= deadline:
raise RuntimeError("未取得可辨识的窗口画面;不会将黑屏标记为录制正常")
encoder = subprocess.Popen(command, stdin=subprocess.PIPE, creationflags=0x08000000)
print(f"capture_started_at={time.time()}", flush=True)
next_frame = time.monotonic()
while not stop.is_set():
if control.is_finished():
raise RuntimeError("窗口采集线程已停止,保留已录制的片段")
if not ctypes.windll.user32.IsWindow(hwnd) or ctypes.windll.user32.IsIconic(hwnd):
raise RuntimeError("窗口已关闭或最小化,停止录制以避免黑屏或冻结画面")
with lock:
data = latest[0]
encoder.stdin.write(data)
next_frame += 1 / fps
# A slow encoder must not build an unbounded queue or a busy catch-up loop.
next_frame = max(next_frame, time.monotonic())
stop.wait(max(0, next_frame - time.monotonic()))
if error:
raise RuntimeError(error[-1])
finally:
try:
control.stop()
finally:
if encoder is not None:
try:
encoder.stdin.close()
encoder.wait(timeout=4)
except (OSError, subprocess.TimeoutExpired):
encoder.kill()
encoder.wait(timeout=2)
if encoder is not None and encoder.returncode:
raise RuntimeError(f"窗口编码进程退出:{encoder.returncode}")
def main() -> int:
parser = argparse.ArgumentParser()
for key in ("hwnd", "width", "height", "fps"):
parser.add_argument(f"--{key}", type=int, required=True)
parser.add_argument("command", nargs=argparse.REMAINDER)
args = parser.parse_args()
if sys.stdin.readline().strip() != "start":
return 1
try:
record_window(args.hwnd, args.width, args.height, args.fps, args.command[1:])
except (OSError, RuntimeError, ImportError, ValueError, subprocess.SubprocessError) as exc:
print(f"Windows Graphics Capture: {exc}", file=sys.stderr, flush=True)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())