引言
骑车时录了不少视频,一直想给画面加上运动数据——心率、速度、轨迹图那种 DJI / Garmin Virb 风格的仪表盘 overlay。
最开始做的是网页版方案:HLS 切片 + hls.js 播放,再用 HTML + Canvas 在视频上叠一层实时 HUD。能用,但有几个硬伤:移动端全屏时 HTML 层会被系统播放器吃掉,视频下载/转发后数据也没了,而且不同浏览器对原生 HLS 的支持参差不齐(新版 Chrome 自称支持 HLS,但对 AES-128 加密切片的行为并不一致)。
干脆换个思路:把数据直接烧录进视频画面。数据成为画面本身,全屏、下载、转发、任何播放器都带着。这篇文章记录完整实现过程。
为什么不用 DJI Studio
先说结论:DJI Studio / Mimo 这类官方 App 的仪表盘功能,只能用 DJI 自家的数据源(相机 GPS、手机),第三方手表的数据接不进去。
我的实际情况更尴尬一点:心率在华为手表里,而华为运动健康导出的 GPX / FIT 文件心率是脱敏的——两个格式都试过,轨迹在、心率没了。但同一份数据同步到 RQ Run 之后心率是完整的,而 RQ 的数据我早就接入了自建的运动数据服务端(博客的运动详情页就是用它渲染的)。
所以数据源其实现成:服务端的 trkpt 轨迹点,心率、速度、海拔、步频、里程、经纬度全有。缺的不是数据,而是一个”一句话就自动生成”的流水线——我最终的用法是:在微信里告诉 AI agent 视频在哪个目录,它直接在 Linux 服务器上跑脚本,解析数据、烧录、切片、加密、上传,全自动。DJI Studio 给不了这个。

