Files
2026-07-20 11:06:57 -05:00

1018 lines
40 KiB
Python

from __future__ import annotations
import os
import shutil
import signal
import subprocess
import tempfile
import threading
import time
import numpy as np
import pyray as rl
from collections.abc import Callable
from dataclasses import dataclass
from openpilot.selfdrive.ui.lib.local_routes import local_route_audio_paths
from openpilot.selfdrive.ui.lib import cloud_routes_shim as cloud
from openpilot.selfdrive.ui.ui_state import device
from openpilot.selfdrive.ui.widgets.screen_header import ScreenHeader, HEADER_HEIGHT
from openpilot.selfdrive.ui.lib.local_routes import (
CAMERA_LABELS,
compute_route_distance_miles,
get_local_route,
local_route_camera_paths,
)
from openpilot.system.ui.lib.application import gui_app, FontWeight, MousePos
from openpilot.system.ui.lib.multilang import tr
from openpilot.system.ui.lib.text_measure import measure_text_cached
from openpilot.system.ui.widgets import Widget
MARGIN = 40
SPACING = 25
PLAY_R = 70
SKIP_R = 56
SKIP_OFFSET = PLAY_R + 64 + SKIP_R
SHARE_R = 40
CAM_TAB_H = 66
CAM_TAB_GAP = 14
AUTOHIDE_S = 3.0 # seconds of no touch before the transport + scrubber fade out
STALE_FRAME_TOL = 120 # frames: accept a decoded frame only if within this of the playhead
SCREEN_BG = rl.Color(14, 15, 18, 255)
SCREEN_BORDER = rl.Color(255, 255, 255, 26)
TEAL = rl.Color(16, 185, 169, 255)
GLYPH_DARK = rl.Color(8, 16, 16, 255)
MUTED = rl.Color(160, 160, 165, 255)
TRACK_BG = rl.Color(50, 53, 60, 255)
BTN_BG = rl.Color(38, 40, 46, 255)
SKIP_BG = rl.Color(26, 28, 33, 210)
VIDEO_FPS = 20.0
NOMINAL_SEGMENT_FRAMES = int(VIDEO_FPS * 60)
def _find_bin(name: str) -> str | None:
"""Locate a helper binary robustly — the UI process's PATH may not include /usr/local/bin."""
found = shutil.which(name)
if found:
return found
for p in (f"/usr/local/bin/{name}", f"/usr/bin/{name}"):
if os.path.exists(p):
return p
return None
def _find_hwdec() -> str | None:
"""The Venus HW HEVC decoder (built only on-device). Absent on PC/open-source -> ffmpeg SW."""
try:
from openpilot.common.basedir import BASEDIR
path = os.path.join(BASEDIR, "system", "loggerd", "encoder", "v4l_decode")
return path if os.path.exists(path) else None
except Exception:
return None
@dataclass
class _DecodedFrame:
global_frame: int
frame: np.ndarray
# Cap the read-ahead buffer. Kept modest (~64MB, ~1.5s at 20fps) rather than large: this device's
# offroad memory headroom is thin, and an oversized buffer left playback prone to stalling when a
# periodic background task spiked memory during long sessions.
PREBUFFER_BYTES = 64_000_000
SEEK_AHEAD_FRAMES = 150 # target jumps beyond this restart ffmpeg instead of waiting
class _CloudProxy:
"""Local TLS-terminating proxy for cloud playback: the device ffmpeg has no https/tls protocol,
so signed konn3kt segment URLs are served to it as http://127.0.0.1:{port}/{index} and this proxy
streams the upstream https body through (Range passthrough for seeks)."""
def __init__(self, urls: list[str]):
import http.server
import requests
proxy = self
self._session = requests.Session() # TLS/conn reuse across range requests (seek bisection)
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def do_GET(self):
try:
idx = int(self.path.strip("/").split("?")[0])
url = proxy._urls[idx]
except Exception:
self.send_error(404)
return
headers = {}
rng = self.headers.get("Range")
if rng:
headers["Range"] = rng
try:
up = proxy._session.get(url, headers=headers, stream=True, timeout=20)
except Exception:
self.send_error(502)
return
try:
self.send_response(up.status_code)
for h in ("Content-Length", "Content-Range", "Accept-Ranges", "Content-Type"):
if h in up.headers:
self.send_header(h, up.headers[h])
self.end_headers()
for chunk in up.iter_content(chunk_size=65536):
if chunk:
self.wfile.write(chunk)
except Exception:
pass
finally:
up.close()
self._urls = list(urls)
self._server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
self._server.daemon_threads = True
self.port = self._server.server_address[1]
threading.Thread(target=self._server.serve_forever, daemon=True).start()
def local_urls(self) -> list[str]:
return [f"http://127.0.0.1:{self.port}/{i}" for i in range(len(self._urls))]
def stop(self) -> None:
try:
self._server.shutdown()
self._server.server_close()
except Exception:
pass
class _StreamFrameWorker:
"""Streams decoded frames sequentially from ffmpeg into a prebuffer for smooth playback.
Handles any source — road qcamera.ts (small, software-decodes at hundreds of fps), wide/driver
full-res .hevc, or cloud HTTP URLs — through ffmpeg's concat demuxer. One long-lived ffmpeg
decodes forward from the playhead while a reader thread fills a bounded frame buffer a few seconds
ahead; small forward moves are served from the buffer, a backward or far-forward seek restarts
ffmpeg at the new offset. Replaces the old per-frame FrameReader/HTTP-seek workers (4s stalls)."""
def __init__(self, sources: list[str], status: str | None = None):
self._proxy: _CloudProxy | None = None
if sources and sources[0].startswith(("http://", "https://")):
self._proxy = _CloudProxy(sources)
sources = self._proxy.local_urls()
self._sources = list(sources)
self._ffmpeg = _find_bin("ffmpeg")
self._ffprobe = _find_bin("ffprobe")
# Hardware-decode full-res .hevc (wide/driver) via the Venus decoder; road qcam .ts stays SW
# (already 270fps). Offroad the Venus decoder + GPU are idle.
self._hwdec = _find_hwdec() if self._sources and self._sources[0].endswith(".hevc") else None
self.total_frames = max(1, len(self._sources) * NOMINAL_SEGMENT_FRAMES)
self._dims: tuple[int, int] | None = None
self._prebuffer = 60
self._playlist = self._write_playlist()
self._hw_playlist: str | None = None # per-seek playlist starting at the target segment (HW path)
self._cv = threading.Condition()
self._status_lock = threading.Lock()
self._buf: dict[int, np.ndarray] = {}
self._base = 0 # global index of the next frame ffmpeg will emit
self._run_start = 0 # global index where the current ffmpeg run began
self._target = 0
self._need_seek = True
self._seek_to = 0
self._stop = False
self._status = status or tr("Loading video")
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def _write_playlist(self, start_idx: int = 0) -> str | None:
srcs = self._sources[start_idx:]
if not srcs:
return None
fd, path = tempfile.mkstemp(suffix=".txt", prefix="iqroute_")
with os.fdopen(fd, "w") as f:
for s in srcs:
f.write("file '%s'\n" % s.replace("'", "'\\''"))
# Tell the concat demuxer each segment's duration so a seek can jump straight to the right
# segment instead of opening every earlier one to measure it (the large-route lag).
f.write("duration %.3f\n" % (NOMINAL_SEGMENT_FRAMES / VIDEO_FPS))
return path
def stop(self) -> None:
with self._cv:
self._stop = True
self._cv.notify_all()
self._thread.join(timeout=1.5)
if self._proxy is not None:
self._proxy.stop()
for pl in (self._playlist, self._hw_playlist):
if pl:
try:
os.unlink(pl)
except OSError:
pass
def request_frame(self, frame_idx: int) -> None:
frame_idx = max(0, min(self.total_frames - 1, int(frame_idx)))
with self._cv:
self._target = frame_idx
# Seek (restart ffmpeg) on a backward move or a big forward jump; otherwise let the
# sequential decode catch up from the buffer.
if frame_idx < self._run_start or frame_idx > self._base + SEEK_AHEAD_FRAMES:
self._need_seek = True
self._seek_to = frame_idx
self._cv.notify_all()
def pop_result(self) -> _DecodedFrame | None:
# Return the newest decoded frame at or before the playhead; if none yet (just after a seek),
# the earliest buffered frame. Lets slow (full-res) cams display their latest frame instead of
# stalling waiting for the exact index.
with self._cv:
if not self._buf:
return None
target = self._target
le = [k for k in self._buf if k <= target]
key = max(le) if le else min(self._buf)
frame = self._buf[key]
return _DecodedFrame(key, frame)
def status(self) -> str:
with self._status_lock:
return self._status
def _set_status(self, status: str) -> None:
with self._status_lock:
self._status = status
def _probe_dims(self) -> tuple[int, int] | None:
if self._dims is not None or self._ffprobe is None or not self._sources:
return self._dims
try:
out = subprocess.run(
[self._ffprobe, "-v", "quiet", "-select_streams", "v:0", "-show_entries",
"stream=width,height", "-of", "csv=p=0:s=x", self._sources[0]],
capture_output=True, text=True, timeout=20).stdout.strip()
# A TS can report the stream twice; take the first non-empty line.
line = next((ln for ln in out.splitlines() if "x" in ln), "")
w, h = (int(v) for v in line.split("x")[:2])
self._dims = (w, h)
except Exception:
self._dims = None
return self._dims
def _spawn(self, start_frame: int) -> subprocess.Popen:
start_s = max(0.0, start_frame / VIDEO_FPS)
if self._hwdec:
# ffmpeg concats + seeks the raw .hevc (bitstream copy, fast), the Venus decoder turns it
# into rgb24 at ~30fps (vs ~12fps software). Seeking a raw-hevc concat to 0 needs no -ss.
import shlex
# Seek on raw HEVC: INPUT -ss is erratic (no real timestamps) and OUTPUT -ss on the whole
# route reads every packet up to the target (minutes on long routes). So start the concat at
# the TARGET SEGMENT (never reads earlier segments) and OUTPUT -ss only the <60s offset within
# it — fast and frame-accurate. dump_extra re-inserts the parameter sets on every keyframe so
# a mid-stream start is decodable.
seg_idx = min(len(self._sources) - 1, int(start_frame // NOMINAL_SEGMENT_FRAMES))
local_s = max(0.0, (start_frame - seg_idx * NOMINAL_SEGMENT_FRAMES) / VIDEO_FPS)
if self._hw_playlist:
try:
os.unlink(self._hw_playlist)
except OSError:
pass
self._hw_playlist = self._write_playlist(seg_idx)
ff = [self._ffmpeg, "-hide_banner", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", self._hw_playlist]
if local_s > 0.05:
ff += ["-ss", f"{local_s:.3f}"]
ff += ["-c:v", "copy", "-bsf:v", "dump_extra", "-f", "hevc", "pipe:1"]
cmd = " ".join(shlex.quote(a) for a in ff) + " | " + shlex.quote(self._hwdec) + " pipe:0"
try:
from openpilot.common.swaglog import cloudlog
cloudlog.warning(f"video_player: HW decode path ({self._hwdec})")
except Exception:
pass
# New session so we can kill the WHOLE pipe (ffmpeg + v4l_decode) as a group; killing just
# the shell would orphan them and leave v4l_decode holding /dev/video32 (-> next decode hangs).
return subprocess.Popen(cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
bufsize=0, start_new_session=True)
cmd = [self._ffmpeg, "-hide_banner", "-loglevel", "error"]
playlist = self._playlist
if self._proxy is not None:
# concat playlists only open local files by default; allow the proxy's http entries.
# Seeking over http bisects with many high-latency range requests, so start the playlist at
# the target segment and only -ss the in-segment remainder (same trick as the HW path).
cmd += ["-protocol_whitelist", "file,http,tcp"]
seg_idx = min(len(self._sources) - 1, int(start_frame // NOMINAL_SEGMENT_FRAMES))
start_s = max(0.0, (start_frame - seg_idx * NOMINAL_SEGMENT_FRAMES) / VIDEO_FPS)
if seg_idx > 0:
if self._hw_playlist:
try:
os.unlink(self._hw_playlist)
except OSError:
pass
self._hw_playlist = self._write_playlist(seg_idx)
playlist = self._hw_playlist
# Seeking a concat of timestamp-less raw .hevc to exactly 0 yields no output; only add -ss for
# real (non-zero) seeks.
if start_s > 0.05:
cmd += ["-ss", f"{start_s:.3f}"]
cmd += ["-f", "concat", "-safe", "0", "-i", playlist,
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"]
return subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0,
start_new_session=True)
@staticmethod
def _kill(proc) -> None:
if proc is None:
return
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL) # whole pipe group
except Exception:
try:
proc.kill()
except Exception:
pass
try:
proc.wait(timeout=1.0) # reap so it doesn't linger as a zombie
except Exception:
pass
@staticmethod
def _read_into(pipe, arr) -> bool:
# Read one frame straight into the numpy buffer — no bytes concat/copy (a ~7MB memcpy/frame
# otherwise, which halved decode throughput).
mv = memoryview(arr).cast("B") # flat byte view so slicing is by bytes, not rows
n = arr.nbytes
got = 0
while got < n:
r = pipe.readinto(mv[got:])
if not r:
return False
got += r
return True
def _run(self) -> None:
if self._ffmpeg is None or self._playlist is None:
self._set_status(tr("Unable to load video"))
return
dims = self._probe_dims()
# retry a couple of times: over cloud streaming a single probe can fail transiently
# (flaky LTE/hotspot), and giving up permanently shows "Unable to load video"
for _ in range(2):
if dims is not None or self._stop:
break
time.sleep(2.0)
dims = self._probe_dims()
if dims is None:
self._set_status(tr("Unable to load video"))
return
w, h = dims
frame_bytes = w * h * 3
self._prebuffer = max(8, min(30, PREBUFFER_BYTES // max(1, frame_bytes)))
proc: subprocess.Popen | None = None
try:
while not self._stop:
with self._cv:
restart = self._need_seek or proc is None
if restart:
self._need_seek = False
seek_to = self._seek_to
if restart:
self._kill(proc)
proc = None
with self._cv:
self._buf.clear()
self._base = seek_to
self._run_start = seek_to
proc = self._spawn(seek_to)
with self._cv:
while (not self._stop and not self._need_seek
and self._base - self._target > self._prebuffer):
self._cv.wait(0.1)
if self._stop or self._need_seek:
continue
frame = np.empty((h, w, 3), dtype=np.uint8)
if not self._read_into(proc.stdout, frame): # end of the concatenated stream
self._set_status("")
with self._cv:
while not self._stop and not self._need_seek:
self._cv.wait(0.2)
continue
with self._cv:
if self._need_seek or self._stop:
continue # a seek landed mid-read; drop this frame and restart
self._buf[self._base] = frame
# Keep the buffer bounded to the most recently decoded frames (they arrive in order).
over = len(self._buf) - (self._prebuffer + 16)
if over > 0:
for k in sorted(self._buf)[:over]:
del self._buf[k]
self._base += 1
self._cv.notify_all()
self._set_status("")
finally:
self._kill(proc)
class _RouteAudioPlayer:
"""Plays a route's audio (from the qcamera.ts segments) synced to the scrubber.
raylib's audio device won't initialize on the comma, so we decode the qcam AAC track with
ffmpeg and pipe raw PCM to aplay (ALSA) — the only working output path on device. Multi-segment
audio is stitched gaplessly with the MPEG-TS `concat:` protocol. Pause/seek tear the pipe down
and (on resume) respawn ffmpeg at the new offset; frame-exact sync isn't needed for a dashcam."""
SAMPLE_RATE = 48000
CHANNELS = 2
def __init__(self, ts_paths: list[str]):
self._ts_paths = list(ts_paths)
self._ffmpeg = _find_bin("ffmpeg")
self._aplay = _find_bin("aplay")
self._ff: subprocess.Popen | None = None
self._ap: subprocess.Popen | None = None
self._lock = threading.Lock()
@property
def available(self) -> bool:
return bool(self._ts_paths and self._ffmpeg and self._aplay)
def _kill(self) -> None:
for proc in (self._ap, self._ff):
if proc is not None:
try:
proc.kill()
except Exception:
pass
self._ff = self._ap = None
def stop(self) -> None:
with self._lock:
self._kill()
def play_from(self, start_s: float) -> None:
if not self.available:
return
with self._lock:
self._kill()
concat = "concat:" + "|".join(self._ts_paths)
try:
self._ff = subprocess.Popen(
[self._ffmpeg, "-v", "quiet", "-ss", f"{max(0.0, start_s):.3f}", "-i", concat,
"-vn", "-f", "s16le", "-ar", str(self.SAMPLE_RATE), "-ac", str(self.CHANNELS), "-"],
stdout=subprocess.PIPE)
self._ap = subprocess.Popen(
[self._aplay, "-q", "-f", "S16_LE", "-c", str(self.CHANNELS), "-r", str(self.SAMPLE_RATE)],
stdin=self._ff.stdout)
if self._ff.stdout is not None:
self._ff.stdout.close() # aplay owns the read end of the pipe now
except Exception:
self._kill()
class VideoPlayerLayout(Widget):
"""Offroad route video player for local road-camera recordings."""
def __init__(self):
super().__init__()
self._header = self._child(ScreenHeader(tr("Video Player")))
self._play_icon = gui_app.texture("icons/iq/play.png", 70, 70, keep_aspect_ratio=True)
self._skip_back_icon = gui_app.texture("icons/iq/rotate-ccw.png", 78, 78, keep_aspect_ratio=True)
self._skip_fwd_icon = gui_app.texture("icons/iq/rotate-cw.png", 78, 78, keep_aspect_ratio=True)
self._route: str | None = None
self._playing = False
self._progress = 0.0
self._duration = 1.0
# Camera selection (Road / Wide / Driver) — only cameras actually recorded are shown.
self._cameras: tuple[str, ...] = ()
self._camera: str = "road"
self._cam_tab_rects: list[tuple[rl.Rectangle, str]] = []
# Cloud streaming (routes overwritten/deleted on device): road cam only, decoded over HTTP.
self._is_cloud: bool = False
self._cloud_fullname: str = ""
self._dongle: str | None = None
# Route metadata for the info line (date/time from the header, plus length + miles here).
self._meta_label: str = ""
self._distance_miles: float | None = None
self._miles_thread: threading.Thread | None = None
self._miles_route: str | None = None
self._speed = 1.0
self._speed_rect = rl.Rectangle(0, 0, 0, 0)
self._play_rect = rl.Rectangle(0, 0, 0, 0)
self._skip_back_rect = rl.Rectangle(0, 0, 0, 0)
self._skip_fwd_rect = rl.Rectangle(0, 0, 0, 0)
self._share_rect = rl.Rectangle(0, 0, 0, 0)
self._track_x0 = 0.0
self._track_w = 1.0
self._track_rect = rl.Rectangle(0, 0, 0, 0)
self._dragging = False
self._was_down = False
self._on_share_cb: Callable[[], None] | None = None
self._audio: _RouteAudioPlayer | None = None
self._worker: _StreamFrameWorker | None = None
self._texture: rl.Texture | None = None
self._texture_size: tuple[int, int] = (0, 0)
self._status = ""
self._last_playback_t = 0.0
# Auto-hide overlay controls (center transport + scrubber) after a few seconds of no touch.
self._last_interaction = 0.0
self._press_revealed = False
# Loading state for the current camera (fresh full-res HEVC takes a beat to open/index).
self._loading = False
# Newest decoded global frame index — used to pace playback to the decoder (so a slow full-res
# cam plays at its real rate instead of the scrubber racing ahead of the picture).
self._last_decoded_idx = -1
self._last_uploaded_idx = -1
def set_on_back(self, cb: Callable[[], None]) -> None:
self._header.set_on_back(cb)
def set_on_share(self, cb: Callable[[], None]) -> None:
self._on_share_cb = cb
def set_route(self, route: str | None) -> None:
self._stop_worker()
self._stop_audio()
self._clear_texture()
self._route = route
self._playing = False
self._progress = 0.0
self._status = ""
self._last_playback_t = rl.get_time()
self._cameras = ()
self._cam_tab_rects = []
self._distance_miles = None
if not route:
self._header.set_title(tr("Video Player"))
self._meta_label = ""
return
info = get_local_route(route)
self._is_cloud = info is None
if info is not None:
self._cameras = info.cameras
self._header.set_title(info.label)
self._meta_label = self._fmt(info.duration_s)
self._duration = max(1.0, info.duration_s)
self._audio = _RouteAudioPlayer(local_route_audio_paths(route))
self._start_miles_computation(route)
else:
# Not on device — stream the road cam from konn3kt. Reconstruct the cloud fullname
# (dongle|count--uid) from the canonical route name.
self._dongle = cloud.get_dongle_id()
self._cloud_fullname = f"{self._dongle}|{route}" if self._dongle else route
self._cameras = ("road",)
self._header.set_title(route)
self._meta_label = tr("Cloud only")
self._audio = None # cloud audio streaming is a follow-up
self._camera = "road" if "road" in self._cameras else (self._cameras[0] if self._cameras else "road")
self._load_camera(preserve_progress=False)
def _stop_audio(self) -> None:
if self._audio is not None:
self._audio.stop()
def _sync_audio(self) -> None:
"""Start/stop audio to match play state + position. Only called at state transitions
(play/pause, seek, drag end) — never per-frame — since each start respawns ffmpeg."""
if self._audio is None:
return
if self._playing and not self._dragging:
self._audio.play_from(self._progress * self._duration)
else:
self._audio.stop()
def _load_camera(self, preserve_progress: bool = True) -> None:
self._stop_worker()
self._last_decoded_idx = -1
self._last_uploaded_idx = -1
if not preserve_progress:
self._progress = 0.0
if not self._route:
return
if self._is_cloud:
urls = cloud.cloud_route_camera_urls(self._dongle, self._cloud_fullname, self._camera) if self._dongle else []
if not urls:
self._status = tr("Cloud video unavailable")
return
self._status = ""
self._loading = True
self._worker = _StreamFrameWorker(urls, status=tr("Streaming from cloud"))
self._duration = max(1.0, self._worker.total_frames / VIDEO_FPS)
self._request_current_frame()
return
camera_paths = local_route_camera_paths(self._route, self._camera)
if not camera_paths:
self._status = tr("No video for this camera")
return
self._status = ""
self._loading = True
self._worker = _StreamFrameWorker(camera_paths)
self._duration = max(1.0, self._worker.total_frames / VIDEO_FPS)
self._request_current_frame()
def _select_camera(self, camera: str) -> None:
if camera == self._camera or camera not in self._cameras:
return
self._camera = camera
self._clear_texture()
self._loading = True
self._load_camera(preserve_progress=True)
def _start_miles_computation(self, route: str) -> None:
# Integrating vEgo over the qlogs is slow (reads every segment), so compute off the UI thread.
self._miles_route = route
def _worker():
try:
miles = compute_route_distance_miles(route)
except Exception:
miles = None
if self._miles_route == route:
self._distance_miles = miles
self._miles_thread = threading.Thread(target=_worker, daemon=True)
self._miles_thread.start()
def show_event(self):
super().show_event()
# Watching a recording is active use — suppress the offroad inactivity timeout that would
# otherwise blank the screen / bounce back to home mid-playback.
device.set_override_interactive_timeout(24 * 60 * 60)
def hide_event(self):
super().hide_event()
self._stop_worker()
self._stop_audio()
device.set_override_interactive_timeout(None)
def _stop_worker(self) -> None:
if self._worker is not None:
self._worker.stop()
self._worker = None
def _clear_texture(self) -> None:
if self._texture is not None:
rl.unload_texture(self._texture)
self._texture = None
self._texture_size = (0, 0)
def _request_current_frame(self) -> None:
if self._worker is None:
return
frame_idx = int(round(self._progress * max(0, self._worker.total_frames - 1)))
self._worker.request_frame(frame_idx)
def _consume_decoded_frame(self) -> None:
if self._worker is None:
return
result = self._worker.pop_result()
if result is None:
return
# Ignore stale frames from before a seek (the worker briefly still holds the old position's
# buffer). Accepting them would drag _last_decoded_idx — and the pace clamp — back, which made
# the scrubber snap to where it was. Only accept frames near the current playhead.
total = max(1, self._worker.total_frames)
target_frame = self._progress * (total - 1)
if abs(result.global_frame - target_frame) > STALE_FRAME_TOL:
return
# Don't re-upload the same frame: _render runs at 60fps but frames only change ~15-20x/sec.
# Uploading a ~7MB texture every render frame was ~400MB/s of wasted GPU bandwidth, starving
# the decode. Only upload when the frame actually advances.
if result.global_frame == self._last_uploaded_idx:
return
frame = result.frame
height, width = frame.shape[:2]
if self._texture is None or self._texture_size != (width, height):
if self._texture is not None:
rl.unload_texture(self._texture)
image = rl.Image(None, width, height, 1, rl.PixelFormat.PIXELFORMAT_UNCOMPRESSED_R8G8B8)
self._texture = rl.load_texture_from_image(image)
rl.set_texture_filter(self._texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
self._texture_size = (width, height)
rl.update_texture(self._texture, rl.ffi.cast("void *", frame.ctypes.data))
self._loading = False
self._last_decoded_idx = result.global_frame
self._last_uploaded_idx = result.global_frame
def _advance_playback(self) -> None:
now = rl.get_time()
if not self._playing or self._dragging:
self._last_playback_t = now
return
dt = max(0.0, now - self._last_playback_t) * self._speed
self._last_playback_t = now
self._progress = min(1.0, self._progress + dt / max(1.0, self._duration))
# Don't let the playhead outrun the decoder (keeps the picture in step on slow full-res cams).
if self._worker is not None and self._last_decoded_idx >= 0:
cap = (self._last_decoded_idx + 12) / max(1, self._worker.total_frames)
if self._progress > cap:
self._progress = cap
if self._progress >= 1.0:
self._playing = False
self._stop_audio()
self._request_current_frame()
@staticmethod
def _fmt(seconds: float) -> str:
s = max(0, int(seconds))
return f"{s // 60}:{s % 60:02d}"
def _controls_visible(self, now: float) -> bool:
# Transport + scrubber stay up while paused or shortly after the last touch, then fade away.
return (not self._playing) or (now - self._last_interaction < AUTOHIDE_S)
def _note_interaction(self) -> None:
self._last_interaction = rl.get_time()
@staticmethod
def _draw_dotted(font, parts: list[str], x: float, y: float, size: int, color) -> None:
"""Draw text segments joined by a small drawn dot (the font atlas lacks the '·' glyph)."""
cx = x
for i, part in enumerate(parts):
if i > 0:
cx += 10
rl.draw_circle(int(cx), int(y + size / 2), 3, rl.Color(color.r, color.g, color.b, 150))
cx += 16
rl.draw_text_ex(font, part, rl.Vector2(int(cx), int(y)), size, 0, color)
cx += measure_text_cached(font, part, size).x
def _meta_parts(self) -> list[str]:
parts: list[str] = []
if self._meta_label:
parts.append(self._meta_label)
if self._distance_miles is not None and self._distance_miles >= 0.05:
parts.append(f"{self._distance_miles:.1f} mi")
elif self._distance_miles is None and self._route and not self._is_cloud:
parts.append(tr("calculating miles..."))
return parts
def _render(self, rect: rl.Rectangle):
now = rl.get_time()
# Keep the screen awake the whole time the viewer is on screen — reset every frame because the
# offroad wakefulness timer only otherwise resets on a physical touch (show_event alone wasn't
# enough). Restored to the normal timeout in hide_event.
device.set_override_interactive_timeout(24 * 60 * 60)
self._advance_playback()
self._consume_decoded_frame()
# 1. Full-bleed video (cover the whole surface, crop overflow).
rl.draw_rectangle_rec(rect, rl.Color(6, 7, 9, 255))
self._draw_video_cover(rect)
controls = self._controls_visible(now)
# 2. Top scrim + header (back / date / share) + metadata + camera tabs. Always visible so the
# user can navigate; the scrim keeps text legible over bright footage.
rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y), int(rect.width), 240,
rl.Color(0, 0, 0, 200), rl.Color(0, 0, 0, 0))
header_rect = rl.Rectangle(rect.x + MARGIN, rect.y + MARGIN, rect.width - 2 * MARGIN, HEADER_HEIGHT)
self._header.render(header_rect)
self._render_share(header_rect)
self._render_speed(header_rect)
meta_y = header_rect.y + HEADER_HEIGHT + 6
parts = self._meta_parts()
if parts:
self._draw_dotted(gui_app.font(FontWeight.MEDIUM), parts, rect.x + MARGIN + 4, meta_y, 28, MUTED)
self._render_cam_tabs(rect.x + MARGIN, meta_y + 40)
# 3. Loading spinner while the (fresh full-res) camera opens.
if self._texture is None:
self._draw_center_spinner(rect, now)
# 4. Center transport (auto-hides) — only once there's video to control.
cx = rect.x + rect.width / 2
cy = rect.y + rect.height / 2
self._skip_back_rect = self._circle_rect(cx - SKIP_OFFSET, cy, SKIP_R)
self._skip_fwd_rect = self._circle_rect(cx + SKIP_OFFSET, cy, SKIP_R)
self._play_rect = self._circle_rect(cx, cy, PLAY_R)
if controls and self._texture is not None:
self._draw_skip(cx - SKIP_OFFSET, cy, SKIP_R, forward=False)
self._draw_skip(cx + SKIP_OFFSET, cy, SKIP_R, forward=True)
self._draw_play(cx, cy)
# 5. Bottom scrim + scrubber (auto-hides).
bar_y = rect.y + rect.height - MARGIN - 20
if controls:
rl.draw_rectangle_gradient_v(int(rect.x), int(rect.y + rect.height - 200), int(rect.width), 200,
rl.Color(0, 0, 0, 0), rl.Color(0, 0, 0, 210))
self._render_scrub(rect.x + MARGIN, rect.width - 2 * MARGIN, bar_y)
else:
# Keep the hit-track current so a drag can still start, just don't paint it.
self._track_rect = rl.Rectangle(0, 0, 0, 0)
self._update_drag()
def _circle_rect(self, cx, cy, r):
return rl.Rectangle(cx - r, cy - r, r * 2, r * 2)
def _draw_video_cover(self, rect: rl.Rectangle) -> None:
if self._texture is None:
return
width, height = self._texture_size
if width <= 0 or height <= 0:
return
# Cover: scale so the frame fills the surface, cropping the overflow instead of letterboxing.
scale = max(rect.width / width, rect.height / height)
crop_w = rect.width / scale
crop_h = rect.height / scale
src = rl.Rectangle((width - crop_w) / 2, (height - crop_h) / 2, crop_w, crop_h)
rl.draw_texture_pro(self._texture, src, rect, rl.Vector2(0, 0), 0.0, rl.WHITE)
def _draw_center_spinner(self, rect: rl.Rectangle, now: float) -> None:
cx = rect.x + rect.width / 2
cy = rect.y + rect.height / 2
a = (now * 280) % 360
rl.draw_ring(rl.Vector2(cx, cy), 34, 42, 0, 360, 48, rl.Color(255, 255, 255, 35))
rl.draw_ring(rl.Vector2(cx, cy), 34, 42, a, a + 90, 24, TEAL)
label = self._status or (self._worker.status() if self._worker is not None else "") \
or tr("Loading %s") % CAMERA_LABELS.get(self._camera, "")
if label:
f = gui_app.font(FontWeight.MEDIUM)
ts = measure_text_cached(f, label, 30)
rl.draw_text_ex(f, label, rl.Vector2(int(cx - ts.x / 2), int(cy + 64)), 30, 0, rl.Color(220, 224, 230, 255))
def _render_cam_tabs(self, x: float, y: float) -> None:
self._cam_tab_rects = []
if len(self._cameras) <= 1:
return
font = gui_app.font(FontWeight.MEDIUM)
fs = 28
pad = 24
tx = x
for cam in self._cameras:
label = CAMERA_LABELS.get(cam, cam)
tw = measure_text_cached(font, label, fs).x + pad * 2
tab = rl.Rectangle(tx, y, tw, CAM_TAB_H)
selected = cam == self._camera
rl.draw_rectangle_rounded(tab, 0.5, 16, TEAL if selected else rl.Color(30, 32, 38, 220))
rl.draw_text_ex(font, label, rl.Vector2(int(tx + pad), int(y + (CAM_TAB_H - fs) / 2)), fs, 0,
GLYPH_DARK if selected else rl.Color(226, 230, 236, 255))
self._cam_tab_rects.append((tab, cam))
tx += tw + CAM_TAB_GAP
def _draw_play(self, cx, cy):
rl.draw_circle(int(cx), int(cy), PLAY_R + 4, rl.Color(0, 0, 0, 70))
rl.draw_circle(int(cx), int(cy), PLAY_R, TEAL)
if self._playing:
bar_w, bar_h, gap = 15, 58, 15
rl.draw_rectangle_rounded(rl.Rectangle(cx - gap / 2 - bar_w, cy - bar_h / 2, bar_w, bar_h), 0.4, 6, GLYPH_DARK)
rl.draw_rectangle_rounded(rl.Rectangle(cx + gap / 2, cy - bar_h / 2, bar_w, bar_h), 0.4, 6, GLYPH_DARK)
else:
rl.draw_texture(self._play_icon, int(cx - self._play_icon.width / 2 + 5), int(cy - self._play_icon.height / 2),
GLYPH_DARK)
def _draw_skip(self, cx, cy, r, forward: bool):
rl.draw_circle(int(cx), int(cy), r, rl.Color(20, 22, 27, 190))
icon = self._skip_fwd_icon if forward else self._skip_back_icon
rl.draw_texture(icon, int(cx - icon.width / 2), int(cy - icon.height / 2), rl.WHITE)
f = gui_app.font(FontWeight.BOLD)
ts = measure_text_cached(f, "10", 26)
rl.draw_text_ex(f, "10", rl.Vector2(cx - ts.x / 2, cy - ts.y / 2 + 2), 26, 0, rl.WHITE)
def _render_speed(self, header_rect: rl.Rectangle):
# Speed / fast-forward pill, left of the share button. Tap to cycle 1x -> 2x -> 4x -> 8x.
cy = header_rect.y + HEADER_HEIGHT / 2
font = gui_app.font(FontWeight.BOLD)
label = f"{self._speed:g}x"
fs = 30
w = measure_text_cached(font, label, fs).x + 44
x = header_rect.x + header_rect.width - 2 * SHARE_R - 20 - w
self._speed_rect = rl.Rectangle(x, cy - 34, w, 68)
active = self._speed > 1.0
rl.draw_rectangle_rounded(self._speed_rect, 0.5, 12, TEAL if active else rl.Color(32, 34, 40, 210))
tw = measure_text_cached(font, label, fs).x
rl.draw_text_ex(font, label, rl.Vector2(int(x + (w - tw) / 2), int(cy - fs / 2)), fs, 0,
GLYPH_DARK if active else rl.WHITE)
def _render_share(self, header_rect: rl.Rectangle):
sx = header_rect.x + header_rect.width - SHARE_R
sy = header_rect.y + HEADER_HEIGHT / 2
self._share_rect = self._circle_rect(sx, sy, SHARE_R)
is_pressed = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT) and rl.check_collision_point_rec(rl.get_mouse_position(), self._share_rect)
rl.draw_circle(int(sx), int(sy), SHARE_R, rl.Color(50, 53, 61, 235) if is_pressed else rl.Color(32, 34, 40, 200))
nr = 6
left = rl.Vector2(sx - 13, sy)
top = rl.Vector2(sx + 13, sy - 14)
bot = rl.Vector2(sx + 13, sy + 14)
rl.draw_line_ex(left, top, 3, TEAL)
rl.draw_line_ex(left, bot, 3, TEAL)
for p in (left, top, bot):
rl.draw_circle(int(p.x), int(p.y), nr, TEAL)
def _render_scrub(self, x, w, bar_y):
font = gui_app.font(FontWeight.MEDIUM)
fs = 30
left = self._fmt(self._progress * self._duration)
right = "-" + self._fmt((1.0 - self._progress) * self._duration)
lw = measure_text_cached(font, left, fs)
rw = measure_text_cached(font, right, fs)
self._track_x0 = x + lw.x + 24
track_x1 = x + w - rw.x - 24
self._track_w = max(1.0, track_x1 - self._track_x0)
rl.draw_text_ex(font, left, rl.Vector2(x, bar_y - fs / 2), fs, 0, rl.WHITE)
rl.draw_text_ex(font, right, rl.Vector2(x + w - rw.x, bar_y - fs / 2), fs, 0, rl.Color(200, 204, 210, 255))
rl.draw_rectangle_rounded(rl.Rectangle(self._track_x0, bar_y - 4, self._track_w, 8), 1.0, 8, rl.Color(255, 255, 255, 60))
fill = self._track_w * self._progress
if fill > 0:
rl.draw_rectangle_rounded(rl.Rectangle(self._track_x0, bar_y - 4, fill, 8), 1.0, 8, TEAL)
hx = int(self._track_x0 + fill)
if self._dragging:
rl.draw_circle(hx, int(bar_y), 22, rl.Color(16, 185, 169, 70))
rl.draw_circle(hx, int(bar_y), 17, rl.WHITE)
else:
rl.draw_circle(hx, int(bar_y), 13, rl.WHITE)
self._track_rect = rl.Rectangle(self._track_x0 - 20, bar_y - 34, self._track_w + 40, 68)
def _reset_pace_anchor(self) -> None:
# After an explicit seek, move the decode-pace anchor to the new spot so _advance_playback
# doesn't clamp the playhead back to the pre-seek frame (made scrubbing feel stuck).
if self._worker is not None:
self._last_decoded_idx = int(self._progress * max(0, self._worker.total_frames - 1))
def _seek(self, delta_s: float):
self._progress = min(1.0, max(0.0, self._progress + delta_s / self._duration))
self._reset_pace_anchor()
self._request_current_frame()
self._sync_audio()
def _update_drag(self):
down = rl.is_mouse_button_down(rl.MouseButton.MOUSE_BUTTON_LEFT)
mp = rl.get_mouse_position()
if down and not self._was_down and rl.check_collision_point_rec(mp, self._track_rect):
self._dragging = True
self._note_interaction()
self._stop_audio()
if not down:
was_dragging = self._dragging
self._dragging = False
if was_dragging:
# Seek once on release — re-seeking every drag frame just thrashes ffmpeg so nothing decodes.
self._note_interaction()
self._reset_pace_anchor()
self._request_current_frame()
self._sync_audio()
if self._dragging:
# Move the handle live for feedback; the actual seek happens on release.
self._progress = min(1.0, max(0.0, (mp.x - self._track_x0) / self._track_w))
self._note_interaction()
self._was_down = down
def _handle_mouse_press(self, mouse_pos: MousePos):
# A tap while the transport is hidden just reveals it; it shouldn't also fire a control.
self._press_revealed = not self._controls_visible(rl.get_time())
self._note_interaction()
def _handle_mouse_release(self, mouse_pos: MousePos):
if self._dragging:
return
self._note_interaction()
# Camera tabs and header controls are always live.
for tab, cam in self._cam_tab_rects:
if rl.check_collision_point_rec(mouse_pos, tab):
self._select_camera(cam)
return
if rl.check_collision_point_rec(mouse_pos, self._share_rect):
if self._on_share_cb is not None:
self._on_share_cb()
return
if rl.check_collision_point_rec(mouse_pos, self._speed_rect):
speeds = [1.0, 2.0, 4.0, 8.0]
idx = speeds.index(self._speed) if self._speed in speeds else 0
self._speed = speeds[(idx + 1) % len(speeds)]
return
# If this tap only revealed the hidden transport, don't also trigger it.
if self._press_revealed:
return
if rl.check_collision_point_rec(mouse_pos, self._play_rect):
self._playing = not self._playing
self._sync_audio()
elif rl.check_collision_point_rec(mouse_pos, self._skip_back_rect):
self._seek(-10)
elif rl.check_collision_point_rec(mouse_pos, self._skip_fwd_rect):
self._seek(10)