diff --git a/scripts/launch_ui_raybig_desktop.sh b/scripts/launch_ui_raybig_desktop.sh index b528893b6..3fe592381 100755 --- a/scripts/launch_ui_raybig_desktop.sh +++ b/scripts/launch_ui_raybig_desktop.sh @@ -202,6 +202,18 @@ prepare_pandad_host_artifacts() { rm -f "${ROOT_DIR}/selfdrive/pandad/libcan_list_to_can_capnp.a" } +prepare_acados_host_artifacts() { + # c_generated_code is regenerated byte-for-byte each run, so SCons sees no change and skips + # relinking, leaving a larch64 solver lib from a device build in place. Drop the whole tree. + local rel="" + for rel in "selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so" \ + "selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so"; do + if archive_is_aarch64_elf "${ROOT_DIR}/${rel}"; then + rm -rf "$(dirname "${ROOT_DIR}/${rel}")" + fi + done +} + python_ui_runtime_ok() { "${PY_BIN}" - <<'PY' import pyray # noqa: F401 @@ -286,6 +298,7 @@ if ! python_ui_runtime_ok >/dev/null 2>&1; then prepare_common_host_artifacts prepare_msgq_host_artifacts prepare_pandad_host_artifacts + prepare_acados_host_artifacts remove_if_elf "common/params_pyx.so" remove_if_elf "common/transformations/transformations.so" remove_if_elf "msgq/ipc_pyx.so" diff --git a/tools/STARPILOT_DEVELOPMENT.md b/tools/STARPILOT_DEVELOPMENT.md index ab0c9e169..1b57bddd2 100644 --- a/tools/STARPILOT_DEVELOPMENT.md +++ b/tools/STARPILOT_DEVELOPMENT.md @@ -82,6 +82,9 @@ All three entrypoints do the same thing. `./dev` is the shortest general-purpose - `./dev sync` - `./dev shell` +`./onroad` replays a route into a desktop UI to watch live. To record one to an mp4 instead of +watching it in real time, use `tools/clip/run.py` (see [tools/clip/README.md](clip/README.md)). + ### Desktop UI shorthands - `./c3 [jobs] [args...]` diff --git a/tools/clip/README.md b/tools/clip/README.md new file mode 100644 index 000000000..22b505ce5 --- /dev/null +++ b/tools/clip/README.md @@ -0,0 +1,91 @@ +# Clip + +`clip.py` records a video of a driving route by replaying it into a desktop UI and capturing the result. Use it to export footage of a route without watching it live. + +## Setup + +Install dependencies (this pulls in `uv`, `ffmpeg`, and `xvfb`): + +```bash +tools/ubuntu_setup.sh +``` + +To clip a route from your comma account, authenticate first: + +```bash +python3 tools/lib/auth.py +``` + +Public routes and `--demo` need no authentication. + +`replay` and the raybig UI runtime are built automatically on first run, so expect the first +clip to take a few extra minutes. + +## Usage + +```bash +# Clip the demo route +python3 tools/clip/run.py --demo -o output.mp4 + +# Clip seconds 140-260 of a route, timing in the route ID +python3 tools/clip/run.py 78511c37de32c375/00000958--8cb1d3e165/140/260 -o clip.mp4 + +# Same, timing as flags +python3 tools/clip/run.py 78511c37de32c375/00000958--8cb1d3e165 -s 140 -e 260 -o clip.mp4 +``` + +Recording happens in real time, so a 120s clip takes about 120s to produce. + +## Choosing a UI + +`-u/--ui` selects which desktop UI to record. The default is `c3`. + +```bash +python3 tools/clip/run.py --demo -u raybig -o output.mp4 +``` + +- `c3` renders the Qt UI into a virtual Xvfb display and screen captures it with `ffmpeg`. +- `raybig` runs the raylib UI, which exports its own frames straight to `ffmpeg`. This bypasses + screen capture entirely, so it also works where a compositor hides window contents from + `x11grab` (notably WSLg). + +Note that `raybig` clips do not get the title, metadata, or route timer overlays that `c3` +clips get: those are `ffmpeg` `drawtext` filters applied during screen capture, and the raylib +UI's own export path does not run them. + +## Options + +| Flag | Description | +| --- | --- | +| `-o, --output` | Output path, must be `.mp4` (default `output.mp4`) | +| `-s, --start` / `-e, --end` | Clip window in seconds; omit if timing is in the route ID | +| `-u, --ui` | `c3` (default) or `raybig` | +| `-f, --file-size` | Target size in MB (default 9, sized for Discord/GitHub) | +| `-q, --quality` | `high` (hevc, default) or `low` (qcam) | +| `-x, --speed` | Record at this speed multiple | +| `-t, --title` | Burn a title into the video (`c3` only) | +| `-d, --data-dir` | Use local route data instead of downloading | +| `-p, --prefix` | openpilot prefix to isolate the run | + +The default 9MB target suits short clips. For anything longer than about a minute, raise it +(`-f 250`) or the bitrate drops low enough to be visibly bad. + +## WSL + +Recording works on WSL2 under Windows 11, which supplies a display through WSLg. Use +`-u raybig`; `-u c3` relies on screen capture and can produce black frames there. + +```bash +python3 tools/clip/run.py -u raybig -f 250 -o ~/clips/foo.mp4 78511c37de32c375/00000958--8cb1d3e165/140/260 +``` + +Running the tool rebuilds native extensions, and this repo tracks those build artifacts in git, +so the working tree ends up dirty afterwards. That does not affect running it again, it only +matters when pulling: + +```bash +git checkout -- . && git pull +``` + +The discarded files are rebuilt on the next run. If you have source edits you want to keep, +commit or stash them instead, since `git checkout -- .` discards all unstaged changes. diff --git a/tools/clip/run.py b/tools/clip/run.py index 9045a4381..078146131 100755 --- a/tools/clip/run.py +++ b/tools/clip/run.py @@ -1,26 +1,29 @@ #!/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 Popen +from subprocess import PIPE, Popen, TimeoutExpired from typing import Literal -from cereal.messaging import SubMaster from openpilot.common.basedir import BASEDIR -from openpilot.common.params import Params, UnknownKeyName -from openpilot.common.prefix import OpenpilotPrefix 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 @@ -30,10 +33,14 @@ 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 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') @@ -113,7 +120,8 @@ def parse_args(parser: ArgumentParser): parser.error(f'failed to get route: {e}') # FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data - length = round(args.route.max_seg_number * 60) + # 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: @@ -123,6 +131,8 @@ def parse_args(parser: ArgumentParser): def populate_car_params(lr: LogReader): + from openpilot.common.params import Params, UnknownKeyName + init_data = lr.first('initData') assert init_data is not None @@ -135,18 +145,42 @@ def populate_car_params(lr: LogReader): 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 validate_env(parser: ArgumentParser): +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') - for proc in ['Xvfb', 'ffmpeg']: + + 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') - for proc in [REPLAY, UI]: - if shutil.which(proc) is None: - parser.exit(1, f'clip.py: error: missing {proc} command, did you build openpilot yet?\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): @@ -168,6 +202,8 @@ def validate_title(title: str): def wait_for_frames(procs: list[Popen]): + from cereal.messaging import SubMaster + sm = SubMaster(['uiDebug']) no_frames_drawn = True while no_frames_drawn: @@ -176,46 +212,45 @@ def wait_for_frames(procs: list[Popen]): check_for_failure(procs) -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, -): - logger.info(f'clipping route {route.name.canonical_name}, start={start} end={end} quality={quality} target_filesize={target_mb}MB') - lr = get_logreader(route) +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') - begin_at = max(start - SECONDS_TO_WARM, 0) - duration = end - start - bit_rate_kbps = int(round(target_mb * 8 * 1024 * 1024 / duration / 1000)) - # TODO: evaluate creating fn that inspects /tmp/.X11-unix and creates unused display to avoid possibility of collision - display = f':{randint(99, 999)}' +def prepare_replay(jobs: int | None = None): + if shutil.which(REPLAY) is not None: + return - 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") + 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') - if speed > 1: - overlays += [ - f"setpts=PTS/{speed}", - "fps=60", - ] - ffmpeg_cmd = [ +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), @@ -237,39 +272,214 @@ def clip( out, ] - replay_cmd = [REPLAY, '--ecam', '-c', '1', '-s', str(begin_at), '--prefix', prefix] + +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): + # 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. + procs = [ui_proc, replay_proc] + logger.info('waiting for replay to begin (loading segments, may take a while)...') + wait_for_frames(procs) + logger.info(f'recording in progress ({duration}s)...') + started_at = time.monotonic() + time.sleep(SECONDS_TO_WARM + duration + RECORD_TAIL_MARGIN) + 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) + recorded_seconds = time.monotonic() - started_at + 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'], +): + 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", + ] + + # cache enough of the ~60s segments to cover the clip, else replay runs dry partway through + # and loops back on itself, repeating earlier footage + segments_to_cache = math.ceil((end - begin_at) / 60) + 1 + replay_cmd = [REPLAY, '--ecam', '-c', str(segments_to_cache), '-s', str(begin_at), '--prefix', prefix] if data_dir: replay_cmd.extend(['--data_dir', data_dir]) if quality == 'low': replay_cmd.append('--qcam') replay_cmd.append(route.name.canonical_name) - ui_cmd = [UI, '-platform', 'xcb'] - xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}'] + 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. + env['FPS'] = str(FRAMERATE) - with managed_proc(xvfb_cmd, env) as xvfb_proc, managed_proc(ui_cmd, env) as ui_proc, managed_proc(replay_cmd, env) as replay_proc: - procs = [xvfb_proc, ui_proc, replay_proc] - 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) - with managed_proc(ffmpeg_cmd, env) as ffmpeg_proc: - procs.append(ffmpeg_proc) - logger.info(f'recording in progress ({duration}s)...') - ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS) - check_for_failure(procs) - logger.info(f'recording complete: {Path(out).resolve()}') + 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) + 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) + 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') - validate_env(p) 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') @@ -282,7 +492,11 @@ def main(): 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 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') args = parse_args(p) + validate_env(p, args.ui) exit_code = 1 try: clip( @@ -296,6 +510,7 @@ def main(): speed=args.speed, target_mb=args.file_size, title=args.title, + ui=args.ui, ) exit_code = 0 except KeyboardInterrupt as e: