fix: preserve cross-device evidence and render recorded reports
This commit is contained in:
1 parent
a66803651a
commit
1440a1f508
28 files changed
+759
-128
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user