feat(plugins): add source-aware evidence and recording timelines
This commit is contained in:
1 parent
72ff342605
commit
ef7af9988e
61 files changed
+3847
-108
No files matched your search
+101
-20
@@ -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 {}
|
||||
|
||||
Reference in new issue
Block a user