mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-08 01:45:54 +08:00
MP4 Export for Raybig on WSL
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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...]`
|
||||
|
||||
@@ -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.
|
||||
+277
-62
@@ -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 <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')
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user