大疆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)批量复制到剪裁后的文件。

2026-10-24_190422


完整 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 强行映射后导致写头失败。

修正思路:

  1. 不映射数据流,只映射视频和字幕:-map 0:v -map 0:s?(? 表示字幕流不存在时也不报错)。
  2. 去掉 -copyts:输入 seeking 时 FFmpeg 会自动把输出时间戳归零并同步调整字幕事件;-copyts 反而会让时间戳不从 0 开始,部分播放器字幕显示异常。
  3. GPS 及姿态元数据:通过 ExifTool -TagsFromFile -all:all 从原文件复制(原文件 GPS 在 QuickTime atom 中,ExifTool 能完整搬运)。
  4. 大疆私有数据流 (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 与全部其它元数据」的要求。


使用步骤

  1. 把上面代码保存为 DJI 无人机视频剪裁工具1.0.py。
  2. 确保 ffmpeg.exe、ffprobe.exe、exiftool.exe 在 PATH 中(环境变量已经配好)。
  3. 运行:DJI 无人机视频剪裁工具1.0.py。
  4. 选择输入 DJI_0982.MP4 → 输出自动填成 DJI_0982_trimmed.MP4。
  5. 填起始 00:00:10,结束 00:00:30(或勾选“持续时长”并填 00:00:20)。
  6. 点「开始剪裁」,日志里会依次出现:
    • FFmpeg 流映射(应只有 Video + Subtitle)
    • ExifTool 复制完成(含 1 image files updated)
    • 验证区应看到 GPSCoordinates : 36 deg ... 和字幕流信息
  7. 完成后弹窗提示,输出文件即可交给后续处理。

编译的x64可执行文件下载地址:

https://wwbur.lanzoul.com/i70254d790ti
密码:df8w

posted on 2026-10-24 19:09  igaoyuan  阅读(17)  评论(0)    收藏  举报

导航