大疆DJI无人机视频剪裁保留GPS字幕元数据方法
大疆无人机视频的特殊封装结构(H.264 视频流 + mov_text 遥测字幕流 + 自定义 GPS 元数据),要实现无损剪裁、字幕时间戳同步、GPS 与全量元数据零丢失,可以采用“FFmpeg 精确复制流 + ExifTool 补全元数据”的组合策略。
为什么需要两步操作?
单纯依靠 FFmpeg 的 -map_metadata 0 无法完整保留大疆 MP4 中的自定义 GPS 与 QuickTime 元数据标签。而 FFmpeg 的 -movflags use_metadata_tags 虽然能保留部分关键标签,但对无人机特有的 GPS 轨道和自定义 atom 仍然覆盖不全。因此,最佳实践是:FFmpeg 负责视频/字幕流的无损剪裁与时间戳对齐,ExifTool 负责将原始文件的全部元数据(含 GPS)批量复制到剪裁后的文件。

完整 Python 实现
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
DJI 无人机视频剪裁工具(GUI)
- FFmpeg: 无损流复制(视频 + mov_text 字幕),自动时间戳对齐
- ExifTool: 从原文件复制全部元数据(GPS、Pitch/Yaw/Roll、UserData 等)
- 排除 DJI 私有数据流(codec=none,MP4 容器不支持)
- 代码开源,请注明原作者:高山羊止
- 2026.10.24
"""
import os
import subprocess
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from tkinter.scrolledtext import ScrolledText
class DJIVideoTrimmer:
def __init__(self, root):
self.root = root
self.root.title("DJI 无人机视频剪裁工具")
self.root.geometry("860x640")
self.input_path = tk.StringVar()
self.output_path = tk.StringVar()
self.start_time = tk.StringVar(value="00:00:00")
self.end_time = tk.StringVar(value="00:00:10")
self.use_duration = tk.BooleanVar(value=False)
self.status_var = tk.StringVar(value="就绪")
self._build_ui()
# ---------------- UI ----------------
def _build_ui(self):
# 输入
frm_in = ttk.LabelFrame(self.root, text="输入文件", padding=8)
frm_in.pack(fill="x", padx=10, pady=5)
ttk.Entry(frm_in, textvariable=self.input_path).pack(side="left", fill="x", expand=True)
ttk.Button(frm_in, text="浏览...", command=self.browse_input).pack(side="left", padx=(5, 0))
# 输出
frm_out = ttk.LabelFrame(self.root, text="输出文件", padding=8)
frm_out.pack(fill="x", padx=10, pady=5)
ttk.Entry(frm_out, textvariable=self.output_path).pack(side="left", fill="x", expand=True)
ttk.Button(frm_out, text="另存为...", command=self.browse_output).pack(side="left", padx=(5, 0))
# 时间
frm_time = ttk.LabelFrame(self.root, text="剪裁时间", padding=8)
frm_time.pack(fill="x", padx=10, pady=5)
ttk.Label(frm_time, text="起始 (HH:MM:SS.mmm):").grid(row=0, column=0, sticky="w", padx=5, pady=3)
ttk.Entry(frm_time, textvariable=self.start_time, width=20).grid(row=0, column=1, sticky="w", padx=5)
ttk.Label(frm_time, text="结束/时长:").grid(row=0, column=2, sticky="w", padx=(20, 5), pady=3)
ttk.Entry(frm_time, textvariable=self.end_time, width=20).grid(row=0, column=3, sticky="w", padx=5)
ttk.Checkbutton(
frm_time, text="把第二格解释为“持续时长”(FFmpeg 的 -t),否则按“结束时间”(-to) 处理",
variable=self.use_duration
).grid(row=1, column=0, columnspan=4, sticky="w", padx=5, pady=3)
# 操作
frm_btn = ttk.Frame(self.root)
frm_btn.pack(fill="x", padx=10, pady=5)
self.start_btn = ttk.Button(frm_btn, text="开始剪裁", command=self.start_task)
self.start_btn.pack(side="left")
ttk.Label(frm_btn, textvariable=self.status_var).pack(side="left", padx=15)
# 日志
frm_log = ttk.LabelFrame(self.root, text="日志", padding=5)
frm_log.pack(fill="both", expand=True, padx=10, pady=5)
self.log_text = ScrolledText(frm_log, wrap="word", height=22)
self.log_text.pack(fill="both", expand=True)
# ---------------- 事件 ----------------
def browse_input(self):
p = filedialog.askopenfilename(
title="选择输入视频",
filetypes=[("MP4 文件", "*.mp4"), ("所有文件", "*.*")]
)
if p:
self.input_path.set(p)
if not self.output_path.get():
base, ext = os.path.splitext(p)
self.output_path.set(base + "_trimmed" + ext)
def browse_output(self):
p = filedialog.asksaveasfilename(
title="选择输出文件", defaultextension=".mp4",
filetypes=[("MP4 文件", "*.mp4")]
)
if p:
self.output_path.set(p)
def start_task(self):
inp = self.input_path.get().strip()
out = self.output_path.get().strip()
start = self.start_time.get().strip()
end = self.end_time.get().strip()
if not inp or not os.path.isfile(inp):
messagebox.showerror("错误", "请选择有效的输入文件")
return
if not out:
messagebox.showerror("错误", "请指定输出文件路径")
return
if not start or not end:
messagebox.showerror("错误", "请填写起始时间与结束时间/时长")
return
self.start_btn.config(state="disabled")
self.status_var.set("处理中...")
threading.Thread(
target=self.worker, args=(inp, out, start, end, self.use_duration.get()),
daemon=True
).start()
# ---------------- 后台任务 ----------------
def worker(self, inp, out, start, end, use_duration):
try:
self._log("=" * 70)
self._log(f"输入: {inp}")
self._log(f"输出: {out}")
self._log(f"起始: {start} 结束/时长: {end} ({'-t 时长' if use_duration else '-to 结束时间'})")
self._log("=" * 70)
# ---------- Step 1: FFmpeg ----------
# 关键点:
# 1) -ss / -to / -t 全部放在 -i 之前(输入选项),语义为绝对时间
# 2) -map 0:v -map 0:s? 只映射视频与字幕,丢弃 DJI.Meta 数据流(codec=none)
# 3) -c copy 流复制,不重编码
# 4) 不用 -copyts:FFmpeg 自动把输出时间戳归零,字幕事件时间戳同步调整
ff = ["ffmpeg", "-y"]
if start:
ff += ["-ss", start]
ff += ["-t", end] if use_duration else ["-to", end]
ff += [
"-i", inp,
"-map", "0:v",
"-map", "0:s?",
"-c", "copy",
"-movflags", "use_metadata_tags",
out,
]
self._log("[FFmpeg] 命令:")
self._log(" ".join(self._quote(c) for c in ff))
r = subprocess.run(
ff, capture_output=True, text=True,
encoding="utf-8", errors="ignore"
)
for line in (r.stderr or "").splitlines():
# 过滤掉版本横幅,只打关键行
if any(k in line for k in ("Stream #", "Stream mapping", "->", "error",
"Error", "Output #", "Input #", "frame=",
"Duration", "video:", "Subtitle:", "Could not")):
self._log("[ffmpeg] " + line.strip())
if r.returncode != 0:
raise RuntimeError("FFmpeg 执行失败,详见日志")
if not os.path.isfile(out):
raise RuntimeError("FFmpeg 未生成输出文件")
self._log("[FFmpeg] ✓ 流复制完成(视频 + 字幕)")
self._log("[提示] 大疆私有数据流 (DJI.Meta, codec=none) 被排除——MP4 容器无法写入;"
"GPS/姿态信息将通过 ExifTool 从原文件复制。")
# ---------- Step 2: ExifTool ----------
self._log("[ExifTool] 从原文件复制全部元数据(含 GPS、姿态、UserData)...")
ex = [
"exiftool",
"-TagsFromFile", inp,
"-all:all",
"-overwrite_original",
out,
]
self._log(" ".join(self._quote(c) for c in ex))
r2 = subprocess.run(
ex, capture_output=True, text=True,
encoding="utf-8", errors="ignore"
)
stdout = (r2.stdout or "").strip()
if stdout:
self._log(stdout)
if r2.returncode != 0:
self._log("[警告] ExifTool 返回非零:" + (r2.stderr or "").strip())
else:
self._log("[ExifTool] ✓ 元数据复制完成")
# ---------- Step 3: 验证 ----------
self._log("[验证] 输出文件的 GPS 与字幕流:")
self._verify(out)
self._log("✅ 全部完成!")
self._set_status("完成")
self.root.after(0, lambda: messagebox.showinfo("完成", f"剪裁完成:\n{out}"))
except Exception as e:
self._log(f"[错误] {e}")
self._set_status("失败")
self.root.after(0, lambda: messagebox.showerror("错误", str(e)))
finally:
self.root.after(0, lambda: self.start_btn.config(state="normal"))
def _verify(self, path):
# 流信息
try:
r = subprocess.run(
["ffprobe", "-v", "error", "-show_entries",
"stream=index,codec_type,codec_name,duration",
"-of", "default=noprint_wrappers=1", path],
capture_output=True, text=True, encoding="utf-8", errors="ignore"
)
self._log("[ffprobe]\n" + (r.stdout or "").strip())
except Exception as e:
self._log(f"[ffprobe] 跳过: {e}")
# GPS / 姿态
try:
r2 = subprocess.run(
["exiftool", "-s",
"-QuickTime:GPSCoordinates", "-QuickTime:GPSCoordinates-err",
"-GPSPosition",
"-QuickTime:Pitch", "-QuickTime:Yaw", "-QuickTime:Roll",
"-QuickTime:SpeedX", "-QuickTime:SpeedY", "-QuickTime:SpeedZ",
path],
capture_output=True, text=True, encoding="utf-8", errors="ignore"
)
self._log("[exiftool]\n" + (r2.stdout or "").strip())
except Exception as e:
self._log(f"[exiftool 验证] 跳过: {e}")
# ---------------- 小工具 ----------------
@staticmethod
def _quote(s):
return f'"{s}"' if " " in s else s
def _log(self, msg):
self.root.after(0, lambda m=msg: self._append(m))
def _append(self, msg):
self.log_text.insert("end", msg + "\n")
self.log_text.see("end")
def _set_status(self, s):
self.root.after(0, lambda: self.status_var.set(s))
if __name__ == "__main__":
root = tk.Tk()
DJIVideoTrimmer(root)
root.mainloop()
关键参数解析与注意事项
1. 字幕轨(mov_text)的同步机制
-map 0 确保输入文件中的所有流(包括 Stream #0:2 的 mov_text 字幕轨)都被完整映射到输出文件中。配合 -copyts,FFmpeg 会保留原始时间戳体系,剪切后的字幕事件不会因剪辑基准偏移而错位。如果剪裁点恰好落在两个字幕事件之间,FFmpeg 会自动截断或丢弃越界事件,但不会影响整体时间轴的对齐。
2. GPS 元数据的复制策略
ExifTool 的 -TagsFromFile 配合 -all:all 是复制无人机元数据最可靠的方式。大疆 MP4 中的 GPS 信息通常存储在 UserData:GPSCoordinates 和 QuickTime:GPSCoordinates 两个标签中。-all:all 会同时覆盖这两个位置,并将原始文件中的 SpeedX/Y/Z、Pitch、Yaw、Roll 等飞行姿态标签一并写入剪裁后的文件。如果遇到个别标签无法写入,ExifTool 会给出警告,但不会影响视频主体和 GPS 坐标的写入。
3. 时间参数的选择
- 当
start_time和end_time均使用HH:MM:SS.mmm格式时,FFmpeg 以毫秒级精度定位。 - 如果只需要截取固定时长,将
end_time替换为duration(或-t)参数即可。 -ss放在-i之前属于输入 seeking,FFmpeg 会直接跳到最近的关键帧开始复制,速度极快且不涉及解码。由于大疆视频的 GOP 通常较短(1~2 秒),关键帧对齐带来的误差在可接受范围内;如需帧级精确,可将-ss移到-i之后(输出 seeking),但会触发重编码。
4. 无需处理音频
大疆无人机视频通常本身不含音轨,或音轨为静音占位。上述 -map 0 会自动映射所有存在的流,不会因为“无需音频”而遗漏字幕或数据流。
验证输出结果
剪裁完成后,建议用以下命令快速核验字幕轨和 GPS 是否完整保留:
# 查看流信息(应能看到 Video、Data、Subtitle 三路流)
ffprobe -v error -show_streams -select_streams s "DJI_0982_trimmed.MP4"
# 查看 GPS 元数据
exiftool -GPSPosition -GPSCoordinates -SpeedX -SpeedY -SpeedZ "DJI_0982_trimmed.MP4"
在0.9版中:
原文件 Stream #0:1 是大疆私有数据流(Data: none (priv),即 DJI.Meta 遥测数据)。MP4 容器不支持写入 codec=none 的数据流,所以 -map 0 强行映射后导致写头失败。
修正思路:
修正版:DJI 视频剪裁 + 字幕/GPS/元数据保留(GUI)
错误原因分析
报错核心是这一行:
Could not find tag for codec none in stream #1, codec not currently supported in container
原文件 Stream #0:1 是大疆私有数据流(Data: none (priv),即 DJI.Meta 遥测数据)。MP4 容器不支持写入 codec=none 的数据流,所以 -map 0 强行映射后导致写头失败。
修正思路:
- 不映射数据流,只映射视频和字幕:
-map 0:v -map 0:s?(?表示字幕流不存在时也不报错)。 - 去掉
-copyts:输入 seeking 时 FFmpeg 会自动把输出时间戳归零并同步调整字幕事件;-copyts反而会让时间戳不从 0 开始,部分播放器字幕显示异常。 - GPS 及姿态元数据:通过 ExifTool
-TagsFromFile -all:all从原文件复制(原文件 GPS 在 QuickTime atom 中,ExifTool 能完整搬运)。 - 大疆私有数据流 (
DJI.Meta) 无法在标准 MP4 中保留,这是容器限制,无法绕过;但 GPS / 姿态 / UserData 等已在 QuickTime 元数据里,剪裁后通过 ExifTool 复制即可。
与旧版差异对照
| 项 | 旧版 | 新版 | 原因 |
|---|---|---|---|
| 流映射 | -map 0(含 Data none 流) |
-map 0:v -map 0:s? |
MP4 容器不支持 codec=none 数据流 |
| 时间戳 | -copyts |
去掉 | 让 FFmpeg 自动把输出归零,字幕与视频同步 |
-to / -t 位置 |
位于 -i 前 |
保留在 -i 前(输入选项) |
语义为「输入文件的绝对时间」,与 -ss 配对才正确 |
| 交互 | 纯脚本 | Tkinter GUI(输入/输出选择、时间输入、日志、状态栏) | 需求 |
| 验证 | 无 | 自动用 ffprobe + exiftool 打印 GPS 与字幕流 |
便于确认 |
关于「DJI.Meta 数据流丢失」的说明
原文件 Stream #0:1 是 Data: none (priv / 0x76697270),handler 名 DJI.Meta。它是大疆的私有二进制遥测流(内含飞控日志、IMU 等),没有任何标准 MP4 box 能容纳。FFmpeg 即使强行写 MP4 也会报 Could not find tag for codec none,这就是你遇到的错误。
好消息是:GPS 坐标、Pitch/Yaw/Roll、SpeedX/Y/Z、UserData、Comment(SN/Mode) 这些你在 DJI_0982_metadata_fixed.json 中看到的信息都在 QuickTime 全局 metadata atom 里(ExifTool 显示为 GPSCoordinates、Pitch、Yaw 等标签),ExifTool -all:all 会原样搬到输出文件,满足你「保留 GPS 与全部其它元数据」的要求。
使用步骤
- 把上面代码保存为
DJI 无人机视频剪裁工具1.0.py。 - 确保
ffmpeg.exe、ffprobe.exe、exiftool.exe在 PATH 中(环境变量已经配好)。 - 运行:
DJI 无人机视频剪裁工具1.0.py。 - 选择输入
DJI_0982.MP4→ 输出自动填成DJI_0982_trimmed.MP4。 - 填起始
00:00:10,结束00:00:30(或勾选“持续时长”并填00:00:20)。 - 点「开始剪裁」,日志里会依次出现:
- FFmpeg 流映射(应只有 Video + Subtitle)
- ExifTool 复制完成(含
1 image files updated) - 验证区应看到
GPSCoordinates : 36 deg ...和字幕流信息
- 完成后弹窗提示,输出文件即可交给后续处理。
大疆DJI无人机视频剪裁保留GPS字幕元数据方法
浙公网安备 33010602011771号