feat(clip): title and metadata overlay (#35099)

* wip

* moar

* ensure inter is installed

* line len

* refactor

* dont need this

* no longer than

* show meta for 4s
pull/35123/head
Trey Moen 5 days ago committed by GitHub
parent 8ee99523f4
commit f704d18a8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 64
      tools/clip/run.py
  2. 1
      tools/install_ubuntu_dependencies.sh
  3. 7
      tools/lib/route.py

@ -16,7 +16,7 @@ from typing import Literal
from cereal.messaging import SubMaster
from openpilot.common.basedir import BASEDIR
from openpilot.common.prefix import OpenpilotPrefix
from openpilot.tools.lib.route import SegmentRange, get_max_seg_number_cached
from openpilot.tools.lib.route import Route
DEFAULT_OUTPUT = 'output.mp4'
DEMO_START = 90
@ -26,7 +26,7 @@ FRAMERATE = 20
PIXEL_DEPTH = '24'
RESOLUTION = '2160x1080'
SECONDS_TO_WARM = 2
PROC_WAIT_SECONDS = 5
PROC_WAIT_SECONDS = 30
REPLAY = str(Path(BASEDIR, 'tools/replay/replay').resolve())
UI = str(Path(BASEDIR, 'selfdrive/ui/ui').resolve())
@ -52,6 +52,29 @@ def check_for_failure(proc: Popen):
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_meta_text(route: Route):
metadata = route.get_metadata()
origin_parts = metadata['git_remote'].split('/')
origin = origin_parts[3] if len(origin_parts) > 3 else 'unknown'
return ', '.join([
f'openpilot v{metadata['version']}',
f'route: {metadata['fullname']}',
f'car: {metadata['platform']}',
f'origin: {origin}',
f'branch: {metadata['git_branch']}',
f'commit: {metadata['git_commit'][:7]}',
f'modified: {str(metadata['git_dirty']).lower()}',
])
def parse_args(parser: ArgumentParser):
args = parser.parse_args()
if args.demo:
@ -74,16 +97,13 @@ def parse_args(parser: ArgumentParser):
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')
# if using local files, don't worry about length check right now so we skip the network call
# TODO: derive segment count from local FS
if not args.data_dir:
try:
num_segs = get_max_seg_number_cached(SegmentRange(args.route))
args.route = Route(args.route, data_dir=args.data_dir)
except Exception as e:
parser.error(f'failed to get route length: {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
length = round(num_segs * 60)
length = round(args.route.max_seg_number * 60)
if args.start >= length:
parser.error(f'start ({args.start}s) cannot be after end of route ({length}s)')
if args.end > length:
@ -119,6 +139,12 @@ def validate_route(route: str):
return route
def validate_title(title: str):
if len(title) > 80:
raise ArgumentTypeError('title must be no longer than 80 chars')
return title
def wait_for_frames(procs: list[Popen]):
sm = SubMaster(['uiDebug'])
no_frames_drawn = True
@ -129,20 +155,27 @@ def wait_for_frames(procs: list[Popen]):
check_for_failure(proc)
def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, route: str, output_filepath: str, start: int, end: int, target_size_mb: int):
logger.info(f'clipping route {route}, start={start} end={end} quality={quality} target_filesize={target_size_mb}MB')
def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, route: Route, out: str, start: int, end: 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')
begin_at = max(start - SECONDS_TO_WARM, 0)
duration = end - start
bit_rate_kbps = int(round(target_size_mb * 8 * 1024 * 1024 / duration / 1000))
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)}'
meta_text = get_meta_text(route)
overlays = [
f"drawtext=text='{escape_ffmpeg_text(meta_text)}':fontfile=Inter.tff:fontcolor=white:fontsize=18:box=1:boxcolor=black@0.33:boxborderw=7:x=(w-text_w)/2:y=5.5:enable='between(t,1,5)'"
]
if title:
overlays.append(f"drawtext=text='{escape_ffmpeg_text(title)}':fontfile=Inter.tff:fontcolor=white:fontsize=32:box=1:boxcolor=black@0.33:boxborderw=10:x=(w-text_w)/2:y=53")
ffmpeg_cmd = [
'ffmpeg', '-y', '-video_size', RESOLUTION, '-framerate', str(FRAMERATE), '-f', 'x11grab', '-draw_mouse', '0',
'-i', display, '-c:v', 'libx264', '-maxrate', f'{bit_rate_kbps}k', '-bufsize', f'{bit_rate_kbps*2}k', '-crf', '23',
'-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-f', 'mp4', '-t', str(duration), output_filepath,
'-filter:v', ','.join(overlays), '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-f', 'mp4', '-t', str(duration), out
]
replay_cmd = [REPLAY, '-c', '1', '-s', str(begin_at), '--prefix', prefix]
@ -150,7 +183,7 @@ def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, rou
replay_cmd.extend(['--data_dir', data_dir])
if quality == 'low':
replay_cmd.append('--qcam')
replay_cmd.append(route)
replay_cmd.append(route.name.canonical_name)
ui_cmd = [UI, '-platform', 'xcb']
xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}']
@ -183,7 +216,7 @@ def clip(data_dir: str | None, quality: Literal['low', 'high'], prefix: str, rou
ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS)
for proc in procs:
check_for_failure(proc)
logger.info(f'recording complete: {Path(output_filepath).resolve()}')
logger.info(f'recording complete: {Path(out).resolve()}')
def main():
@ -199,9 +232,10 @@ def main():
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('-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)
args = parse_args(p)
try:
clip(args.data_dir, args.quality, args.prefix, args.route, args.output, args.start, args.end, args.file_size)
clip(args.data_dir, args.quality, args.prefix, args.route, args.output, args.start, args.end, args.file_size, args.title)
except KeyboardInterrupt as e:
logger.exception('interrupted by user', exc_info=e)
except Exception as e:

@ -33,6 +33,7 @@ function install_ubuntu_common_requirements() {
git \
git-lfs \
ffmpeg \
fonts-inter \
libavformat-dev \
libavcodec-dev \
libavdevice-dev \

@ -21,6 +21,7 @@ class Route:
def __init__(self, name, data_dir=None):
self._name = RouteName(name)
self.files = None
self.metadata = None
if data_dir is not None:
self._segments = self._get_segments_local(data_dir)
else:
@ -59,6 +60,12 @@ class Route:
qcamera_path_by_seg_num = {s.name.segment_num: s.qcamera_path for s in self._segments}
return [qcamera_path_by_seg_num.get(i, None) for i in range(self.max_seg_number + 1)]
def get_metadata(self):
if not self.metadata:
api = CommaApi(get_token())
self.metadata = api.get('v1/route/' + self.name.canonical_name)
return self.metadata
# TODO: refactor this, it's super repetitive
def _get_segments_remote(self):
api = CommaApi(get_token())

Loading…
Cancel
Save