mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
214 lines
8.7 KiB
Python
214 lines
8.7 KiB
Python
"""A bounded replay MP4, separate from the seekable preview's rolling cache."""
|
|
|
|
import fcntl
|
|
import json
|
|
import math
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import uuid
|
|
|
|
from openpilot.starpilot.replay.media import MediaMux
|
|
|
|
BITRATE_KBPS = 4000
|
|
MAX_EXPORT_BYTES = 32 * 1024 ** 3
|
|
EXPORT_TTL = 30 * 60
|
|
MAX_FRAME_GAP = .5
|
|
|
|
|
|
def disk_budget(directory: Path) -> int:
|
|
usage = shutil.disk_usage(directory)
|
|
reserve = max(5 * 1024 ** 3, math.ceil(usage.total * .1))
|
|
return max(0, min(MAX_EXPORT_BYTES, (usage.free - reserve) // 2))
|
|
|
|
|
|
def export_ranges(status: dict, segment: int | None) -> list[tuple[float, float]]:
|
|
rows = status.get('segmentRanges')
|
|
if not rows:
|
|
raise ValueError('Replay engine needs an update before exporting')
|
|
ranges = []
|
|
found = set()
|
|
previous_end = status['start']
|
|
for row in rows:
|
|
if (type(row) is not list or len(row) != 3 or type(row[0]) is not int or row[0] in found or
|
|
not all(type(v) in (int, float) and math.isfinite(v) for v in row[1:]) or
|
|
not status['start'] <= row[1] < row[2] <= status['end'] or row[1] < previous_end):
|
|
raise ValueError('Invalid recorded segment ranges')
|
|
found.add(row[0])
|
|
previous_end = row[2]
|
|
if segment is None or segment == row[0]:
|
|
begin, end = row[1:]
|
|
if ranges and abs(ranges[-1][1] - begin) < 1e-6:
|
|
ranges[-1] = ranges[-1][0], end
|
|
else:
|
|
ranges.append((begin, end))
|
|
if not ranges:
|
|
raise ValueError('Selected segment has no replayable coverage')
|
|
return ranges
|
|
|
|
|
|
class ExportVideo:
|
|
def __init__(self, directory: Path, width: int, height: int, ranges, budget: int):
|
|
self.directory, self.width, self.height = Path(directory), width, height
|
|
self.ranges, self.budget = ranges, budget
|
|
self.duration = sum(end - begin for begin, end in ranges)
|
|
self.index = 0
|
|
self.elapsed = 0.
|
|
self.frames = self.written = self.disk_check_at = 0
|
|
self.pending = self.mux = None
|
|
self.seen = self.running = self.pausing = False
|
|
self.first_position = self.last_position = None
|
|
self.file = (self.directory / 'export.part').open('xb')
|
|
os.chmod(self.directory / 'export.part', 0o600)
|
|
|
|
@property
|
|
def progress(self):
|
|
current = 0 if self.last_position is None else max(0., self.last_position - self.ranges[self.index][0])
|
|
return min(1., (self.elapsed + current) / self.duration)
|
|
|
|
def write(self, data):
|
|
if self.written + len(data) > self.budget:
|
|
raise OSError('Replay exceeded its export storage budget')
|
|
if self.written >= self.disk_check_at:
|
|
if disk_budget(self.directory) <= 0:
|
|
raise OSError('Not enough free recording storage for this export')
|
|
self.disk_check_at = self.written + 4 * 1024 ** 2
|
|
self.file.write(data)
|
|
self.written += len(data)
|
|
|
|
def frame(self, packet: bytes, *, position: float, keyframe: bool):
|
|
begin, end = self.ranges[self.index]
|
|
if position < begin - 1e-6 or position >= end:
|
|
return
|
|
if not self.seen:
|
|
if not keyframe or position - begin > MAX_FRAME_GAP:
|
|
raise ValueError('Replay could not render the beginning of the selected coverage')
|
|
self.first_position = position
|
|
position = begin
|
|
self.seen = True
|
|
if self.last_position is not None and position <= self.last_position:
|
|
return
|
|
if self.last_position is not None and position - self.last_position > MAX_FRAME_GAP:
|
|
raise ValueError('Replay lost video coverage; export was not completed')
|
|
pts = round((self.elapsed + position - begin) * 1e6)
|
|
if self.mux is None:
|
|
self.mux = MediaMux(self.width, self.height, packet)
|
|
self.write(self.mux.init)
|
|
if self.pending is not None:
|
|
self.flush(pts)
|
|
self.pending = packet, pts, keyframe
|
|
self.last_position = position
|
|
self.frames += 1
|
|
|
|
def flush(self, end_us):
|
|
if self.pending is None:
|
|
raise ValueError('Replay export is missing its final frame')
|
|
packet, pts, keyframe = self.pending
|
|
duration = end_us - pts
|
|
if not 0 < duration <= round(MAX_FRAME_GAP * 1e6):
|
|
raise ValueError('Replay export has incomplete video coverage')
|
|
self.write(self.mux.frame(packet, pts_us=pts, duration_us=duration, keyframe=keyframe))
|
|
self.pending = None
|
|
|
|
def advance(self, engine, status, readback_pending: bool) -> bool:
|
|
begin, end = self.ranges[self.index]
|
|
boundary = status['position'] >= end - 1e-6 or (status.get('state') == 'ended' and end == status['end'])
|
|
if self.seen and not self.running and not boundary:
|
|
engine.command({'op': 'pause', 'paused': False, 'requestId': uuid.uuid4().hex})
|
|
self.running = True
|
|
if not boundary:
|
|
return False
|
|
if not status['paused']:
|
|
if not self.pausing:
|
|
engine.command({'op': 'pause', 'paused': True, 'requestId': uuid.uuid4().hex})
|
|
self.pausing = True
|
|
return False
|
|
if readback_pending:
|
|
return False
|
|
if not self.seen or end - self.last_position > MAX_FRAME_GAP:
|
|
raise ValueError('Replay could not render the end of the selected coverage')
|
|
self.elapsed += end - begin
|
|
self.flush(round(self.elapsed * 1e6))
|
|
if self.index + 1 == len(self.ranges):
|
|
return True
|
|
self.index += 1
|
|
self.seen = self.running = self.pausing = False
|
|
self.first_position = self.last_position = None
|
|
engine.command({'op': 'seek', 'position': self.ranges[self.index][0], 'requestId': uuid.uuid4().hex})
|
|
return False
|
|
|
|
def finalize(self):
|
|
from openpilot.starpilot.galaxy.recording_media import _ffmpeg_binary
|
|
self.file.flush()
|
|
os.fsync(self.file.fileno())
|
|
self.file.close()
|
|
usage = shutil.disk_usage(self.directory)
|
|
if usage.free - self.written < max(5 * 1024 ** 3, math.ceil(usage.total * .1)):
|
|
raise OSError('Not enough free storage to finalize replay')
|
|
binary = _ffmpeg_binary()
|
|
output = self.directory / 'export.mp4'
|
|
timeout = max(30, self.written / (16 * 1024 ** 2) + 10)
|
|
subprocess.run([str(binary), '-nostdin', '-hide_banner', '-loglevel', 'error', '-i', str(self.directory / 'export.part'),
|
|
'-map', '0:v:0', '-an', '-c:v', 'copy', '-movflags', '+faststart', '-f', 'mp4', str(output)],
|
|
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=timeout, check=True)
|
|
os.chmod(output, 0o600)
|
|
probe = subprocess.run([str(binary.with_name('ffprobe')), '-v', 'error', '-show_entries',
|
|
'stream=codec_name,nb_frames:format=duration', '-of', 'json', str(output)],
|
|
stdin=subprocess.DEVNULL, capture_output=True, timeout=10, check=True)
|
|
if len(probe.stdout) > 8192:
|
|
raise ValueError('Invalid replay export metadata')
|
|
info = json.loads(probe.stdout)
|
|
streams = info.get('streams', [])
|
|
if (len(streams) != 1 or streams[0].get('codec_name') != 'h264' or int(streams[0].get('nb_frames', 0)) != self.frames or
|
|
abs(float(info.get('format', {}).get('duration', 0)) - self.duration) > .1 or
|
|
not 0 < output.stat().st_size <= self.budget):
|
|
raise ValueError('Replay export did not contain the complete requested video')
|
|
(self.directory / 'export.part').unlink()
|
|
|
|
def close(self):
|
|
self.file.close()
|
|
if self.mux is not None:
|
|
self.mux.close()
|
|
|
|
|
|
class ExportSource:
|
|
"""A finished private file; no source-recording lease survives rendering."""
|
|
def __init__(self, root: Path, export_id: str, size: int):
|
|
self.fds = []
|
|
try:
|
|
parent = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
self.fds.append(parent)
|
|
parent = os.open('exports', os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
self.fds.append(parent)
|
|
parent = os.open(export_id, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
|
|
self.fds.append(parent)
|
|
for fd in self.fds:
|
|
info = os.fstat(fd)
|
|
if info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o700:
|
|
raise ValueError('Replay export directory changed')
|
|
self.fd = os.open('replay.mp4', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
|
|
self.fds.append(self.fd)
|
|
fcntl.flock(self.fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
info = os.fstat(self.fd)
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_size != size:
|
|
raise ValueError('Replay export file changed')
|
|
self.identity = info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns
|
|
self.parent = parent
|
|
except BaseException:
|
|
self.close()
|
|
raise
|
|
|
|
def current(self):
|
|
try:
|
|
info = os.stat('replay.mp4', dir_fd=self.parent, follow_symlinks=False)
|
|
return self.identity == (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)
|
|
except OSError:
|
|
return False
|
|
|
|
def close(self):
|
|
for fd in reversed(self.fds):
|
|
os.close(fd)
|
|
self.fds.clear()
|