feat(plugins): add source-aware evidence and recording timelines

This commit is contained in:
chenmingxuan committed 2026-10-16 10:20:11 +08:00
1 parent 72ff342605
commit ef7af9988e
61 files changed
+3847 -108

No files matched your search

+101 -20
View File
@@ -23,6 +23,7 @@ Provides shared types and utilities for video recording across platforms.
import asyncio
import importlib.util
import json
import math
from pathlib import Path
import platform
import re
@@ -64,6 +65,10 @@ def build_scrcpy_record_command(
output_path: Path,
video_bit_rate: str = "2M",
lock_capture_orientation: bool = True,
*,
max_long_edge: int | None = None,
fps: int | None = None,
codec: str | None = None,
) -> list[str]:
"""Build the shared scrcpy command used by all recording paths.
@@ -86,6 +91,13 @@ def build_scrcpy_record_command(
]
if lock_capture_orientation:
command.append("--capture-orientation=@")
if max_long_edge:
command += ["--max-size", str(max_long_edge)]
if fps:
command += ["--max-fps", str(fps)]
if codec:
command += ["--video-codec", codec]
command.append("--no-audio")
return command
@@ -94,6 +106,7 @@ async def await_scrcpy_first_frame(
spawned_at: float,
*,
timeout: float = SCRCPY_STARTUP_TIMEOUT_SECONDS,
require_marker: bool = False,
) -> float:
"""Estimate when the first frame of a scrcpy recording reached the host."""
fallback = spawned_at + SCRCPY_STARTUP_FALLBACK_SECONDS
@@ -106,6 +119,8 @@ async def await_scrcpy_first_frame(
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
if require_marker:
raise TimeoutError("scrcpy 未在启动时限内确认开始录制")
logger.warning(
f"scrcpy did not report '{SCRCPY_RECORDING_STARTED_MARKER}' within "
f"{timeout:.1f}s; assuming the first frame at spawn + "
@@ -121,6 +136,8 @@ async def await_scrcpy_first_frame(
await asyncio.wait_for(process.wait(), timeout=1.0)
except (AttributeError, ProcessLookupError, TimeoutError):
pass
if require_marker:
raise OSError("scrcpy 输出已关闭,但未确认开始录制")
return fallback
if SCRCPY_RECORDING_STARTED_MARKER in line.decode(errors="replace"):
return max(spawned_at, time.time())
@@ -152,6 +169,7 @@ class RecordingSession(BaseModel):
video_id: UUID
device_id: str
start_time: float
recording_codec: str = "h264"
process: Any = None
data_engine_start_time: float | None = None
local_video_path: Path | None = None
@@ -310,7 +328,7 @@ async def normalize_recording_to_mp4(
async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
"""Atomically publish a fixed-dimension recording segment as MP4."""
"""Publish only a playable segment; preserve the source on failed finalization."""
if not source_path.exists() or source_path.stat().st_size == 0:
return False
temporary_path = output_path.with_name(f"{output_path.stem}.part{output_path.suffix}")
@@ -336,13 +354,13 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_stdout, stderr = await process.communicate()
_stdout, stderr = await _communicate_video_process(process, 30)
valid = (
process.returncode == 0
and temporary_path.exists()
and temporary_path.stat().st_size > 0
)
if valid:
if valid and await probe_video_segment(temporary_path):
temporary_path.replace(output_path)
return True
@@ -350,7 +368,9 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
f"Direct remux failed for {source_path} (code {process.returncode}), "
"attempting transcode fallback with ffmpeg..."
)
# Fallback to ultrafast re-encoding in case MKV container was truncated or has timestamp irregularities
# Re-encode the actual decoded frames with their original presentation
# intervals. Never manufacture a duration from wall time, duplicate frames,
# or use a constant-frame-rate filter to cover a recording gap.
fallback_proc = await asyncio.create_subprocess_exec(
get_ffmpeg_path(),
"-y",
@@ -358,31 +378,47 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
"+genpts+discardcorrupt",
"-i",
str(source_path),
"-map",
"0:v:0",
"-an",
"-vf",
"setpts=PTS-STARTPTS",
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-bf",
"0",
"-fps_mode",
"passthrough",
"-enc_time_base",
"1:1000000",
"-movflags",
"+faststart",
str(temporary_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_fout, _ferr = await fallback_proc.communicate()
_fout, _ferr = await _communicate_video_process(fallback_proc, 120)
if (
fallback_proc.returncode == 0
and temporary_path.exists()
and temporary_path.stat().st_size > 0
and await probe_video_segment(temporary_path)
):
temporary_path.replace(output_path)
return True
logger.error(
f"Failed to remux recording segment (code {process.returncode}): "
f"{stderr.decode(errors='replace')[-2000:]}"
f"Recording conversion produced no valid timed video; source preserved: {source_path} "
f"(remux={process.returncode}, fallback={fallback_proc.returncode}). "
f"{(_ferr or stderr).decode(errors='replace')[-2000:]}"
)
except asyncio.CancelledError:
temporary_path.unlink(missing_ok=True)
raise
except Exception as e:
logger.error(f"Failed to remux recording segment: {e}")
if temporary_path.exists():
@@ -390,40 +426,85 @@ async def remux_recording_to_mp4(source_path: Path, output_path: Path) -> bool:
return False
async def _communicate_video_process(process, timeout: float):
"""Bound media inspection/conversion and reclaim its child on cancellation."""
try:
return await asyncio.wait_for(process.communicate(), timeout)
except (asyncio.CancelledError, TimeoutError):
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
await asyncio.wait_for(process.wait(), 5)
raise
async def probe_video_segment(video_path: Path) -> dict[str, float | int]:
"""Read duration and coded dimensions for a finalized segment."""
"""Validate video timing before admitting a segment into the task timeline."""
process = await asyncio.create_subprocess_exec(
get_ffprobe_path(),
"-v",
"error",
"-select_streams",
"v:0",
"-read_intervals",
"%+#64",
"-show_packets",
"-show_entries",
"stream=width,height,duration,codec_type:format=duration",
"stream=width,height,duration,start_time,nb_frames,avg_frame_rate:"
"format=duration:packet=pts_time,flags",
"-of",
"json",
str(video_path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _stderr = await process.communicate()
stdout, _stderr = await _communicate_video_process(process, 10)
if process.returncode != 0:
return {}
try:
payload = json.loads(stdout)
streams = payload.get("streams") or []
video_stream = next(
(s for s in streams if s.get("width") and s.get("height")),
next(
(s for s in streams if s.get("codec_type") == "video"),
streams[0] if streams else {},
),
)
video_stream = streams[0] if streams else {}
duration = float(
(payload.get("format") or {}).get("duration") or video_stream.get("duration") or 0
video_stream.get("duration") or (payload.get("format") or {}).get("duration") or 0
)
width, height = int(video_stream.get("width") or 0), int(video_stream.get("height") or 0)
start = float(video_stream.get("start_time") or 0)
frame_count = (
int(video_stream.get("nb_frames") or 0) if video_stream.get("nb_frames") != "N/A" else 0
)
numerator, _, denominator = str(video_stream.get("avg_frame_rate") or "0/1").partition("/")
average_fps = float(numerator) / (float(denominator or 1) or 1)
pts = [
float(p["pts_time"])
for p in payload.get("packets", [])
if "pts_time" in p and "D" not in str(p.get("flags", ""))
]
# Recorder configuration is capped at 60 fps. A generous 240 fps ceiling
# still catches malformed microsecond durations without rejecting VFR.
if (
not math.isfinite(duration)
or duration <= 0
or not math.isfinite(start)
or not math.isfinite(average_fps)
or not 0 < width <= 32768
or not 0 < height <= 32768
or average_fps < 0
or average_fps > 240
or frame_count < 0
or frame_count / duration > 240
or not pts
or any(not math.isfinite(value) for value in pts)
or max(pts) - start > duration + 0.05
):
logger.warning(f"Recording segment has invalid video timing: {video_path}")
return {}
return {
"duration": duration,
"width": int(video_stream.get("width") or 0),
"height": int(video_stream.get("height") or 0),
"width": width,
"height": height,
}
except (TypeError, ValueError, json.JSONDecodeError):
return {}