整体方案
|
数据源是运动服务端的 trkpt 轨迹点,每个点包含:sec(秒)、heart_rate、speed、altitude、cadence、distance、经纬度。一条 36 分钟的骑行记录大约 430 个点(5 秒一个)。
逐秒渲染 HUD 帧
核心思路:视频多少秒就生成多少张透明 PNG,第 i 张对应该秒的数据状态,再用 FFmpeg 以 1fps 作为第二路输入叠上去。
布局
参考 DJI 仪表盘的布局:
- 左上:日期 + 实时时钟(运动开始时间 + 已时长逐秒走)
- 顶部居中:深色圆角计时胶囊
- 左列:数据卡(斜体大数值 + 绿色斜体单位),按运动类型适配
- 右上:指北轨迹图(N 标记 + 虚线参考线 + 当前位置点)
- 底部居中:坡度小弧表
- 右下:半圆速度表(骑行)/ 配速表(跑步)
数据卡按运动类型适配——骑行显示海拔/坡度/总里程/心率/消耗热量/累计爬升/平均速度,跑步换成配速/步频/平均步频等,超过 6 项自动收紧行距。
字体是最大的坑
最初用系统里的文泉驿正黑,效果一言难尽。后来试了一圈,数字和单位最终选定 Barlow Semi Condensed Bold Italic(Google Fonts 免费可商用,气质最接近大疆仪表盘),中文标签用 Noto Sans CJK:
|
两个细节:
- 不要用 PIL 的仿射变换做伪斜体,切变会让文字发虚,直接选自带斜体的字体
- 描边换成柔和投影:黑色文字 +
GaussianBlur(4)+ 偏移 3px,比stroke_width的硬描边干净得多
轨迹图的圆形裁切
最初版本的轨迹会画出圆形边框外——CSS 里 border-radius: 50% + overflow: hidden 是天然裁切,PIL 里要手动做蒙版:
|
数据对齐的坑
- 步频:华为设备直接给总步频(
180),RQ 给单脚步频(90 需要 ×2)。取中位数判断:>130 认为是总步频直接用,否则 ×2 - 消耗热量:API 只有总热量,按里程占比折算到每秒(比时间线性更接近真实消耗曲线)
- 心率配色:与网页版保持一致(<110 白 / <130 蓝 / <150 绿 / <170 橙 / ≥170 红),无数据显示
--
FFmpeg 烧录与转码
HUD 帧生成后,一条命令完成烧录 + 缩放 + 转码 + HLS 切片:
|
几个关键点:
-framerate 1:HUD PNG 按 1fps 读入,每张图正好覆盖一秒scale2ref:把 1920x1080 的 HUD 帧缩放到和主画面一致再叠加,主画面缩到任何分辨率 HUD 都不会错位- 多段视频的时间轴:两段视频共用一个全局 HUD 序列,第二段用
-start_number 1273从全局第 1273 秒开始读 - 必须重编码:烧录天然要求重编码。顺带解决了一个老问题——DJI 原片是 HEVC(H.265),Chrome/Edge 的 MSE 无法软解 TS 里的 HEVC,转 H.264 后全平台可播
-map 0:v:0 -map 0:a:0:DJI 的 MP4 里有私有数据流和缩略图流,不剔除会导致 TS 封装失败
HLS AES-128 加密与密钥下发
视频不想被随便搬走,用了 HLS 标准的 AES-128 加密:keyinfo.txt 里指定密钥 URL、本地密钥文件和 IV,FFmpeg 切片时自动加密并在 m3u8 里写入 #EXT-X-KEY。
关键设计是密钥不放 OSS,而是存在后端数据库,通过一个公开接口下发,但校验 Referer/Origin:
|
- 浏览器从博客域名发起 → 放行,返回 16 字节密钥
- 空 Referer(curl 直接打)→ 403
- 其他域名(被盗链嵌套)→ 403 + CORS 拦截
要说明的是天花板:这是”防君子不防小人”的方案,会抓包伪造 Referer 的人依然能拿到密钥。对于无登录的公开博客,这就是合理上限——目的是让别人不能简单地右键另存、不能把 m3u8 搬到自己网站播。
前端播放
烧录之后前端反而简单了——HTML HUD 整套删掉,回归原生播放器:
- 读
workout_videos.json拿到 OSS 目录,探测index.json(404 = 没有视频,不渲染卡片) - 滚动到视频卡片才加载 hls.js
- hls.js 按播放进度拉切片,密钥请求自动带 Referer
- 多段视频生成一个合并的
all.m3u8,进度条显示总时长、可跨段拖动
踩过的坑(都值得记一笔)
- CDN 缓存的 CORS 投毒:无 Origin 请求先把
.ts切片缓存进 CDN,之后所有跨域请求拿到的都是这份没有Access-Control-Allow-Origin的缓存,hls.js 全被浏览器拦死。解法是在 CDN 域名配置里强制附加响应头(缓存命中也生效) - Cloudflare 拦 Python UA:脚本用 urllib 提交密钥被 1010 拦截,伪装浏览器 UA 解决;TLS 指纹级别的拦截则直接换 curl
- 断点续传的密钥一致性:加密切片中途失败后,密钥文件必须和切片同生共死——密钥丢了但残留切片还在时,必须清掉重切,否则就是”切片旧密钥、库里新密钥”的死局
- 大切片调优:2 分钟 1080p50 的切片有 130-170MB,hls.js 默认 20 秒超时必死,
fragLoadingTimeOut要放宽到 120s 并加大重试 - OSS 偶发 502:上传必须带指数退避重试
效果与总结
最终效果就是文章开头那张图:数据以每秒一帧的精度烧进画面,36 分钟视频、2168 张 HUD 帧、19 个加密切片。全屏、下载、转发,数据都在。
整条链路最后收敛成一条命令:
|
latest 会自动解析为最新一条运动记录。现在我生成视频的方式是:在微信里告诉 AI agent 视频在哪个目录,它在服务器上执行这条命令,解析 RQ 数据、渲染 HUD、烧录转码、加密切片、上传 OSS、提交密钥,一条龙全自动——几分钟后博客的运动详情页里就能看了。
整个实现其实就两个脚本:一个 Python 渲染帧,一个 FFmpeg 转码上传。花费最多的是样式打磨——字体选了三轮,对照参考图一个像素一个像素调位置。技术本身不复杂,但把”能看”做到”好看”,才是这类小工具最耗时间的地方。
如果你也有运动相机 + 运动数据,这套方案直接可用:数据源换成你的 GPX/FIT 解析结果即可,渲染和转码部分完全通用。
评论
0 条评论