mirror of
https://gitlvb.teallvbs.xyz/IQ.Lvbs/IQ.Pilot.git
synced 2026-07-23 20:42:11 +08:00
294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""
|
|
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import queue
|
|
import shutil
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
import pyray as rl
|
|
|
|
from openpilot.common.params import Params
|
|
from openpilot.common.swaglog import cloudlog
|
|
from openpilot.system.hardware.hw import Paths
|
|
|
|
PARAM_KEY = "ScreenRecording"
|
|
PARAM_POLL_INTERVAL = 0.5 # seconds between param checks
|
|
CAPTURE_FPS = int(os.getenv("SCREEN_RECORD_FPS", "15"))
|
|
MAX_DURATION_S = int(os.getenv("SCREEN_RECORD_MAX_S", str(10 * 60))) # auto-stop safety
|
|
MIN_FREE_DISK_BYTES = 2 * 1024**3 # refuse to record with < 2GB free
|
|
QUEUE_MAX_FRAMES = 30 # ~2s of frames before we start dropping
|
|
MIN_VALID_OUTPUT_BYTES = 8 * 1024
|
|
CRF = os.getenv("SCREEN_RECORD_CRF", "28")
|
|
MAX_BITRATE = os.getenv("SCREEN_RECORD_MAXRATE", "2M")
|
|
# capture at half res above this width (BIG UI 2160 -> 1080), native below (mici 536)
|
|
DOWNSCALE_THRESHOLD = 1200
|
|
# Skip capture while the UI frame time is above this multiple of target: the readback
|
|
# (blocking glReadPixels) and extra present-blit stall the render thread, so back off and
|
|
# let the UI recover rather than dragging its framerate down. The recorder self-throttles.
|
|
BACKOFF_FRAME_TIME_RATIO = float(os.getenv("SCREEN_RECORD_BACKOFF", "1.35"))
|
|
|
|
|
|
class ScreenRecorder:
|
|
def __init__(self, width: int, height: int, target_fps: int):
|
|
self._width = width
|
|
self._height = height
|
|
scale = 2 if width > DOWNSCALE_THRESHOLD else 1
|
|
# yuv420 needs even dimensions
|
|
self._rec_width = (width // scale) & ~1
|
|
self._rec_height = (height // scale) & ~1
|
|
self._target_fps = max(1, target_fps)
|
|
self._capture_fps = max(1, min(CAPTURE_FPS, target_fps))
|
|
self._frame_interval = max(1, round(target_fps / self._capture_fps))
|
|
self._target_frame_time = 1.0 / self._target_fps
|
|
|
|
self._params = Params()
|
|
self._active = False
|
|
self._owns_rt = False
|
|
self._rt: rl.RenderTexture | None = None
|
|
self._small_rt: rl.RenderTexture | None = None
|
|
|
|
self._proc: subprocess.Popen | None = None
|
|
self._queue: queue.Queue[bytes | None] | None = None
|
|
self._writer: threading.Thread | None = None
|
|
self._finalizers: list[threading.Thread] = []
|
|
|
|
self._frame_idx = 0
|
|
self._dropped = 0
|
|
self._backoff_skips = 0
|
|
self._start_time = 0.0
|
|
self._last_poll = 0.0
|
|
self._out_path = ""
|
|
# after a self-initiated stop, don't restart until the param is observed False
|
|
self._await_param_clear = False
|
|
# adaptive backoff: EMA of the render-loop frame time
|
|
self._last_frame_t = 0.0
|
|
self._frame_time_ema = 0.0
|
|
self._capture_this_frame = False
|
|
|
|
@property
|
|
def active(self) -> bool:
|
|
return self._active
|
|
|
|
@property
|
|
def render_texture(self) -> rl.RenderTexture | None:
|
|
return self._rt
|
|
|
|
def begin_frame(self) -> bool:
|
|
self._capture_this_frame = False
|
|
if not self._active:
|
|
return False
|
|
|
|
now = time.monotonic()
|
|
if self._last_frame_t:
|
|
dt = now - self._last_frame_t
|
|
self._frame_time_ema = dt if self._frame_time_ema == 0.0 else 0.85 * self._frame_time_ema + 0.15 * dt
|
|
self._last_frame_t = now
|
|
|
|
self._frame_idx += 1
|
|
if self._frame_idx % self._frame_interval != 0:
|
|
return False
|
|
if self._frame_time_ema > self._target_frame_time * BACKOFF_FRAME_TIME_RATIO:
|
|
self._backoff_skips += 1
|
|
if self._backoff_skips % 200 == 1:
|
|
cloudlog.warning(f"screen_recorder: UI busy, backing off capture ({self._backoff_skips} skips)")
|
|
return False
|
|
|
|
self._capture_this_frame = True
|
|
return True
|
|
|
|
def update(self, app_render_texture: rl.RenderTexture | None) -> None:
|
|
"""Called every frame from the render thread. Handles start/stop and safety limits."""
|
|
now = time.monotonic()
|
|
if self._active:
|
|
# encoder died (e.g. broken pipe) or hit the duration cap
|
|
if self._proc is not None and self._proc.poll() is not None:
|
|
cloudlog.error(f"screen_recorder: encoder exited unexpectedly rc={self._proc.returncode}")
|
|
self._stop(clear_param=True)
|
|
elif now - self._start_time > MAX_DURATION_S:
|
|
cloudlog.warning("screen_recorder: max duration reached, auto-stopping")
|
|
self._stop(clear_param=True)
|
|
|
|
if now - self._last_poll < PARAM_POLL_INTERVAL:
|
|
return
|
|
self._last_poll = now
|
|
|
|
want = self._params.get_bool(PARAM_KEY)
|
|
if self._await_param_clear:
|
|
if want:
|
|
return
|
|
self._await_param_clear = False
|
|
if want and not self._active:
|
|
self._start(app_render_texture)
|
|
elif not want and self._active:
|
|
self._stop(clear_param=False)
|
|
|
|
def _start(self, app_render_texture: rl.RenderTexture | None) -> None:
|
|
root = Paths.screen_recordings_root()
|
|
try:
|
|
os.makedirs(root, exist_ok=True)
|
|
if shutil.disk_usage(root).free < MIN_FREE_DISK_BYTES:
|
|
cloudlog.warning("screen_recorder: not enough free disk space, refusing to record")
|
|
self._params.put_bool_nonblocking(PARAM_KEY, False)
|
|
return
|
|
except OSError as e:
|
|
cloudlog.exception(f"screen_recorder: cannot prepare output dir: {e}")
|
|
self._params.put_bool_nonblocking(PARAM_KEY, False)
|
|
return
|
|
|
|
if app_render_texture is not None:
|
|
self._rt = app_render_texture
|
|
self._owns_rt = False
|
|
else:
|
|
self._rt = rl.load_render_texture(self._width, self._height)
|
|
rl.set_texture_filter(self._rt.texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
|
self._owns_rt = True
|
|
self._small_rt = rl.load_render_texture(self._rec_width, self._rec_height)
|
|
|
|
self._out_path = os.path.join(root, time.strftime("screen_recording_%Y-%m-%d_%H-%M-%S.mp4"))
|
|
args = [
|
|
'ffmpeg', '-v', 'error', '-nostats',
|
|
'-f', 'rawvideo', '-pix_fmt', 'rgba',
|
|
'-s', f'{self._rec_width}x{self._rec_height}',
|
|
'-r', str(self._capture_fps), '-i', 'pipe:0',
|
|
'-vf', 'vflip,format=yuv420p',
|
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency',
|
|
'-crf', CRF, '-maxrate', MAX_BITRATE, '-bufsize', '4M',
|
|
'-g', str(self._capture_fps * 2),
|
|
'-threads', '2',
|
|
# fragmented mp4: file stays playable even if we die before a clean stop
|
|
'-movflags', '+frag_keyframe+empty_moov+default_base_moof',
|
|
'-y', '-f', 'mp4', self._out_path,
|
|
]
|
|
try:
|
|
self._proc = subprocess.Popen(args, stdin=subprocess.PIPE,
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
preexec_fn=lambda: os.nice(15))
|
|
except OSError as e:
|
|
cloudlog.exception(f"screen_recorder: failed to start ffmpeg: {e}")
|
|
self._release_textures()
|
|
self._params.put_bool_nonblocking(PARAM_KEY, False)
|
|
return
|
|
|
|
self._queue = queue.Queue(maxsize=QUEUE_MAX_FRAMES)
|
|
self._writer = threading.Thread(target=self._writer_thread, args=(self._proc, self._queue), daemon=True)
|
|
self._writer.start()
|
|
|
|
self._frame_idx = 0
|
|
self._dropped = 0
|
|
self._backoff_skips = 0
|
|
self._last_frame_t = 0.0
|
|
self._frame_time_ema = 0.0
|
|
self._start_time = time.monotonic()
|
|
self._active = True
|
|
cloudlog.event("screen_recorder: started", path=self._out_path,
|
|
size=f"{self._rec_width}x{self._rec_height}", fps=self._capture_fps)
|
|
|
|
def _stop(self, clear_param: bool) -> None:
|
|
self._active = False
|
|
proc, q, writer = self._proc, self._queue, self._writer
|
|
self._proc, self._queue, self._writer = None, None, None
|
|
self._release_textures()
|
|
|
|
if clear_param:
|
|
self._params.put_bool_nonblocking(PARAM_KEY, False)
|
|
self._await_param_clear = True
|
|
|
|
# finalize off the render thread; ffmpeg needs a clean stdin close to flush
|
|
out_path = self._out_path
|
|
def _finalize():
|
|
try:
|
|
if q is not None:
|
|
q.put(None)
|
|
if writer is not None:
|
|
writer.join(timeout=10)
|
|
if proc is not None:
|
|
if proc.stdin is not None:
|
|
try:
|
|
proc.stdin.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=15)
|
|
except subprocess.TimeoutExpired:
|
|
proc.terminate()
|
|
proc.wait(timeout=5)
|
|
try:
|
|
if out_path and os.path.getsize(out_path) < MIN_VALID_OUTPUT_BYTES:
|
|
os.remove(out_path)
|
|
cloudlog.warning(f"screen_recorder: discarded truncated output {out_path}")
|
|
except OSError:
|
|
pass
|
|
except Exception:
|
|
cloudlog.exception("screen_recorder: finalize failed")
|
|
|
|
t = threading.Thread(target=_finalize, daemon=True)
|
|
t.start()
|
|
self._finalizers = [f for f in self._finalizers if f.is_alive()] + [t]
|
|
cloudlog.event("screen_recorder: stopped", path=self._out_path, dropped=self._dropped)
|
|
|
|
def _release_textures(self) -> None:
|
|
# render thread only
|
|
if self._small_rt is not None:
|
|
rl.unload_render_texture(self._small_rt)
|
|
self._small_rt = None
|
|
if self._rt is not None and self._owns_rt:
|
|
rl.unload_render_texture(self._rt)
|
|
self._rt = None
|
|
self._owns_rt = False
|
|
|
|
def capture(self, frame_rt: rl.RenderTexture) -> None:
|
|
if not self._capture_this_frame or self._small_rt is None or self._queue is None:
|
|
return
|
|
if self._queue.full():
|
|
self._dropped += 1
|
|
if self._dropped % 100 == 1:
|
|
cloudlog.warning(f"screen_recorder: encoder falling behind, dropped {self._dropped} frames")
|
|
return
|
|
|
|
src = rl.Rectangle(0, 0, float(frame_rt.texture.width), -float(frame_rt.texture.height))
|
|
dst = rl.Rectangle(0, 0, float(self._rec_width), float(self._rec_height))
|
|
rl.begin_texture_mode(self._small_rt)
|
|
rl.draw_texture_pro(frame_rt.texture, src, dst, rl.Vector2(0, 0), 0.0, rl.WHITE)
|
|
rl.end_texture_mode()
|
|
|
|
image = rl.load_image_from_texture(self._small_rt.texture)
|
|
try:
|
|
data = bytes(rl.ffi.buffer(image.data, self._rec_width * self._rec_height * 4))
|
|
finally:
|
|
rl.unload_image(image)
|
|
try:
|
|
self._queue.put_nowait(data)
|
|
except queue.Full:
|
|
self._dropped += 1
|
|
|
|
def draw_indicator(self, width: int) -> None:
|
|
"""Pulsing red REC dot, drawn into the frame so it shows on screen and in the video."""
|
|
big = width > DOWNSCALE_THRESHOLD
|
|
radius = 14 if big else 7
|
|
margin = (28 if big else 14) + radius
|
|
alpha = int(155 + 100 * math.sin(time.monotonic() * 4.0))
|
|
rl.draw_circle(width - margin, margin, float(radius), rl.Color(255, 60, 60, alpha))
|
|
|
|
@staticmethod
|
|
def _writer_thread(proc: subprocess.Popen, q: queue.Queue[bytes | None]) -> None:
|
|
while True:
|
|
data = q.get()
|
|
if data is None:
|
|
break
|
|
try:
|
|
proc.stdin.write(data)
|
|
except (BrokenPipeError, OSError):
|
|
break
|
|
|
|
def close(self) -> None:
|
|
if self._active:
|
|
self._stop(clear_param=False)
|
|
for t in self._finalizers:
|
|
t.join(timeout=15)
|