|
| 1 | +"""Burn altitude/speed (and optional lat/lon) HUD via ASS + ffmpeg.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import shutil |
| 6 | +import subprocess |
| 7 | +import tempfile |
| 8 | +from pathlib import Path |
| 9 | +from typing import List, Optional, Union |
| 10 | + |
| 11 | +from .models import TelemetryFrame |
| 12 | + |
| 13 | +PathLike = Union[str, Path] |
| 14 | + |
| 15 | + |
| 16 | +def ffmpeg_available() -> bool: |
| 17 | + return shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None |
| 18 | + |
| 19 | + |
| 20 | +def ensure_ffmpeg() -> str: |
| 21 | + """Return path to ffmpeg, attempting a package install if missing.""" |
| 22 | + path = shutil.which("ffmpeg") |
| 23 | + if path: |
| 24 | + return path |
| 25 | + # Best-effort install on this Linux box (Debian/Ubuntu style). |
| 26 | + for cmd in ( |
| 27 | + ["apt-get", "update"], |
| 28 | + ["apt-get", "install", "-y", "ffmpeg"], |
| 29 | + ): |
| 30 | + try: |
| 31 | + subprocess.run(cmd, check=False, capture_output=True, timeout=180) |
| 32 | + except (OSError, subprocess.TimeoutExpired): |
| 33 | + break |
| 34 | + path = shutil.which("ffmpeg") |
| 35 | + if not path: |
| 36 | + raise RuntimeError( |
| 37 | + "ffmpeg is required for HUD burn but could not be found or installed" |
| 38 | + ) |
| 39 | + return path |
| 40 | + |
| 41 | + |
| 42 | +def _fmt_speed(frame: TelemetryFrame) -> str: |
| 43 | + if frame.speed_mps is None: |
| 44 | + return "spd: --" |
| 45 | + src = "rec" if frame.speed_source == "recorded" else "est" |
| 46 | + return f"spd: {frame.speed_mps:.1f} m/s ({src})" |
| 47 | + |
| 48 | + |
| 49 | +def _fmt_alt(frame: TelemetryFrame) -> str: |
| 50 | + tag = "abs" if frame.altitude_source == "absolute" else "rel" |
| 51 | + return f"alt: {frame.altitude_m:.1f} m ({tag})" |
| 52 | + |
| 53 | + |
| 54 | +def frames_to_ass( |
| 55 | + frames: List[TelemetryFrame], |
| 56 | + *, |
| 57 | + include_latlon: bool = True, |
| 58 | + play_res_x: int = 1280, |
| 59 | + play_res_y: int = 720, |
| 60 | +) -> str: |
| 61 | + """Build a minimal ASS/SSA script with one dialogue event per frame cue.""" |
| 62 | + header = f"""[Script Info] |
| 63 | +ScriptType: v4.00+ |
| 64 | +PlayResX: {play_res_x} |
| 65 | +PlayResY: {play_res_y} |
| 66 | +WrapStyle: 2 |
| 67 | +ScaledBorderAndShadow: yes |
| 68 | +
|
| 69 | +[V4+ Styles] |
| 70 | +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding |
| 71 | +Style: HUD,DejaVu Sans,28,&H00FFFFFF,&H000000FF,&H80000000,&H80000000,-1,0,0,0,100,100,0,0,1,2,0,7,20,20,20,1 |
| 72 | +
|
| 73 | +[Events] |
| 74 | +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text |
| 75 | +""" |
| 76 | + lines = [header] |
| 77 | + for fr in frames: |
| 78 | + parts = [_fmt_alt(fr), _fmt_speed(fr)] |
| 79 | + if include_latlon: |
| 80 | + parts.append(f"lat: {fr.latitude:.6f} lon: {fr.longitude:.6f}") |
| 81 | + text = r"\N".join(parts) |
| 82 | + # Escape ASS specials lightly |
| 83 | + text = text.replace("{", r"\{").replace("}", r"\}") |
| 84 | + lines.append( |
| 85 | + f"Dialogue: 0,{fr.cue_start_ass},{fr.cue_end_ass},HUD,,0,0,0,,{text}\n" |
| 86 | + ) |
| 87 | + return "".join(lines) |
| 88 | + |
| 89 | + |
| 90 | +def burn_hud( |
| 91 | + video: PathLike, |
| 92 | + frames: List[TelemetryFrame], |
| 93 | + out: PathLike, |
| 94 | + *, |
| 95 | + include_latlon: bool = True, |
| 96 | +) -> Path: |
| 97 | + """Generate ASS from frames and burn into video with ffmpeg -vf ass=... |
| 98 | +
|
| 99 | + Source video and SRT-derived data are read-only; only ``out`` is written. |
| 100 | + """ |
| 101 | + video_path = Path(video) |
| 102 | + out_path = Path(out) |
| 103 | + if not video_path.is_file(): |
| 104 | + raise FileNotFoundError(f"Video file not found: {video_path}") |
| 105 | + if not frames: |
| 106 | + raise ValueError("No telemetry frames to burn into HUD") |
| 107 | + |
| 108 | + ffmpeg = ensure_ffmpeg() |
| 109 | + ass_text = frames_to_ass(frames, include_latlon=include_latlon) |
| 110 | + |
| 111 | + with tempfile.TemporaryDirectory(prefix="flight_hud_") as tmp: |
| 112 | + ass_path = Path(tmp) / "hud.ass" |
| 113 | + ass_path.write_text(ass_text, encoding="utf-8") |
| 114 | + # Escape path for ffmpeg filter (colons / backslashes) |
| 115 | + ass_filter = str(ass_path).replace("\\", "/").replace(":", r"\:") |
| 116 | + cmd = [ |
| 117 | + ffmpeg, |
| 118 | + "-y", |
| 119 | + "-i", |
| 120 | + str(video_path), |
| 121 | + "-vf", |
| 122 | + f"ass={ass_filter}", |
| 123 | + "-c:a", |
| 124 | + "copy", |
| 125 | + str(out_path), |
| 126 | + ] |
| 127 | + # Silent lavfi test videos have no audio — allow failure on -c:a copy |
| 128 | + proc = subprocess.run(cmd, capture_output=True, text=True) |
| 129 | + if proc.returncode != 0: |
| 130 | + # Retry without audio copy (generated silent clips) |
| 131 | + cmd_no_a = [ |
| 132 | + ffmpeg, |
| 133 | + "-y", |
| 134 | + "-i", |
| 135 | + str(video_path), |
| 136 | + "-vf", |
| 137 | + f"ass={ass_filter}", |
| 138 | + "-an", |
| 139 | + str(out_path), |
| 140 | + ] |
| 141 | + proc2 = subprocess.run(cmd_no_a, capture_output=True, text=True) |
| 142 | + if proc2.returncode != 0: |
| 143 | + raise RuntimeError( |
| 144 | + "ffmpeg HUD burn failed:\n" |
| 145 | + + (proc2.stderr or proc.stderr or "unknown error") |
| 146 | + ) |
| 147 | + |
| 148 | + if not out_path.is_file() or out_path.stat().st_size == 0: |
| 149 | + raise RuntimeError(f"HUD output was not created: {out_path}") |
| 150 | + return out_path |
| 151 | + |
| 152 | + |
| 153 | +def make_silent_test_video( |
| 154 | + path: PathLike, *, duration_s: float = 3.0, size: str = "640x360" |
| 155 | +) -> Path: |
| 156 | + """Generate a short silent test MP4 with lavfi (no drone footage required).""" |
| 157 | + ffmpeg = ensure_ffmpeg() |
| 158 | + out = Path(path) |
| 159 | + cmd = [ |
| 160 | + ffmpeg, |
| 161 | + "-y", |
| 162 | + "-f", |
| 163 | + "lavfi", |
| 164 | + "-i", |
| 165 | + f"color=c=blue:s={size}:d={duration_s}", |
| 166 | + "-c:v", |
| 167 | + "libx264", |
| 168 | + "-pix_fmt", |
| 169 | + "yuv420p", |
| 170 | + "-t", |
| 171 | + str(duration_s), |
| 172 | + str(out), |
| 173 | + ] |
| 174 | + proc = subprocess.run(cmd, capture_output=True, text=True) |
| 175 | + if proc.returncode != 0 or not out.is_file(): |
| 176 | + raise RuntimeError( |
| 177 | + "Failed to generate test video:\n" + (proc.stderr or "unknown") |
| 178 | + ) |
| 179 | + return out |
| 180 | + |
| 181 | + |
| 182 | +def probe_is_video(path: PathLike) -> bool: |
| 183 | + """Return True if ffprobe sees a video stream.""" |
| 184 | + ffprobe = shutil.which("ffprobe") |
| 185 | + if not ffprobe: |
| 186 | + return False |
| 187 | + cmd = [ |
| 188 | + ffprobe, |
| 189 | + "-v", |
| 190 | + "error", |
| 191 | + "-select_streams", |
| 192 | + "v:0", |
| 193 | + "-show_entries", |
| 194 | + "stream=codec_type", |
| 195 | + "-of", |
| 196 | + "csv=p=0", |
| 197 | + str(path), |
| 198 | + ] |
| 199 | + proc = subprocess.run(cmd, capture_output=True, text=True) |
| 200 | + return proc.returncode == 0 and "video" in (proc.stdout or "") |
0 commit comments