mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-20 15:54:13 +08:00
605 lines
24 KiB
Python
Executable File
605 lines
24 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import logging
|
|
import math
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import signal
|
|
import sys
|
|
import time
|
|
from argparse import ArgumentParser, ArgumentTypeError
|
|
from collections.abc import Sequence
|
|
from pathlib import Path
|
|
from random import randint
|
|
from subprocess import PIPE, Popen, TimeoutExpired
|
|
from typing import Literal
|
|
|
|
from openpilot.common.basedir import BASEDIR
|
|
from openpilot.common.utils import managed_proc
|
|
from openpilot.tools.lib.route import Route
|
|
from openpilot.tools.lib.logreader import LogReader
|
|
|
|
# cereal.messaging, common.params and common.prefix load native extensions that the raybig path
|
|
# builds at runtime (prepare_raybig_runtime), so they're imported at point of use instead - at
|
|
# module scope they'd fail before that build ever runs.
|
|
|
|
DEFAULT_OUTPUT = 'output.mp4'
|
|
DEMO_START = 90
|
|
DEMO_END = 105
|
|
DEMO_ROUTE = 'a2a0ccea32023010/2023-07-27--13-01-19'
|
|
FRAMERATE = 20
|
|
PIXEL_DEPTH = '24'
|
|
RESOLUTION = '2160x1080'
|
|
SECONDS_TO_WARM = 2
|
|
PROC_WAIT_SECONDS = 30*10
|
|
RECORD_TAIL_MARGIN = 5 # extra seconds recorded past the requested end, see record_raybig
|
|
MAX_CACHED_SEGMENTS = 5 # replay's own default, see tools/replay/main.cc
|
|
MAX_PLAYBACK = 10 # upper bound for --playback, see record_raybig
|
|
PROGRESS_INTERVAL = 30 # seconds between progress lines while recording
|
|
STALL_WARN_SECONDS = 60 # warn if the output file stops growing for this long
|
|
|
|
OPENPILOT_FONT = str(Path(BASEDIR, 'selfdrive/assets/fonts/Inter-Regular.ttf').resolve())
|
|
REPLAY = str(Path(BASEDIR, 'tools/replay/replay').resolve())
|
|
UI = str(Path(BASEDIR, 'selfdrive/ui/ui').resolve())
|
|
RAYBIG_UI = str(Path(BASEDIR, 'selfdrive/ui/ui.py').resolve())
|
|
RAYBIG_PREPARE_SCRIPT = str(Path(BASEDIR, 'scripts/launch_ui_raybig_desktop.sh').resolve())
|
|
WSLG_X11_DIR = '/mnt/wslg/.X11-unix'
|
|
|
|
logger = logging.getLogger('clip.py')
|
|
|
|
|
|
def proc_output(proc: Popen, tail: int = 4000) -> str:
|
|
log_file = getattr(proc, 'log_file', None)
|
|
if log_file is None:
|
|
return ''
|
|
pos = log_file.tell()
|
|
log_file.seek(0)
|
|
try:
|
|
return log_file.read().decode(errors='replace')[-tail:]
|
|
finally:
|
|
log_file.seek(pos)
|
|
|
|
|
|
def check_for_failure(procs: list[Popen]):
|
|
for proc in procs:
|
|
exit_code = proc.poll()
|
|
if exit_code is not None and exit_code != 0:
|
|
cmd = str(proc.args)
|
|
if isinstance(proc.args, str):
|
|
cmd = proc.args
|
|
elif isinstance(proc.args, Sequence):
|
|
cmd = str(proc.args[0])
|
|
msg = f'{cmd} failed, exit code {exit_code}'
|
|
logger.error(msg)
|
|
output = proc_output(proc)
|
|
if output:
|
|
logger.error(output)
|
|
raise ChildProcessError(msg)
|
|
|
|
|
|
def escape_ffmpeg_text(value: str):
|
|
special_chars = {',': '\\,', ':': '\\:', '=': '\\=', '[': '\\[', ']': '\\]'}
|
|
value = value.replace('\\', '\\\\\\\\\\\\\\\\')
|
|
for char, escaped in special_chars.items():
|
|
value = value.replace(char, escaped)
|
|
return value
|
|
|
|
|
|
def get_logreader(route: Route):
|
|
return LogReader(route.qlog_paths()[0] if len(route.qlog_paths()) else route.name.canonical_name)
|
|
|
|
|
|
def get_meta_text(lr: LogReader, route: Route):
|
|
init_data = lr.first('initData')
|
|
car_params = lr.first('carParams')
|
|
origin_parts = init_data.gitRemote.split('/')
|
|
origin = origin_parts[3] if len(origin_parts) > 3 else 'unknown'
|
|
return ', '.join([
|
|
f"openpilot v{init_data.version}",
|
|
f"route: {route.name.canonical_name}",
|
|
f"car: {car_params.carFingerprint}",
|
|
f"origin: {origin}",
|
|
f"branch: {init_data.gitBranch}",
|
|
f"commit: {init_data.gitCommit[:7]}",
|
|
f"modified: {str(init_data.dirty).lower()}",
|
|
])
|
|
|
|
|
|
def parse_args(parser: ArgumentParser):
|
|
args = parser.parse_args()
|
|
if args.demo:
|
|
args.route = DEMO_ROUTE
|
|
if args.start is None or args.end is None:
|
|
args.start = DEMO_START
|
|
args.end = DEMO_END
|
|
elif args.route.count('/') == 1:
|
|
if args.start is None or args.end is None:
|
|
parser.error('must provide both start and end if timing is not in the route ID')
|
|
elif args.route.count('/') == 3:
|
|
if args.start is not None or args.end is not None:
|
|
parser.error('don\'t provide timing when including it in the route ID')
|
|
parts = args.route.split('/')
|
|
args.route = '/'.join(parts[:2])
|
|
args.start = int(parts[2])
|
|
args.end = int(parts[3])
|
|
if args.end <= args.start:
|
|
parser.error(f'end ({args.end}) must be greater than start ({args.start})')
|
|
if args.start < SECONDS_TO_WARM:
|
|
parser.error(f'start must be greater than {SECONDS_TO_WARM}s to allow the UI time to warm up')
|
|
|
|
try:
|
|
args.route = Route(args.route, data_dir=args.data_dir)
|
|
except Exception as e:
|
|
parser.error(f'failed to get route: {e}')
|
|
|
|
# FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data
|
|
# max_seg_number is a 0-based index, so the route has max_seg_number + 1 segments
|
|
length = round((args.route.max_seg_number + 1) * 60)
|
|
if args.start >= length:
|
|
parser.error(f'start ({args.start}s) cannot be after end of route ({length}s)')
|
|
if args.end > length:
|
|
parser.error(f'end ({args.end}s) cannot be after end of route ({length}s)')
|
|
|
|
return args
|
|
|
|
|
|
def populate_car_params(lr: LogReader):
|
|
from openpilot.common.params import Params, UnknownKeyName
|
|
|
|
init_data = lr.first('initData')
|
|
assert init_data is not None
|
|
|
|
params = Params()
|
|
entries = init_data.params.entries
|
|
for cp in entries:
|
|
key, value = cp.key, cp.value
|
|
try:
|
|
params.put(key, params.cpp2python(key, value))
|
|
except UnknownKeyName:
|
|
# forks of openpilot may have other Params keys configured. ignore these
|
|
logger.warning(f"unknown Params key '{key}', skipping")
|
|
except (TypeError, ValueError) as e:
|
|
# logged value doesn't cast to the param's type, e.g. an empty JSON param. ignore these
|
|
logger.warning(f"could not restore Params key '{key}', skipping: {e}")
|
|
logger.debug('persisted CarParams')
|
|
|
|
|
|
def wslg_available() -> bool:
|
|
return os.path.isdir(WSLG_X11_DIR)
|
|
|
|
|
|
def validate_env(parser: ArgumentParser, ui: Literal['c3', 'raybig']):
|
|
if platform.system() not in ['Linux']:
|
|
parser.exit(1, f'clip.py: error: {platform.system()} is not a supported operating system\n')
|
|
|
|
use_wslg = ui == 'raybig' and wslg_available()
|
|
|
|
required_bins = ['ffmpeg']
|
|
if ui == 'raybig':
|
|
required_bins.append('ffprobe') # used to retime the export, see retime_to_wall_clock
|
|
if not use_wslg:
|
|
# Under WSLg, raybig renders against the existing live :0 display instead of a virtual Xvfb one.
|
|
required_bins.append('Xvfb')
|
|
for proc in required_bins:
|
|
if shutil.which(proc) is None:
|
|
parser.exit(1, f'clip.py: error: missing {proc} command, is it installed?\n')
|
|
|
|
# REPLAY is not checked here; prepare_replay() builds it on demand
|
|
|
|
if ui == 'c3':
|
|
if shutil.which(UI) is None:
|
|
parser.exit(1, f'clip.py: error: missing {UI} command, did you build openpilot yet?\n')
|
|
elif ui == 'raybig':
|
|
if not os.path.isfile(RAYBIG_UI):
|
|
parser.exit(1, f'clip.py: error: missing {RAYBIG_UI}\n')
|
|
if not os.access(RAYBIG_PREPARE_SCRIPT, os.X_OK):
|
|
parser.exit(1, f'clip.py: error: missing or non-executable {RAYBIG_PREPARE_SCRIPT}\n')
|
|
|
|
|
|
def validate_output_file(output_file: str):
|
|
if not output_file.endswith('.mp4'):
|
|
raise ArgumentTypeError('output must be an mp4')
|
|
return output_file
|
|
|
|
|
|
def validate_route(route: str):
|
|
if route.count('/') not in (1, 3):
|
|
raise ArgumentTypeError(f'route must include or exclude timing, example: {DEMO_ROUTE}')
|
|
return route
|
|
|
|
|
|
def validate_title(title: str):
|
|
if len(title) > 80:
|
|
raise ArgumentTypeError('title must be no longer than 80 chars')
|
|
return title
|
|
|
|
|
|
def validate_scale(scale: str):
|
|
value = float(scale)
|
|
if not 0 < value <= 1:
|
|
raise ArgumentTypeError('scale must be greater than 0 and at most 1')
|
|
return value
|
|
|
|
|
|
def validate_playback(playback: str):
|
|
value = float(playback)
|
|
if not 1 <= value <= MAX_PLAYBACK:
|
|
raise ArgumentTypeError(f'playback must be between 1 and {MAX_PLAYBACK}')
|
|
return value
|
|
|
|
|
|
def wait_for_frames(procs: list[Popen]):
|
|
from cereal.messaging import SubMaster
|
|
|
|
sm = SubMaster(['uiDebug'])
|
|
no_frames_drawn = True
|
|
while no_frames_drawn:
|
|
sm.update()
|
|
no_frames_drawn = sm['uiDebug'].drawTimeMillis == 0.
|
|
check_for_failure(procs)
|
|
|
|
|
|
def wait_for_xvfb(display_num: int, xvfb_proc: Popen, timeout: float = 10.0):
|
|
# the UI and replay start immediately after, so wait for the display to actually exist
|
|
socket_path = f'/tmp/.X11-unix/X{display_num}'
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
check_for_failure([xvfb_proc])
|
|
if os.path.exists(socket_path):
|
|
return
|
|
time.sleep(0.05)
|
|
raise TimeoutError(f'Xvfb did not create {socket_path} within {timeout}s')
|
|
|
|
|
|
def prepare_replay(jobs: int | None = None):
|
|
if shutil.which(REPLAY) is not None:
|
|
return
|
|
|
|
logger.info('building replay (this can take a while the first time)...')
|
|
env = os.environ.copy()
|
|
env['SP_DISABLE_AUTO_DEVICE_SCONS'] = '1'
|
|
scons = Path(BASEDIR, '.venv/bin/scons')
|
|
cmd = [str(scons)] if os.access(scons, os.X_OK) else [sys.executable, '-m', 'SCons']
|
|
cmd += ['--extras', '-j', str(jobs or os.cpu_count() or 8), 'tools/replay/replay']
|
|
if Popen(cmd, cwd=BASEDIR, env=env).wait() != 0:
|
|
raise ChildProcessError('failed to build replay, see output above')
|
|
|
|
|
|
def prepare_raybig_runtime():
|
|
logger.info('preparing raybig host runtime (this can take a while the first time)...')
|
|
env = os.environ.copy()
|
|
env['SP_RAYBIG_COMPILE_ONLY'] = '1'
|
|
# Without this, the script's own cleanup trap restores/deletes the .so files it just built.
|
|
env['SP_KEEP_DESKTOP_RUNTIME_ARTIFACTS'] = '1'
|
|
result = Popen([RAYBIG_PREPARE_SCRIPT], env=env)
|
|
if result.wait() != 0:
|
|
raise ChildProcessError('failed to prepare raybig host runtime, see output above')
|
|
|
|
|
|
def make_ffmpeg_cmd(display: str, out: str, duration: int, bit_rate_kbps: int, overlays: list[str]):
|
|
return [
|
|
'ffmpeg', '-y',
|
|
'-video_size', RESOLUTION,
|
|
'-framerate', str(FRAMERATE),
|
|
'-f', 'x11grab',
|
|
'-rtbufsize', '100M',
|
|
'-draw_mouse', '0',
|
|
'-i', display,
|
|
'-c:v', 'libx264',
|
|
'-maxrate', f'{bit_rate_kbps}k',
|
|
'-bufsize', f'{bit_rate_kbps*2}k',
|
|
'-crf', '23',
|
|
'-filter:v', ','.join(overlays),
|
|
'-preset', 'ultrafast',
|
|
'-tune', 'zerolatency',
|
|
'-pix_fmt', 'yuv420p',
|
|
'-movflags', '+faststart',
|
|
'-f', 'mp4',
|
|
'-t', str(duration),
|
|
out,
|
|
]
|
|
|
|
|
|
def record(procs: list[Popen], env: dict[str, str], display: str, out: str, duration: int,
|
|
bit_rate_kbps: int, overlays: list[str]):
|
|
# c3 has no frame export of its own, so screen capture its Xvfb display. raybig uses
|
|
# record_raybig instead.
|
|
logger.info('waiting for replay to begin (loading segments, may take a while)...')
|
|
wait_for_frames(procs)
|
|
logger.debug(f'letting UI warm up ({SECONDS_TO_WARM}s)...')
|
|
time.sleep(SECONDS_TO_WARM)
|
|
check_for_failure(procs)
|
|
|
|
ffmpeg_cmd = make_ffmpeg_cmd(display, out, duration, bit_rate_kbps, overlays)
|
|
with managed_proc(ffmpeg_cmd, env) as ffmpeg_proc:
|
|
all_procs = procs + [ffmpeg_proc]
|
|
logger.info(f'recording in progress ({duration}s)...')
|
|
ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS)
|
|
check_for_failure(all_procs)
|
|
logger.info(f'recording complete: {Path(out).resolve()}')
|
|
|
|
|
|
def probe_video(path: str) -> tuple[float, float]:
|
|
"""Return (duration_seconds, frame_rate) of a video file's first video stream."""
|
|
proc = Popen(['ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
|
'-show_entries', 'stream=r_frame_rate:format=duration',
|
|
'-of', 'default=noprint_wrappers=1:nokey=1', path], stdout=PIPE, stderr=PIPE)
|
|
stdout, stderr = proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise ChildProcessError(f'ffprobe failed on {path}: {stderr.decode().strip()}')
|
|
|
|
values = stdout.decode().split()
|
|
rate_str = next(v for v in values if '/' in v)
|
|
numerator, denominator = rate_str.split('/')
|
|
frame_rate = float(numerator) / float(denominator)
|
|
duration = float(next(v for v in values if '/' not in v))
|
|
return duration, frame_rate
|
|
|
|
|
|
def retime_to_wall_clock(out: str, recorded_seconds: float, tolerance: float = 0.10):
|
|
# the UI tags the export with the fps it targeted, but the real rate depends on how fast this
|
|
# machine reads frames back from the GPU. frames are produced in lockstep with real-time
|
|
# replay, so re-tag to match wall clock. remuxing through raw h264 avoids a re-encode.
|
|
# tolerance is loose because recorded_seconds starts at the first onroad frame, a few seconds
|
|
# after the UI starts recording; this only needs to catch gross mismatches.
|
|
duration, frame_rate = probe_video(out)
|
|
if abs(duration - recorded_seconds) <= tolerance * recorded_seconds:
|
|
return
|
|
|
|
actual_frame_rate = frame_rate * duration / recorded_seconds
|
|
logger.info(f'retiming: {duration:.1f}s at {frame_rate:.2f}fps -> {recorded_seconds:.1f}s at {actual_frame_rate:.2f}fps')
|
|
|
|
out_path = Path(out)
|
|
raw = out_path.with_suffix('.retime.h264')
|
|
retimed = out_path.with_suffix('.retime.mp4')
|
|
try:
|
|
for cmd in (['ffmpeg', '-y', '-v', 'error', '-i', out, '-c', 'copy', '-f', 'h264', str(raw)],
|
|
['ffmpeg', '-y', '-v', 'error', '-r', f'{actual_frame_rate:.6f}', '-i', str(raw),
|
|
'-c', 'copy', '-movflags', '+faststart', str(retimed)]):
|
|
proc = Popen(cmd, stdout=PIPE, stderr=PIPE)
|
|
_, stderr = proc.communicate()
|
|
if proc.returncode != 0:
|
|
raise ChildProcessError(f'retime step failed: {stderr.decode().strip()}')
|
|
retimed.replace(out_path)
|
|
finally:
|
|
raw.unlink(missing_ok=True)
|
|
retimed.unlink(missing_ok=True)
|
|
|
|
|
|
def record_raybig(ui_proc: Popen, replay_proc: Popen, duration: int, out: str, playback: float = 1.0):
|
|
# the UI records from the moment its window opens, so the export leads with some offroad
|
|
# frames. tail margin covers replay not being exactly at `start` on the first onroad frame.
|
|
# at playback > 1 the route advances faster than the clock, so we wait proportionally less.
|
|
procs = [ui_proc, replay_proc]
|
|
logger.info('waiting for replay to begin (loading segments, may take a while)...')
|
|
wait_for_frames(procs)
|
|
record_for = (SECONDS_TO_WARM + duration + RECORD_TAIL_MARGIN) / playback
|
|
if playback > 1:
|
|
logger.info(f'recording in progress ({duration}s of route at {playback}x, ~{record_for:.0f}s wall clock)...')
|
|
else:
|
|
logger.info(f'recording in progress ({duration}s)...')
|
|
started_at = time.monotonic()
|
|
out_path = Path(out)
|
|
last_size, grew_at, logged_at = -1, started_at, started_at
|
|
|
|
# poll rather than sleeping straight through, so a replay or UI that dies partway is caught
|
|
# now instead of after the full duration has elapsed
|
|
while (elapsed := time.monotonic() - started_at) < record_for:
|
|
check_for_failure(procs)
|
|
now = time.monotonic()
|
|
size = out_path.stat().st_size if out_path.exists() else 0
|
|
|
|
if size > last_size:
|
|
last_size, grew_at = size, now
|
|
elif now - grew_at > STALL_WARN_SECONDS:
|
|
logger.warning(f'{out} has not grown in {now - grew_at:.0f}s; recording may have stalled')
|
|
# the source is the usual suspect, so show what it last reported
|
|
output = proc_output(replay_proc, tail=1500)
|
|
if output:
|
|
logger.warning(f'last replay output:\n{output}')
|
|
grew_at = now
|
|
|
|
if now - logged_at >= PROGRESS_INTERVAL:
|
|
logger.info(f'recording {elapsed:.0f}/{record_for:.0f}s, {size / 1e6:.0f}MB')
|
|
logged_at = now
|
|
|
|
time.sleep(1)
|
|
|
|
check_for_failure(procs)
|
|
|
|
logger.info('stopping recording...')
|
|
# SIGINT closes the window gracefully, flushing the UI's own ffmpeg pipe (close_ffmpeg() in
|
|
# system/ui/lib/application.py). SIGTERM, which managed_proc uses, has no handler and would
|
|
# skip that. close_ffmpeg() can take up to 60s, so wait longer than that before killing.
|
|
ui_proc.send_signal(signal.SIGINT)
|
|
# the clip should run for the stretch of route it covers, not the wall clock time it took,
|
|
# so at playback > 1 scale back up. this also self-corrects a machine that couldn't render
|
|
# fast enough to keep up: the result is fewer frames, not a wrongly sped up clip.
|
|
recorded_seconds = (time.monotonic() - started_at) * playback
|
|
try:
|
|
ui_proc.wait(timeout=90)
|
|
except TimeoutExpired:
|
|
logger.warning('UI did not exit cleanly after SIGINT; the recording may be truncated or corrupt')
|
|
ui_proc.kill()
|
|
ui_proc.wait()
|
|
|
|
if ui_proc.returncode not in (0, -signal.SIGINT):
|
|
logger.warning(f'UI exited with code {ui_proc.returncode} after SIGINT; the recording may be incomplete')
|
|
|
|
retime_to_wall_clock(out, recorded_seconds)
|
|
logger.info(f'recording complete: {Path(out).resolve()}')
|
|
|
|
|
|
def clip(
|
|
data_dir: str | None,
|
|
quality: Literal['low', 'high'],
|
|
prefix: str,
|
|
route: Route,
|
|
out: str,
|
|
start: int,
|
|
end: int,
|
|
speed: int,
|
|
target_mb: int,
|
|
title: str | None,
|
|
ui: Literal['c3', 'raybig'],
|
|
scale: float | None,
|
|
playback: float,
|
|
):
|
|
logger.info(f'clipping route {route.name.canonical_name}, start={start} end={end} quality={quality} target_filesize={target_mb}MB')
|
|
Path(out).resolve().parent.mkdir(parents=True, exist_ok=True)
|
|
lr = get_logreader(route)
|
|
|
|
begin_at = max(start - SECONDS_TO_WARM, 0)
|
|
duration = end - start
|
|
bit_rate_kbps = int(round(target_mb * 8 * 1024 * 1024 / duration / 1000))
|
|
|
|
# raybig exports its own frames (RECORD, below) rather than being screen captured, so under
|
|
# WSLg it can just render against the live desktop instead of needing its own Xvfb.
|
|
use_wslg = ui == 'raybig' and wslg_available()
|
|
if use_wslg:
|
|
display = os.environ.get('DISPLAY', ':0')
|
|
else:
|
|
# TODO: evaluate creating fn that inspects /tmp/.X11-unix and creates unused display to avoid possibility of collision
|
|
display_num = randint(99, 999)
|
|
display = f':{display_num}'
|
|
xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}']
|
|
|
|
box_style = 'box=1:boxcolor=black@0.33:boxborderw=7'
|
|
meta_text = get_meta_text(lr, route)
|
|
overlays = [
|
|
# metadata overlay
|
|
f"drawtext=text='{escape_ffmpeg_text(meta_text)}':fontfile={OPENPILOT_FONT}:fontcolor=white:fontsize=15:{box_style}:x=(w-text_w)/2:y=5.5:enable='between(t,1,5)'",
|
|
# route time overlay
|
|
f"drawtext=text='%{{eif\\:floor(({start}+t)/60)\\:d\\:2}}\\:%{{eif\\:mod({start}+t\\,60)\\:d\\:2}}':fontfile={OPENPILOT_FONT}:fontcolor=white:fontsize=24:{box_style}:x=w-text_w-38:y=38"
|
|
]
|
|
if title:
|
|
overlays.append(f"drawtext=text='{escape_ffmpeg_text(title)}':fontfile={OPENPILOT_FONT}:fontcolor=white:fontsize=32:{box_style}:x=(w-text_w)/2:y=53")
|
|
|
|
if speed > 1:
|
|
overlays += [
|
|
f"setpts=PTS/{speed}",
|
|
"fps=60",
|
|
]
|
|
|
|
# read far enough ahead that replay doesn't run dry partway through and loop back on itself,
|
|
# repeating earlier footage. capped because each cached ~60s segment holds its logs and camera
|
|
# video in memory, and a long clip would otherwise try to hold the whole route at once.
|
|
segments_to_cache = min(math.ceil((end - begin_at) / 60) + 1, MAX_CACHED_SEGMENTS)
|
|
replay_cmd = [REPLAY, '--ecam', '-c', str(segments_to_cache), '-s', str(begin_at), '--prefix', prefix]
|
|
if playback > 1:
|
|
replay_cmd.extend(['-x', str(playback)])
|
|
if data_dir:
|
|
replay_cmd.extend(['--data_dir', data_dir])
|
|
if quality == 'low':
|
|
replay_cmd.append('--qcam')
|
|
replay_cmd.append(route.name.canonical_name)
|
|
|
|
prepare_replay()
|
|
|
|
if ui == 'raybig':
|
|
prepare_raybig_runtime()
|
|
ui_cmd = [sys.executable, RAYBIG_UI]
|
|
else:
|
|
ui_cmd = [UI, '-platform', 'xcb']
|
|
|
|
# imported here, after prepare_raybig_runtime() has built the native extensions it needs
|
|
from openpilot.common.prefix import OpenpilotPrefix
|
|
|
|
with OpenpilotPrefix(prefix, shared_download_cache=True):
|
|
populate_car_params(lr)
|
|
env = os.environ.copy()
|
|
env['DISPLAY'] = display
|
|
if ui == 'raybig':
|
|
env['BIG'] = '1'
|
|
env.setdefault('PRIME_TYPE', '0')
|
|
# use the real replayed drive stats instead of raybig's desktop fake-data demo
|
|
env['SP_RAYBIG_FAKE_DRIVE_STATS'] = '0'
|
|
pythonpath_extra = f"{BASEDIR}{os.pathsep}{Path(BASEDIR, 'starpilot/third_party')}"
|
|
env['PYTHONPATH'] = f"{pythonpath_extra}{os.pathsep}{env['PYTHONPATH']}" if env.get('PYTHONPATH') else pythonpath_extra
|
|
# the raylib UI pipes its own frames to ffmpeg (system/ui/lib/application.py). no drawtext
|
|
# overlays on this path, unlike c3.
|
|
env['RECORD'] = '1'
|
|
env['RECORD_OUTPUT'] = out
|
|
env['RECORD_BITRATE'] = f'{bit_rate_kbps}k'
|
|
if speed > 1:
|
|
env['RECORD_SPEED'] = str(speed)
|
|
# the UI defaults to 60fps and tags the export as such, but the per-frame GPU readback
|
|
# can't sustain that and the clip plays fast. ask for a rate it can actually hit. at
|
|
# playback > 1 it has to render proportionally faster to still cover FRAMERATE frames
|
|
# per second of route.
|
|
env['FPS'] = str(int(round(FRAMERATE * playback)))
|
|
# sets the render texture size, which is what gets piped to ffmpeg. left unset, the UI
|
|
# picks a scale that fits the screen.
|
|
if scale is not None:
|
|
env['SCALE'] = str(scale)
|
|
|
|
if use_wslg:
|
|
logger.info('WSLg detected: rendering against the live desktop display.')
|
|
with managed_proc(ui_cmd, env) as ui_proc, managed_proc(replay_cmd, env) as replay_proc:
|
|
record_raybig(ui_proc, replay_proc, duration, out, playback)
|
|
else:
|
|
with managed_proc(xvfb_cmd, env) as xvfb_proc:
|
|
wait_for_xvfb(display_num, xvfb_proc)
|
|
with managed_proc(ui_cmd, env) as ui_proc, managed_proc(replay_cmd, env) as replay_proc:
|
|
record_raybig(ui_proc, replay_proc, duration, out, playback)
|
|
else:
|
|
with managed_proc(xvfb_cmd, env) as xvfb_proc:
|
|
wait_for_xvfb(display_num, xvfb_proc)
|
|
with managed_proc(ui_cmd, env) as ui_proc, managed_proc(replay_cmd, env) as replay_proc:
|
|
record([xvfb_proc, ui_proc, replay_proc], env, display, out, duration, bit_rate_kbps, overlays)
|
|
|
|
|
|
def main():
|
|
p = ArgumentParser(prog='clip.py', description='clip your openpilot route.', epilog='comma.ai')
|
|
route_group = p.add_mutually_exclusive_group(required=True)
|
|
route_group.add_argument('route', nargs='?', type=validate_route, help=f'The route (e.g. {DEMO_ROUTE} or {DEMO_ROUTE}/{DEMO_START}/{DEMO_END})')
|
|
route_group.add_argument('--demo', help='use the demo route', action='store_true')
|
|
p.add_argument('-d', '--data-dir', help='local directory where route data is stored')
|
|
p.add_argument('-e', '--end', help='stop clipping at <end> seconds', type=int)
|
|
p.add_argument('-f', '--file-size', help='target file size (Discord/GitHub support max 10MB, default is 9MB)', type=float, default=9.)
|
|
p.add_argument('-o', '--output', help='output clip to (.mp4)', type=validate_output_file, default=DEFAULT_OUTPUT)
|
|
p.add_argument('-p', '--prefix', help='openpilot prefix', default=f'clip_{randint(100, 99999)}')
|
|
p.add_argument('-q', '--quality', help='quality of camera (low = qcam, high = hevc)', choices=['low', 'high'], default='high')
|
|
p.add_argument('-x', '--speed', help='record the clip at this speed multiple', type=int, default=1)
|
|
p.add_argument('-s', '--start', help='start clipping at <start> seconds', type=int)
|
|
p.add_argument('-t', '--title', help='overlay this title on the video (e.g. "Chill driving across the Golden Gate Bridge")', type=validate_title)
|
|
p.add_argument('-u', '--ui', help='desktop UI to record. raybig exports its own frames, so it also works where screen capture '
|
|
'cannot see the window (e.g. WSLg), but gets no title/metadata overlays',
|
|
choices=['c3', 'raybig'], default='c3')
|
|
p.add_argument('--scale', help='scale the recorded resolution, e.g. 0.5 for half size (raybig only, default is to fit the screen)',
|
|
type=validate_scale)
|
|
p.add_argument('--playback', help='replay faster than real time to finish sooner, e.g. 2 for twice as fast (raybig only). '
|
|
'the clip still plays at normal speed, but drops frames if the UI cannot keep up',
|
|
type=validate_playback, default=1.0)
|
|
args = parse_args(p)
|
|
validate_env(p, args.ui)
|
|
exit_code = 1
|
|
try:
|
|
clip(
|
|
data_dir=args.data_dir,
|
|
quality=args.quality,
|
|
prefix=args.prefix,
|
|
route=args.route,
|
|
out=args.output,
|
|
start=args.start,
|
|
end=args.end,
|
|
speed=args.speed,
|
|
target_mb=args.file_size,
|
|
title=args.title,
|
|
ui=args.ui,
|
|
scale=args.scale,
|
|
playback=args.playback,
|
|
)
|
|
exit_code = 0
|
|
except KeyboardInterrupt as e:
|
|
logger.exception('interrupted by user', exc_info=e)
|
|
except Exception as e:
|
|
logger.exception('encountered error', exc_info=e)
|
|
sys.exit(exit_code)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s\t%(message)s')
|
|
main()
|