Skip to content

Commit 959cd59

Browse files
committed
Add FlightTelemetryTools HUD burn module.
1 parent c386a62 commit 959cd59

1 file changed

Lines changed: 200 additions & 0 deletions

File tree

  • FlightTelemetryTools/flight_telemetry
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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

Comments
 (0)