API Reference

DJIMetadataEmbedder

Embed DJI telemetry data into video files.

The embedder scans a directory for MP4 videos and their matching SRT files and writes processed copies with subtitle tracks and metadata. A DAT flight log can be merged if provided or automatically discovered. Processed files are written to output_dir.

Parameters

directory: path to folder containing MP4/SRT pairs output_dir: destination directory for processed files (ignored if overwrite=True) overwrite: if True, write embedded video over the original file (in-place) dat_path: optional path to a DAT flight log dat_autoscan: search for DAT logs matching each video redact: GPS redaction mode ("none", "drop", "fuzz") time_offset: time offset in seconds to align SRT with MP4 resample_strategy: resampling strategy for SRT↔MP4 alignment ("linear", "nearest", "cubic")

Usage

embedder = DJIMetadataEmbedder("/videos", time_offset=0.5) embedder.process_directory()

Source code in src/dji_metadata_embedder/embedder.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
class DJIMetadataEmbedder:
    """Embed DJI telemetry data into video files.

    The embedder scans a directory for MP4 videos and their matching SRT files
    and writes processed copies with subtitle tracks and metadata. A DAT flight
    log can be merged if provided or automatically discovered. Processed files
    are written to ``output_dir``.

    Parameters
    ----------
    directory: path to folder containing MP4/SRT pairs
    output_dir: destination directory for processed files (ignored if overwrite=True)
    overwrite: if True, write embedded video over the original file (in-place)
    dat_path: optional path to a DAT flight log
    dat_autoscan: search for DAT logs matching each video
    redact: GPS redaction mode ("none", "drop", "fuzz")
    time_offset: time offset in seconds to align SRT with MP4
    resample_strategy: resampling strategy for SRT↔MP4 alignment ("linear", "nearest", "cubic")

    Usage:
        embedder = DJIMetadataEmbedder("/videos", time_offset=0.5)
        embedder.process_directory()
    """

    def __init__(
        self,
        directory: str,
        output_dir: Optional[str] = None,
        overwrite: bool = False,
        dat_path: Optional[str] = None,
        dat_autoscan: bool = False,
        redact: str = "none",
        time_offset: float = 0.0,
        resample_strategy: str = "linear",
        container: str = "mp4",
        extract_home: bool = False,
        audio_sidecar: bool = False,
    ):
        self.directory = Path(directory)
        self.output_dir = (
            Path(output_dir) if output_dir else self.directory / "processed"
        )
        self.overwrite = overwrite
        if not self.overwrite:
            self.output_dir.mkdir(exist_ok=True)
        self.dat_path = Path(dat_path) if dat_path else None
        self.dat_autoscan = dat_autoscan
        self.redact = redact
        self.time_offset = time_offset
        self.resample_strategy = resample_strategy
        # Output container. "mp4" (default) drops DJI's untaggable djmd/dbgi
        # data streams; "mkv" preserves them — Matroska's codec table accepts
        # the codec=none streams the MP4 muxer rejects (issue #197).
        self.container = container
        # Opt-in only: the HOME/launch point is the operator's location, so it
        # is parsed solely when explicitly requested and never written to MP4.
        self.extract_home = extract_home
        # Opt-in: Neo 2 records audio to a separate .m4a; when set, auto-pair and
        # mux the same-basename sidecar into the output (issue #246).
        self.audio_sidecar = audio_sidecar

    def parse_dji_srt(self, srt_path: Path) -> Dict[str, Any]:
        """Parse DJI SRT file and extract telemetry data."""
        telemetry_data: Dict[str, Any] = {
            "gps_coords": [],
            "altitudes": [],
            "rel_altitudes": [],
            "speeds": [],
            "timestamps": [],
            "camera_info": [],
            "first_gps": None,
            "avg_gps": None,
            "max_altitude": None,
            "flight_duration": None,
            "srt_counts": [],
            "diff_times": [],
            "barometers": [],
        }

        try:
            with open(srt_path, "r", encoding="utf-8") as f:
                content = f.read()

            # Split into subtitle blocks
            blocks = content.strip().split("\n\n")

            for block in blocks:
                lines = block.strip().split("\n")
                if len(lines) >= 3:
                    # Parse timestamp
                    timestamp_line = lines[1]
                    timestamp_match = re.search(
                        r"(\d{2}:\d{2}:\d{2},\d{3})", timestamp_line
                    )
                    if timestamp_match:
                        telemetry_data["timestamps"].append(timestamp_match.group(1))

                    # Parse telemetry data (usually in the 3rd line onward)
                    telemetry_line = " ".join(lines[2:])

                    # Remove HTML tags if present (newer DJI format)
                    if "<font" in telemetry_line:
                        telemetry_line = re.sub(r"<[^>]+>", "", telemetry_line)

                    # Detect comprehensive format with frame counters. Older
                    # firmware labels this ``SrtCnt``; newer firmware (Neo,
                    # Mini 5 Pro, Avata 360) uses ``FrameCnt``. Accept either
                    # spelling and store under the existing ``srt_counts`` key
                    # for backwards compatibility.
                    counter_match = re.search(
                        r"(?:Srt|Frame)Cnt\s*:\s*(\d+)", telemetry_line
                    )
                    diff_time_match = re.search(
                        r"DiffTime\s*:\s*([^\s]+)", telemetry_line
                    )
                    if counter_match or diff_time_match:
                        telemetry_data.setdefault("srt_counts", []).append(
                            int(counter_match.group(1)) if counter_match else None
                        )
                        telemetry_data.setdefault("diff_times", []).append(
                            diff_time_match.group(1) if diff_time_match else None
                        )

                    # Extract GPS coordinates - Multiple format support
                    # Format 1: [latitude: xx.xxx] [longitude: xx.xxx]
                    lat_match = re.search(
                        r"\[latitude:\s*([+-]?\d+\.?\d*)\]", telemetry_line
                    )
                    lon_match = re.search(
                        r"\[longitude:\s*([+-]?\d+\.?\d*)\]", telemetry_line
                    )

                    # Format 2: GPS(lat,lon,alt) — also tolerates a unit
                    # suffix on altitude (e.g. M300 emits ``GPS(lat,lon,0.0M)``)
                    # and an optional space between ``GPS`` and ``(`` used by
                    # the P4 RTK compact single-line family.
                    if not lat_match or not lon_match:
                        gps_match = re.search(
                            r"GPS\s*\(([+-]?\d+\.?\d*),\s*([+-]?\d+\.?\d*),\s*([+-]?\d+\.?\d*)[A-Za-z]*\)",
                            telemetry_line,
                        )
                        if gps_match:
                            lat_match = gps_match
                            lon_match = gps_match
                            lat = float(gps_match.group(1))
                            lon = float(gps_match.group(2))
                        else:
                            lat = None
                            lon = None
                    else:
                        lat = float(lat_match.group(1))
                        lon = float(lon_match.group(1))

                    if lat is not None and lon is not None:
                        telemetry_data["gps_coords"].append((lat, lon))

                    # Extract altitudes - [rel_alt: x.xxx abs_alt: xxx.xxx]
                    alt_match = re.search(
                        r"\[rel_alt:\s*([+-]?\d+\.?\d*)\s*abs_alt:\s*([+-]?\d+\.?\d*)\]",
                        telemetry_line,
                    )
                    if alt_match:
                        rel_alt = float(alt_match.group(1))
                        abs_alt = float(alt_match.group(2))
                        telemetry_data["rel_altitudes"].append(rel_alt)
                        telemetry_data["altitudes"].append(abs_alt)

                    # Extract barometric altitude. Two delimiter variants exist:
                    # Avata 2 parenthesised ``BAROMETER(91.2)`` and Matrice 300
                    # colon form ``BAROMETER:0.3M`` (optional trailing unit).
                    # Barometric height is more reliable than GPS altitude in
                    # tight / FPV environments.
                    baro_match = re.search(
                        r"BAROMETER[(:]\s*([+-]?\d+\.?\d*)[A-Za-z]*\)?",
                        telemetry_line,
                    )
                    if baro_match:
                        telemetry_data["barometers"].append(
                            float(baro_match.group(1))
                        )

                    # Extract camera info including extended fields
                    iso_match = re.search(r"\[iso\s*:\s*(\d+)\]", telemetry_line)
                    shutter_match = re.search(
                        r"\[shutter\s*:\s*([^\]]+)\]", telemetry_line
                    )
                    # Aperture is reported two ways: legacy models use an
                    # f-number*100 integer (``[fnum : 170]`` → f/1.7) while
                    # current models (Avata 360, Mini 5 Pro, …) emit a literal
                    # decimal (``[fnum: 1.9]``). Capture both; the value is
                    # stored verbatim without interpretation.
                    fnum_match = re.search(
                        r"\[fnum\s*:\s*([+-]?\d+\.?\d*)\]", telemetry_line
                    )
                    ev_match = re.search(r"\[ev\s*:\s*([^\]]+)\]", telemetry_line)
                    # P4 RTK compact single-line family uses free-standing
                    # tokens (``F/5.6, SS 400, ISO 100, EV 0``) rather than
                    # the bracketed ``[fnum:…]`` syntax. Fall back to those
                    # when the bracket form did not match.
                    if not fnum_match:
                        fnum_match = re.search(
                            r"(?<![A-Za-z])F/(\d+\.?\d*)", telemetry_line
                        )
                    if not shutter_match:
                        shutter_match = re.search(
                            r"(?<![A-Za-z])SS\s+(\d+(?:\.\d+)?)", telemetry_line
                        )
                    if not iso_match:
                        iso_match = re.search(
                            r"(?<![A-Za-z])ISO\s+(\d+)", telemetry_line
                        )
                    if not ev_match:
                        ev_match = re.search(
                            r"(?<![A-Za-z])EV\s+([+-]?\d+\.?\d*)", telemetry_line
                        )
                    ct_match = re.search(r"\[ct\s*:\s*([^\]]+)\]", telemetry_line)
                    color_md_match = re.search(
                        r"\[color_md\s*:\s*([^\]]+)\]", telemetry_line
                    )
                    focal_len_match = re.search(
                        r"\[focal_len\s*:\s*([^\]]+)\]", telemetry_line
                    )

                    if (
                        iso_match
                        or shutter_match
                        or fnum_match
                        or ev_match
                        or ct_match
                        or color_md_match
                        or focal_len_match
                    ):
                        camera_data = {}
                        if iso_match:
                            camera_data["iso"] = iso_match.group(1)
                        if shutter_match:
                            camera_data["shutter"] = shutter_match.group(1)
                        if fnum_match:
                            camera_data["fnum"] = fnum_match.group(1)
                        if ev_match:
                            camera_data["ev"] = ev_match.group(1)
                        if ct_match:
                            camera_data["ct"] = ct_match.group(1)
                        if color_md_match:
                            camera_data["color_md"] = color_md_match.group(1)
                        if focal_len_match:
                            camera_data["focal_len"] = focal_len_match.group(1)

                        telemetry_data["camera_info"].append(camera_data)

            # Calculate summary statistics. Exclude pre-GPS-lock ``(0, 0)``
            # no-fix frames so the clip is not geotagged at Null Island and the
            # average is not dragged toward it (see ``is_gps_fix``).
            valid_coords = [
                c for c in telemetry_data["gps_coords"] if is_gps_fix(c[0], c[1])
            ]
            if valid_coords:
                telemetry_data["first_gps"] = valid_coords[0]
                avg_lat = sum(coord[0] for coord in valid_coords) / len(valid_coords)
                avg_lon = sum(coord[1] for coord in valid_coords) / len(valid_coords)
                telemetry_data["avg_gps"] = (avg_lat, avg_lon)

            if telemetry_data["altitudes"]:
                telemetry_data["max_altitude"] = max(telemetry_data["altitudes"])

            if telemetry_data["rel_altitudes"]:
                telemetry_data["max_rel_altitude"] = max(
                    telemetry_data["rel_altitudes"]
                )

            if telemetry_data["timestamps"] and len(telemetry_data["timestamps"]) > 1:
                # Calculate flight duration
                first_time = telemetry_data["timestamps"][0].split(",")[0]
                last_time = telemetry_data["timestamps"][-1].split(",")[0]
                telemetry_data["flight_duration"] = f"{first_time} - {last_time}"

            # Get camera settings from first frame
            if telemetry_data["camera_info"]:
                telemetry_data["camera_settings"] = telemetry_data["camera_info"][0]

            if self.extract_home:
                telemetry_data["home"] = parse_home(content)

        except Exception as e:
            logger.error("Error parsing SRT file %s: %s", srt_path, e)

        return telemetry_data

    def embed_metadata_ffmpeg(
        self,
        video_path: Path,
        srt_path: Path,
        telemetry: Dict[str, Any],
        output_path: Path,
        audio_path: Optional[Path] = None,
    ) -> bool:
        """Embed SRT as subtitle track and add metadata using ffmpeg.

        When *audio_path* is given (Neo 2 records audio to a separate .m4a,
        issue #246), it is added as a third input and its audio stream is muxed
        into the output. Audio is appended last so the SRT keeps input index 1
        and the existing ``-map 1`` subtitle mapping stays correct.
        """
        import os
        import platform

        try:
            # Check for ffmpeg in environment variable first (Windows)
            ffmpeg_cmd = "ffmpeg"
            if platform.system() == "Windows":
                env_ffmpeg = os.environ.get("DJIEMBED_FFMPEG_PATH")
                if env_ffmpeg and Path(env_ffmpeg).exists():
                    ffmpeg_cmd = env_ffmpeg

            # Build ffmpeg command.
            #
            # MP4 (default): -map 0 -map -0:d -map 1 keeps every video/audio
            # stream from the source plus the SRT subtitle, but explicitly
            # drops DJI's proprietary data streams (`djmd` / `dbgi`, gyro and
            # debug metadata on Air 3S / Neo 2 / etc.). The MP4 muxer cannot
            # tag their codec, so without the drop the whole mux fails with
            # "Could not find tag for codec none". Background: GH discussion
            # #192, follow-up to #193.
            #
            # MKV (--container mkv): Matroska's codec table accepts those
            # streams, so we keep -map 0 (no -0:d) to round-trip djmd/dbgi
            # byte-for-byte, and use the Matroska-native `srt` subtitle codec
            # rather than the MP4-only `mov_text` (issue #197).
            if self.container == "mkv":
                stream_maps = ["-map", "0", "-map", "1"]
                subtitle_codec = "srt"
            else:
                stream_maps = ["-map", "0", "-map", "-0:d", "-map", "1"]
                subtitle_codec = "mov_text"

            # Audio sidecar is the third input (index 2); pull its audio stream
            # with -map 2:a. Keeping it last preserves the SRT's input index 1.
            inputs = ["-i", str(video_path), "-i", str(srt_path)]
            if audio_path is not None:
                inputs += ["-i", str(audio_path)]
                stream_maps += ["-map", "2:a"]

            cmd = [
                ffmpeg_cmd,
                *inputs,
                *stream_maps,
                "-c",
                "copy",
                "-c:s",
                subtitle_codec,
                "-metadata:s:s:0",
                "language=eng",
                "-metadata:s:s:0",
                "title=Telemetry Data",
            ]

            # Add GPS metadata if available
            if telemetry["first_gps"]:
                lat, lon = telemetry["first_gps"]
                cmd.extend(
                    [
                        "-metadata",
                        f"location={lat:+.6f}{lon:+.6f}/",
                        "-metadata",
                        f"location-eng={lat:+.6f}{lon:+.6f}/",
                    ]
                )

            # Add other metadata
            if telemetry["max_altitude"]:
                cmd.extend(["-metadata", f'altitude={telemetry["max_altitude"]:.1f}'])

            # Add creation date from filename if it matches DJI pattern
            filename_date_match = re.search(r"DJI_(\d{8})_(\d{6})", video_path.stem)
            if filename_date_match:
                date_str = filename_date_match.group(1)
                time_str = filename_date_match.group(2)
                creation_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]} {time_str[:2]}:{time_str[2:4]}:{time_str[4:6]}"
                cmd.extend(["-metadata", f"creation_time={creation_date}"])

            # Output file
            cmd.extend(["-y", str(output_path)])

            # Run ffmpeg
            result = subprocess.run(cmd, capture_output=True, text=True)

            if result.returncode == 0:
                logger.info("Successfully processed: %s", video_path.name)
                return True
            else:
                logger.error("FFmpeg error for %s: %s", video_path.name, result.stderr)
                return False

        except Exception as e:
            logger.error("Error processing %s: %s", video_path.name, e)
            return False

    def embed_metadata_exiftool(
        self, video_path: Path, telemetry: Dict[str, Any]
    ) -> bool:
        """Use exiftool to embed GPS metadata (alternative/additional method)."""
        import os
        import platform

        try:
            if not telemetry["first_gps"]:
                return False

            lat, lon = telemetry["first_gps"]

            # Check for exiftool in environment variable first (Windows)
            exiftool_cmd = "exiftool"
            if platform.system() == "Windows":
                env_exiftool = os.environ.get("DJIEMBED_EXIFTOOL_PATH")
                if env_exiftool and Path(env_exiftool).exists():
                    exiftool_cmd = env_exiftool

            cmd = [
                exiftool_cmd,
                f"-GPSLatitude={abs(lat)}",
                f'-GPSLatitudeRef={"N" if lat >= 0 else "S"}',
                f"-GPSLongitude={abs(lon)}",
                f'-GPSLongitudeRef={"E" if lon >= 0 else "W"}',
                "-overwrite_original",
                str(video_path),
            ]

            if telemetry["max_altitude"]:
                cmd.insert(-2, f'-GPSAltitude={telemetry["max_altitude"]}')
                cmd.insert(-2, "-GPSAltitudeRef=0")  # Above sea level

            result = subprocess.run(cmd, capture_output=True, text=True)
            return result.returncode == 0

        except Exception as e:
            logger.error("ExifTool error: %s", e)
            return False

    @staticmethod
    def _find_dat_log(video_path: Path, warnings: list[str]) -> Optional[Path]:
        """Locate the DAT log named after *video_path* for --dat-auto.

        Exact ``<video>.DAT`` (either case) wins; otherwise name-prefix
        matches in the video's own directory, compared case-insensitively so
        ``dji_0001.dat`` pairs on Linux/macOS the same way it does on
        Windows. A miss warns — mirroring the audio-sidecar branch — and a
        multi-match names the (alphabetically first) log it picked, so
        neither case is a silent no-op (issue #339).
        """
        for ext in (".DAT", ".dat"):
            cand = video_path.with_suffix(ext)
            if cand.exists():
                return cand
        stem = video_path.stem.lower()
        matches = sorted(
            p
            for p in video_path.parent.iterdir()
            if p.suffix.lower() == ".dat" and p.stem.lower().startswith(stem)
        )
        if not matches:
            msg = f"No DAT flight log found for: {video_path.name}"
            logger.warning(msg)
            warnings.append(msg)
            return None
        if len(matches) > 1:
            msg = (
                f"Multiple DAT logs match {video_path.name}; "
                f"using {matches[0].name}"
            )
            logger.warning(msg)
            warnings.append(msg)
        return matches[0]

    def process_directory(
        self,
        use_exiftool: bool = False,
        on_progress: Callable[[int, int, str], None] | None = None,
    ) -> Dict[str, Any]:
        """Process all MP4/SRT pairs in the directory.

        ``on_progress(index, total, name)`` is called (1-based) as each video
        is picked up; passing it also disables the interactive progress bar
        (the caller owns the display).

        Returns:
            Dict containing processing results and statistics
        """
        # Find all supported video files (.mp4 plus DJI 360 .osv/.lrf).
        video_files = discover_video_files(self.directory)

        # Initialize result structure
        result: Dict[str, Any] = {
            "processed": 0,
            "total_files": len(video_files),
            "warnings": [],
            "errors": [],
            "output_directory": str(self.directory if self.overwrite else self.output_dir),
        }

        if not video_files:
            warning_msg = f"No MP4 files found in {self.directory}"
            logger.warning(warning_msg)
            result["warnings"].append(warning_msg)
            return result

        logger.info("Found %d video files to process", len(video_files))
        if self.overwrite:
            logger.info("Overwrite mode: embedding in place (destination = input folder)\n")
        else:
            logger.info("Output directory: %s\n", self.output_dir)

        success_count = 0

        # The caller owns the display when it passes a callback.
        bar = Progress(disable=True) if on_progress is not None else Progress()
        with bar as progress:
            task = progress.add_task("Processing videos", total=len(video_files))
            for index, video_path in enumerate(video_files, start=1):
                if on_progress is not None:
                    on_progress(index, len(video_files), video_path.name)
                # Look for corresponding SRT file
                srt_path = video_path.with_suffix(".srt")
                if not srt_path.exists():
                    srt_path = video_path.with_suffix(".SRT")

                if not srt_path.exists():
                    warning_msg = f"No SRT file found for: {video_path.name}"
                    logger.warning(warning_msg)
                    result["warnings"].append(warning_msg)
                    progress.advance(task)
                    continue

                progress.update(task, description=video_path.name)
                logger.debug("Processing %s", video_path.name)

                # Parse SRT telemetry
                telemetry = self.parse_dji_srt(srt_path)
                apply_redaction(telemetry, self.redact)

                # Optionally parse DAT telemetry
                dat_file = None
                if self.dat_path:
                    dat_file = self.dat_path
                elif self.dat_autoscan:
                    dat_file = self._find_dat_log(video_path, result["warnings"])
                if dat_file and dat_file.exists():
                    try:
                        dat_data = parse_dat_v13(dat_file)
                        telemetry["dat_records"] = dat_data.get("records", [])
                    except Exception as e:
                        logger.warning(
                            "Failed to parse DAT file %s: %s", dat_file.name, e
                        )

                # Optionally pair a separate audio sidecar. The Neo 2 records
                # audio to a same-basename .m4a next to the silent video; mux it
                # back in on request (issue #246). Warn-and-continue when absent
                # so mixed folders (some clips with audio, some without) still
                # process every video.
                audio_file = None
                if self.audio_sidecar:
                    for ext in (".m4a", ".M4A"):
                        cand = video_path.with_suffix(ext)
                        if cand.exists():
                            audio_file = cand
                            break
                    if audio_file is None:
                        warning_msg = (
                            f"No .m4a audio sidecar found for: {video_path.name}"
                        )
                        logger.warning(warning_msg)
                        result["warnings"].append(warning_msg)
                    else:
                        # Sanity check: flag a large video/audio length gap (wrong
                        # pairing, truncated recording) but still mux — the user
                        # opted in and may want whatever audio exists. The tolerance
                        # is relative (max of a 2s floor and 5% of the clip) so
                        # normal container-rounding gaps on a correctly paired clip
                        # don't cry wolf, while gross mispairings still warn.
                        video_dur = _ffprobe_duration(video_path)
                        audio_dur = _ffprobe_duration(audio_file)
                        if (
                            video_dur is not None
                            and audio_dur is not None
                            and abs(video_dur - audio_dur) > max(2.0, 0.05 * video_dur)
                        ):
                            warning_msg = (
                                f"Audio sidecar duration ({audio_dur:.1f}s) differs "
                                f"from video ({video_dur:.1f}s) for "
                                f"{video_path.name}; muxing anyway"
                            )
                            logger.warning(warning_msg)
                            result["warnings"].append(warning_msg)

                # Final output path; write to temp first, then atomic move (issue #162).
                # When overwrite (issue #163), destination = same as input file.
                if self.overwrite:
                    output_path = video_path
                else:
                    # MKV mode rewrites the extension so the preserved
                    # djmd/dbgi data streams land in a Matroska container.
                    out_suffix = (
                        ".mkv" if self.container == "mkv" else video_path.suffix
                    )
                    output_path = (
                        self.output_dir / f"{video_path.stem}_metadata{out_suffix}"
                    )
                temp_output_path = output_path.with_name(
                    output_path.stem + _TEMP_SUFFIX + output_path.suffix
                )

                # Embed metadata using ffmpeg into temp file
                if self.embed_metadata_ffmpeg(
                    video_path,
                    srt_path,
                    telemetry,
                    temp_output_path,
                    audio_path=audio_file,
                ):
                    if _validate_embedded_output(video_path, temp_output_path):
                        try:
                            os.replace(temp_output_path, output_path)
                        except OSError as e:
                            logger.error(
                                "Failed to move temp output to %s: %s",
                                output_path,
                                e,
                            )
                            if temp_output_path.exists():
                                try:
                                    temp_output_path.unlink()
                                except OSError:
                                    pass
                            progress.advance(task)
                            continue
                        success_count += 1

                        # Optionally use exiftool for additional metadata
                        if use_exiftool:
                            self.embed_metadata_exiftool(output_path, telemetry)

                        # Save telemetry summary as JSON (atomic write)
                        json_path = output_path.parent / f"{video_path.stem}_telemetry.json"
                        json_tmp_path = Path(str(json_path) + _TEMP_SUFFIX)
                        json_data = {
                            "filename": video_path.name,
                            "first_gps": telemetry["first_gps"],
                            "average_gps": telemetry["avg_gps"],
                            "max_altitude": telemetry["max_altitude"],
                            "max_relative_altitude": telemetry.get("max_rel_altitude"),
                            "flight_duration": telemetry["flight_duration"],
                            "num_gps_points": len(telemetry["gps_coords"]),
                            "camera_settings": telemetry.get("camera_settings", {}),
                            "dat_records": len(telemetry.get("dat_records", [])),
                        }
                        # Barometric altitude is only present on some formats
                        # (Avata 2 / Matrice 300); include it when captured.
                        if telemetry.get("barometers"):
                            json_data["barometers"] = telemetry["barometers"]
                        # HOME is opt-in; emit the key only when extracted. It is
                        # null when requested but absent/dropped, present as a
                        # marker otherwise. Never affects the MP4 itself.
                        if "home" in telemetry:
                            h = telemetry["home"]
                            json_data["home"] = (
                                {"lat": h.lat, "lon": h.lon, "alt": h.alt} if h else None
                            )
                        try:
                            with open(json_tmp_path, "w", encoding="utf-8") as f:
                                json.dump(json_data, f, indent=2)
                            os.replace(json_tmp_path, json_path)
                        except OSError as e:
                            logger.warning("Failed to write telemetry JSON: %s", e)
                            if json_tmp_path.exists():
                                try:
                                    json_tmp_path.unlink()
                                except OSError:
                                    pass
                    else:
                        logger.error(
                            "Validation failed for %s; output not saved.",
                            video_path.name,
                        )
                        if temp_output_path.exists():
                            try:
                                temp_output_path.unlink()
                            except OSError:
                                pass
                    progress.advance(task)
                else:
                    if temp_output_path.exists():
                        try:
                            temp_output_path.unlink()
                        except OSError:
                            pass
                    progress.advance(task)

        result["processed"] = success_count

        logger.info(
            "Processing complete! Successfully processed %d/%d videos",
            success_count,
            len(video_files),
        )
        logger.info("Processed files saved to: %s", self.output_dir)

        return result

embed_metadata_exiftool(video_path, telemetry)

Use exiftool to embed GPS metadata (alternative/additional method).

Source code in src/dji_metadata_embedder/embedder.py
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
def embed_metadata_exiftool(
    self, video_path: Path, telemetry: Dict[str, Any]
) -> bool:
    """Use exiftool to embed GPS metadata (alternative/additional method)."""
    import os
    import platform

    try:
        if not telemetry["first_gps"]:
            return False

        lat, lon = telemetry["first_gps"]

        # Check for exiftool in environment variable first (Windows)
        exiftool_cmd = "exiftool"
        if platform.system() == "Windows":
            env_exiftool = os.environ.get("DJIEMBED_EXIFTOOL_PATH")
            if env_exiftool and Path(env_exiftool).exists():
                exiftool_cmd = env_exiftool

        cmd = [
            exiftool_cmd,
            f"-GPSLatitude={abs(lat)}",
            f'-GPSLatitudeRef={"N" if lat >= 0 else "S"}',
            f"-GPSLongitude={abs(lon)}",
            f'-GPSLongitudeRef={"E" if lon >= 0 else "W"}',
            "-overwrite_original",
            str(video_path),
        ]

        if telemetry["max_altitude"]:
            cmd.insert(-2, f'-GPSAltitude={telemetry["max_altitude"]}')
            cmd.insert(-2, "-GPSAltitudeRef=0")  # Above sea level

        result = subprocess.run(cmd, capture_output=True, text=True)
        return result.returncode == 0

    except Exception as e:
        logger.error("ExifTool error: %s", e)
        return False

embed_metadata_ffmpeg(video_path, srt_path, telemetry, output_path, audio_path=None)

Embed SRT as subtitle track and add metadata using ffmpeg.

When audio_path is given (Neo 2 records audio to a separate .m4a, issue #246), it is added as a third input and its audio stream is muxed into the output. Audio is appended last so the SRT keeps input index 1 and the existing -map 1 subtitle mapping stays correct.

Source code in src/dji_metadata_embedder/embedder.py
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
def embed_metadata_ffmpeg(
    self,
    video_path: Path,
    srt_path: Path,
    telemetry: Dict[str, Any],
    output_path: Path,
    audio_path: Optional[Path] = None,
) -> bool:
    """Embed SRT as subtitle track and add metadata using ffmpeg.

    When *audio_path* is given (Neo 2 records audio to a separate .m4a,
    issue #246), it is added as a third input and its audio stream is muxed
    into the output. Audio is appended last so the SRT keeps input index 1
    and the existing ``-map 1`` subtitle mapping stays correct.
    """
    import os
    import platform

    try:
        # Check for ffmpeg in environment variable first (Windows)
        ffmpeg_cmd = "ffmpeg"
        if platform.system() == "Windows":
            env_ffmpeg = os.environ.get("DJIEMBED_FFMPEG_PATH")
            if env_ffmpeg and Path(env_ffmpeg).exists():
                ffmpeg_cmd = env_ffmpeg

        # Build ffmpeg command.
        #
        # MP4 (default): -map 0 -map -0:d -map 1 keeps every video/audio
        # stream from the source plus the SRT subtitle, but explicitly
        # drops DJI's proprietary data streams (`djmd` / `dbgi`, gyro and
        # debug metadata on Air 3S / Neo 2 / etc.). The MP4 muxer cannot
        # tag their codec, so without the drop the whole mux fails with
        # "Could not find tag for codec none". Background: GH discussion
        # #192, follow-up to #193.
        #
        # MKV (--container mkv): Matroska's codec table accepts those
        # streams, so we keep -map 0 (no -0:d) to round-trip djmd/dbgi
        # byte-for-byte, and use the Matroska-native `srt` subtitle codec
        # rather than the MP4-only `mov_text` (issue #197).
        if self.container == "mkv":
            stream_maps = ["-map", "0", "-map", "1"]
            subtitle_codec = "srt"
        else:
            stream_maps = ["-map", "0", "-map", "-0:d", "-map", "1"]
            subtitle_codec = "mov_text"

        # Audio sidecar is the third input (index 2); pull its audio stream
        # with -map 2:a. Keeping it last preserves the SRT's input index 1.
        inputs = ["-i", str(video_path), "-i", str(srt_path)]
        if audio_path is not None:
            inputs += ["-i", str(audio_path)]
            stream_maps += ["-map", "2:a"]

        cmd = [
            ffmpeg_cmd,
            *inputs,
            *stream_maps,
            "-c",
            "copy",
            "-c:s",
            subtitle_codec,
            "-metadata:s:s:0",
            "language=eng",
            "-metadata:s:s:0",
            "title=Telemetry Data",
        ]

        # Add GPS metadata if available
        if telemetry["first_gps"]:
            lat, lon = telemetry["first_gps"]
            cmd.extend(
                [
                    "-metadata",
                    f"location={lat:+.6f}{lon:+.6f}/",
                    "-metadata",
                    f"location-eng={lat:+.6f}{lon:+.6f}/",
                ]
            )

        # Add other metadata
        if telemetry["max_altitude"]:
            cmd.extend(["-metadata", f'altitude={telemetry["max_altitude"]:.1f}'])

        # Add creation date from filename if it matches DJI pattern
        filename_date_match = re.search(r"DJI_(\d{8})_(\d{6})", video_path.stem)
        if filename_date_match:
            date_str = filename_date_match.group(1)
            time_str = filename_date_match.group(2)
            creation_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]} {time_str[:2]}:{time_str[2:4]}:{time_str[4:6]}"
            cmd.extend(["-metadata", f"creation_time={creation_date}"])

        # Output file
        cmd.extend(["-y", str(output_path)])

        # Run ffmpeg
        result = subprocess.run(cmd, capture_output=True, text=True)

        if result.returncode == 0:
            logger.info("Successfully processed: %s", video_path.name)
            return True
        else:
            logger.error("FFmpeg error for %s: %s", video_path.name, result.stderr)
            return False

    except Exception as e:
        logger.error("Error processing %s: %s", video_path.name, e)
        return False

parse_dji_srt(srt_path)

Parse DJI SRT file and extract telemetry data.

Source code in src/dji_metadata_embedder/embedder.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
def parse_dji_srt(self, srt_path: Path) -> Dict[str, Any]:
    """Parse DJI SRT file and extract telemetry data."""
    telemetry_data: Dict[str, Any] = {
        "gps_coords": [],
        "altitudes": [],
        "rel_altitudes": [],
        "speeds": [],
        "timestamps": [],
        "camera_info": [],
        "first_gps": None,
        "avg_gps": None,
        "max_altitude": None,
        "flight_duration": None,
        "srt_counts": [],
        "diff_times": [],
        "barometers": [],
    }

    try:
        with open(srt_path, "r", encoding="utf-8") as f:
            content = f.read()

        # Split into subtitle blocks
        blocks = content.strip().split("\n\n")

        for block in blocks:
            lines = block.strip().split("\n")
            if len(lines) >= 3:
                # Parse timestamp
                timestamp_line = lines[1]
                timestamp_match = re.search(
                    r"(\d{2}:\d{2}:\d{2},\d{3})", timestamp_line
                )
                if timestamp_match:
                    telemetry_data["timestamps"].append(timestamp_match.group(1))

                # Parse telemetry data (usually in the 3rd line onward)
                telemetry_line = " ".join(lines[2:])

                # Remove HTML tags if present (newer DJI format)
                if "<font" in telemetry_line:
                    telemetry_line = re.sub(r"<[^>]+>", "", telemetry_line)

                # Detect comprehensive format with frame counters. Older
                # firmware labels this ``SrtCnt``; newer firmware (Neo,
                # Mini 5 Pro, Avata 360) uses ``FrameCnt``. Accept either
                # spelling and store under the existing ``srt_counts`` key
                # for backwards compatibility.
                counter_match = re.search(
                    r"(?:Srt|Frame)Cnt\s*:\s*(\d+)", telemetry_line
                )
                diff_time_match = re.search(
                    r"DiffTime\s*:\s*([^\s]+)", telemetry_line
                )
                if counter_match or diff_time_match:
                    telemetry_data.setdefault("srt_counts", []).append(
                        int(counter_match.group(1)) if counter_match else None
                    )
                    telemetry_data.setdefault("diff_times", []).append(
                        diff_time_match.group(1) if diff_time_match else None
                    )

                # Extract GPS coordinates - Multiple format support
                # Format 1: [latitude: xx.xxx] [longitude: xx.xxx]
                lat_match = re.search(
                    r"\[latitude:\s*([+-]?\d+\.?\d*)\]", telemetry_line
                )
                lon_match = re.search(
                    r"\[longitude:\s*([+-]?\d+\.?\d*)\]", telemetry_line
                )

                # Format 2: GPS(lat,lon,alt) — also tolerates a unit
                # suffix on altitude (e.g. M300 emits ``GPS(lat,lon,0.0M)``)
                # and an optional space between ``GPS`` and ``(`` used by
                # the P4 RTK compact single-line family.
                if not lat_match or not lon_match:
                    gps_match = re.search(
                        r"GPS\s*\(([+-]?\d+\.?\d*),\s*([+-]?\d+\.?\d*),\s*([+-]?\d+\.?\d*)[A-Za-z]*\)",
                        telemetry_line,
                    )
                    if gps_match:
                        lat_match = gps_match
                        lon_match = gps_match
                        lat = float(gps_match.group(1))
                        lon = float(gps_match.group(2))
                    else:
                        lat = None
                        lon = None
                else:
                    lat = float(lat_match.group(1))
                    lon = float(lon_match.group(1))

                if lat is not None and lon is not None:
                    telemetry_data["gps_coords"].append((lat, lon))

                # Extract altitudes - [rel_alt: x.xxx abs_alt: xxx.xxx]
                alt_match = re.search(
                    r"\[rel_alt:\s*([+-]?\d+\.?\d*)\s*abs_alt:\s*([+-]?\d+\.?\d*)\]",
                    telemetry_line,
                )
                if alt_match:
                    rel_alt = float(alt_match.group(1))
                    abs_alt = float(alt_match.group(2))
                    telemetry_data["rel_altitudes"].append(rel_alt)
                    telemetry_data["altitudes"].append(abs_alt)

                # Extract barometric altitude. Two delimiter variants exist:
                # Avata 2 parenthesised ``BAROMETER(91.2)`` and Matrice 300
                # colon form ``BAROMETER:0.3M`` (optional trailing unit).
                # Barometric height is more reliable than GPS altitude in
                # tight / FPV environments.
                baro_match = re.search(
                    r"BAROMETER[(:]\s*([+-]?\d+\.?\d*)[A-Za-z]*\)?",
                    telemetry_line,
                )
                if baro_match:
                    telemetry_data["barometers"].append(
                        float(baro_match.group(1))
                    )

                # Extract camera info including extended fields
                iso_match = re.search(r"\[iso\s*:\s*(\d+)\]", telemetry_line)
                shutter_match = re.search(
                    r"\[shutter\s*:\s*([^\]]+)\]", telemetry_line
                )
                # Aperture is reported two ways: legacy models use an
                # f-number*100 integer (``[fnum : 170]`` → f/1.7) while
                # current models (Avata 360, Mini 5 Pro, …) emit a literal
                # decimal (``[fnum: 1.9]``). Capture both; the value is
                # stored verbatim without interpretation.
                fnum_match = re.search(
                    r"\[fnum\s*:\s*([+-]?\d+\.?\d*)\]", telemetry_line
                )
                ev_match = re.search(r"\[ev\s*:\s*([^\]]+)\]", telemetry_line)
                # P4 RTK compact single-line family uses free-standing
                # tokens (``F/5.6, SS 400, ISO 100, EV 0``) rather than
                # the bracketed ``[fnum:…]`` syntax. Fall back to those
                # when the bracket form did not match.
                if not fnum_match:
                    fnum_match = re.search(
                        r"(?<![A-Za-z])F/(\d+\.?\d*)", telemetry_line
                    )
                if not shutter_match:
                    shutter_match = re.search(
                        r"(?<![A-Za-z])SS\s+(\d+(?:\.\d+)?)", telemetry_line
                    )
                if not iso_match:
                    iso_match = re.search(
                        r"(?<![A-Za-z])ISO\s+(\d+)", telemetry_line
                    )
                if not ev_match:
                    ev_match = re.search(
                        r"(?<![A-Za-z])EV\s+([+-]?\d+\.?\d*)", telemetry_line
                    )
                ct_match = re.search(r"\[ct\s*:\s*([^\]]+)\]", telemetry_line)
                color_md_match = re.search(
                    r"\[color_md\s*:\s*([^\]]+)\]", telemetry_line
                )
                focal_len_match = re.search(
                    r"\[focal_len\s*:\s*([^\]]+)\]", telemetry_line
                )

                if (
                    iso_match
                    or shutter_match
                    or fnum_match
                    or ev_match
                    or ct_match
                    or color_md_match
                    or focal_len_match
                ):
                    camera_data = {}
                    if iso_match:
                        camera_data["iso"] = iso_match.group(1)
                    if shutter_match:
                        camera_data["shutter"] = shutter_match.group(1)
                    if fnum_match:
                        camera_data["fnum"] = fnum_match.group(1)
                    if ev_match:
                        camera_data["ev"] = ev_match.group(1)
                    if ct_match:
                        camera_data["ct"] = ct_match.group(1)
                    if color_md_match:
                        camera_data["color_md"] = color_md_match.group(1)
                    if focal_len_match:
                        camera_data["focal_len"] = focal_len_match.group(1)

                    telemetry_data["camera_info"].append(camera_data)

        # Calculate summary statistics. Exclude pre-GPS-lock ``(0, 0)``
        # no-fix frames so the clip is not geotagged at Null Island and the
        # average is not dragged toward it (see ``is_gps_fix``).
        valid_coords = [
            c for c in telemetry_data["gps_coords"] if is_gps_fix(c[0], c[1])
        ]
        if valid_coords:
            telemetry_data["first_gps"] = valid_coords[0]
            avg_lat = sum(coord[0] for coord in valid_coords) / len(valid_coords)
            avg_lon = sum(coord[1] for coord in valid_coords) / len(valid_coords)
            telemetry_data["avg_gps"] = (avg_lat, avg_lon)

        if telemetry_data["altitudes"]:
            telemetry_data["max_altitude"] = max(telemetry_data["altitudes"])

        if telemetry_data["rel_altitudes"]:
            telemetry_data["max_rel_altitude"] = max(
                telemetry_data["rel_altitudes"]
            )

        if telemetry_data["timestamps"] and len(telemetry_data["timestamps"]) > 1:
            # Calculate flight duration
            first_time = telemetry_data["timestamps"][0].split(",")[0]
            last_time = telemetry_data["timestamps"][-1].split(",")[0]
            telemetry_data["flight_duration"] = f"{first_time} - {last_time}"

        # Get camera settings from first frame
        if telemetry_data["camera_info"]:
            telemetry_data["camera_settings"] = telemetry_data["camera_info"][0]

        if self.extract_home:
            telemetry_data["home"] = parse_home(content)

    except Exception as e:
        logger.error("Error parsing SRT file %s: %s", srt_path, e)

    return telemetry_data

process_directory(use_exiftool=False, on_progress=None)

Process all MP4/SRT pairs in the directory.

on_progress(index, total, name) is called (1-based) as each video is picked up; passing it also disables the interactive progress bar (the caller owns the display).

Returns:

Type Description
Dict[str, Any]

Dict containing processing results and statistics

Source code in src/dji_metadata_embedder/embedder.py
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
def process_directory(
    self,
    use_exiftool: bool = False,
    on_progress: Callable[[int, int, str], None] | None = None,
) -> Dict[str, Any]:
    """Process all MP4/SRT pairs in the directory.

    ``on_progress(index, total, name)`` is called (1-based) as each video
    is picked up; passing it also disables the interactive progress bar
    (the caller owns the display).

    Returns:
        Dict containing processing results and statistics
    """
    # Find all supported video files (.mp4 plus DJI 360 .osv/.lrf).
    video_files = discover_video_files(self.directory)

    # Initialize result structure
    result: Dict[str, Any] = {
        "processed": 0,
        "total_files": len(video_files),
        "warnings": [],
        "errors": [],
        "output_directory": str(self.directory if self.overwrite else self.output_dir),
    }

    if not video_files:
        warning_msg = f"No MP4 files found in {self.directory}"
        logger.warning(warning_msg)
        result["warnings"].append(warning_msg)
        return result

    logger.info("Found %d video files to process", len(video_files))
    if self.overwrite:
        logger.info("Overwrite mode: embedding in place (destination = input folder)\n")
    else:
        logger.info("Output directory: %s\n", self.output_dir)

    success_count = 0

    # The caller owns the display when it passes a callback.
    bar = Progress(disable=True) if on_progress is not None else Progress()
    with bar as progress:
        task = progress.add_task("Processing videos", total=len(video_files))
        for index, video_path in enumerate(video_files, start=1):
            if on_progress is not None:
                on_progress(index, len(video_files), video_path.name)
            # Look for corresponding SRT file
            srt_path = video_path.with_suffix(".srt")
            if not srt_path.exists():
                srt_path = video_path.with_suffix(".SRT")

            if not srt_path.exists():
                warning_msg = f"No SRT file found for: {video_path.name}"
                logger.warning(warning_msg)
                result["warnings"].append(warning_msg)
                progress.advance(task)
                continue

            progress.update(task, description=video_path.name)
            logger.debug("Processing %s", video_path.name)

            # Parse SRT telemetry
            telemetry = self.parse_dji_srt(srt_path)
            apply_redaction(telemetry, self.redact)

            # Optionally parse DAT telemetry
            dat_file = None
            if self.dat_path:
                dat_file = self.dat_path
            elif self.dat_autoscan:
                dat_file = self._find_dat_log(video_path, result["warnings"])
            if dat_file and dat_file.exists():
                try:
                    dat_data = parse_dat_v13(dat_file)
                    telemetry["dat_records"] = dat_data.get("records", [])
                except Exception as e:
                    logger.warning(
                        "Failed to parse DAT file %s: %s", dat_file.name, e
                    )

            # Optionally pair a separate audio sidecar. The Neo 2 records
            # audio to a same-basename .m4a next to the silent video; mux it
            # back in on request (issue #246). Warn-and-continue when absent
            # so mixed folders (some clips with audio, some without) still
            # process every video.
            audio_file = None
            if self.audio_sidecar:
                for ext in (".m4a", ".M4A"):
                    cand = video_path.with_suffix(ext)
                    if cand.exists():
                        audio_file = cand
                        break
                if audio_file is None:
                    warning_msg = (
                        f"No .m4a audio sidecar found for: {video_path.name}"
                    )
                    logger.warning(warning_msg)
                    result["warnings"].append(warning_msg)
                else:
                    # Sanity check: flag a large video/audio length gap (wrong
                    # pairing, truncated recording) but still mux — the user
                    # opted in and may want whatever audio exists. The tolerance
                    # is relative (max of a 2s floor and 5% of the clip) so
                    # normal container-rounding gaps on a correctly paired clip
                    # don't cry wolf, while gross mispairings still warn.
                    video_dur = _ffprobe_duration(video_path)
                    audio_dur = _ffprobe_duration(audio_file)
                    if (
                        video_dur is not None
                        and audio_dur is not None
                        and abs(video_dur - audio_dur) > max(2.0, 0.05 * video_dur)
                    ):
                        warning_msg = (
                            f"Audio sidecar duration ({audio_dur:.1f}s) differs "
                            f"from video ({video_dur:.1f}s) for "
                            f"{video_path.name}; muxing anyway"
                        )
                        logger.warning(warning_msg)
                        result["warnings"].append(warning_msg)

            # Final output path; write to temp first, then atomic move (issue #162).
            # When overwrite (issue #163), destination = same as input file.
            if self.overwrite:
                output_path = video_path
            else:
                # MKV mode rewrites the extension so the preserved
                # djmd/dbgi data streams land in a Matroska container.
                out_suffix = (
                    ".mkv" if self.container == "mkv" else video_path.suffix
                )
                output_path = (
                    self.output_dir / f"{video_path.stem}_metadata{out_suffix}"
                )
            temp_output_path = output_path.with_name(
                output_path.stem + _TEMP_SUFFIX + output_path.suffix
            )

            # Embed metadata using ffmpeg into temp file
            if self.embed_metadata_ffmpeg(
                video_path,
                srt_path,
                telemetry,
                temp_output_path,
                audio_path=audio_file,
            ):
                if _validate_embedded_output(video_path, temp_output_path):
                    try:
                        os.replace(temp_output_path, output_path)
                    except OSError as e:
                        logger.error(
                            "Failed to move temp output to %s: %s",
                            output_path,
                            e,
                        )
                        if temp_output_path.exists():
                            try:
                                temp_output_path.unlink()
                            except OSError:
                                pass
                        progress.advance(task)
                        continue
                    success_count += 1

                    # Optionally use exiftool for additional metadata
                    if use_exiftool:
                        self.embed_metadata_exiftool(output_path, telemetry)

                    # Save telemetry summary as JSON (atomic write)
                    json_path = output_path.parent / f"{video_path.stem}_telemetry.json"
                    json_tmp_path = Path(str(json_path) + _TEMP_SUFFIX)
                    json_data = {
                        "filename": video_path.name,
                        "first_gps": telemetry["first_gps"],
                        "average_gps": telemetry["avg_gps"],
                        "max_altitude": telemetry["max_altitude"],
                        "max_relative_altitude": telemetry.get("max_rel_altitude"),
                        "flight_duration": telemetry["flight_duration"],
                        "num_gps_points": len(telemetry["gps_coords"]),
                        "camera_settings": telemetry.get("camera_settings", {}),
                        "dat_records": len(telemetry.get("dat_records", [])),
                    }
                    # Barometric altitude is only present on some formats
                    # (Avata 2 / Matrice 300); include it when captured.
                    if telemetry.get("barometers"):
                        json_data["barometers"] = telemetry["barometers"]
                    # HOME is opt-in; emit the key only when extracted. It is
                    # null when requested but absent/dropped, present as a
                    # marker otherwise. Never affects the MP4 itself.
                    if "home" in telemetry:
                        h = telemetry["home"]
                        json_data["home"] = (
                            {"lat": h.lat, "lon": h.lon, "alt": h.alt} if h else None
                        )
                    try:
                        with open(json_tmp_path, "w", encoding="utf-8") as f:
                            json.dump(json_data, f, indent=2)
                        os.replace(json_tmp_path, json_path)
                    except OSError as e:
                        logger.warning("Failed to write telemetry JSON: %s", e)
                        if json_tmp_path.exists():
                            try:
                                json_tmp_path.unlink()
                            except OSError:
                                pass
                else:
                    logger.error(
                        "Validation failed for %s; output not saved.",
                        video_path.name,
                    )
                    if temp_output_path.exists():
                        try:
                            temp_output_path.unlink()
                        except OSError:
                            pass
                progress.advance(task)
            else:
                if temp_output_path.exists():
                    try:
                        temp_output_path.unlink()
                    except OSError:
                        pass
                progress.advance(task)

    result["processed"] = success_count

    logger.info(
        "Processing complete! Successfully processed %d/%d videos",
        success_count,
        len(video_files),
    )
    logger.info("Processed files saved to: %s", self.output_dir)

    return result

discover_video_files(directory)

Return the video files in directory the embedder can process.

Globs each supported extension in both cases (*.mp4 and *.MP4) so discovery works on case-sensitive file systems too, then returns a sorted, de-duplicated list. On case-insensitive file systems the two globs would otherwise yield the same path twice, hence the set.

Source code in src/dji_metadata_embedder/embedder.py
109
110
111
112
113
114
115
116
117
118
119
120
121
def discover_video_files(directory: Path) -> list[Path]:
    """Return the video files in *directory* the embedder can process.

    Globs each supported extension in both cases (``*.mp4`` and ``*.MP4``) so
    discovery works on case-sensitive file systems too, then returns a sorted,
    de-duplicated list. On case-insensitive file systems the two globs would
    otherwise yield the same path twice, hence the set.
    """
    matches: set[Path] = set()
    for ext in _VIDEO_EXTENSIONS:
        matches.update(directory.glob(f"*.{ext}"))
        matches.update(directory.glob(f"*.{ext.upper()}"))
    return sorted(matches)

run_doctor(online=None)

Print system and dependency information.

online forces the opt-in update check on/off for this run (and persists the choice); None uses the remembered consent, prompting once on interactive terminals.

Source code in src/dji_metadata_embedder/embedder.py
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
def run_doctor(online: bool | None = None) -> None:
    """Print system and dependency information.

    ``online`` forces the opt-in update check on/off for this run (and
    persists the choice); ``None`` uses the remembered consent, prompting
    once on interactive terminals.
    """
    from .utilities import check_dependencies

    # System information
    logger.info("System information:")
    sys_info = system_info.get_system_summary()
    for key, value in sys_info.items():
        logger.info("  %s: %s", key, value)

    # Check dependencies using the utilities function which checks environment vars
    logger.info("Dependency check:")
    deps_ok, missing = check_dependencies()

    logger.info("  ffmpeg: %s", "FOUND" if "ffmpeg" not in missing else "MISSING")
    if "exiftool" not in missing:
        from .utils import exiftool as exiftool_utils

        ver = exiftool_utils.exiftool_version()
        if ver:
            logger.info(
                "  exiftool: FOUND %s (%s: %s)",
                ver,
                exiftool_utils.exiftool_source(),
                exiftool_utils.exiftool_exe(),
            )
            logger.info(
                "  timed-metadata decode: %s",
                exiftool_utils.describe_decode_capability(ver),
            )
        else:
            logger.info("  exiftool: FOUND")
    else:
        logger.info("  exiftool: MISSING")

    if deps_ok:
        logger.info("All dependencies verified.")
    else:
        logger.warning("Some dependencies are missing or not functional.")

    # Opt-in update check (doctor is the only command that may go online).
    from .utils import update_check

    for line in update_check.update_report(online):
        logger.info("%s", line)

Conversion utilities for DJI SRT telemetry files.

This module contains helper functions for extracting telemetry data from DJI subtitle (SRT) files. The data can be converted to GPX tracks or CSV logs, and directories of SRT files can be processed in batch. A small CLI wrapper is provided for convenience.

batch_convert_to_csv(directory)

Convert all SRT files in a directory to CSV files.

Source code in src/dji_metadata_embedder/telemetry_converter.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
def batch_convert_to_csv(directory: Path | str) -> None:
    """
    Convert all SRT files in a directory to CSV files.
    """
    directory = Path(directory)
    srt_files = list(directory.glob("*.srt")) + list(directory.glob("*.SRT"))

    if not srt_files:
        logger.warning("No SRT files found in %s", directory)
        return

    csv_dir = directory / "csv_logs"
    csv_dir.mkdir(exist_ok=True)

    logger.info("Found %d SRT files to convert", len(srt_files))
    with Progress() as progress:
        task = progress.add_task("Converting", total=len(srt_files))
        for srt_file in srt_files:
            output_file = csv_dir / f"{srt_file.stem}.csv"
            try:
                extract_telemetry_to_csv(srt_file, output_file)
            except Exception as e:
                logger.error("Error converting %s: %s", srt_file.name, e)
            progress.advance(task)

    logger.info("CSV files saved to: %s", csv_dir)

batch_convert_to_gpx(directory)

Convert all SRT files in a directory to GPX files.

Source code in src/dji_metadata_embedder/telemetry_converter.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def batch_convert_to_gpx(directory: Path | str) -> None:
    """
    Convert all SRT files in a directory to GPX files.
    """
    directory = Path(directory)
    srt_files = list(directory.glob("*.srt")) + list(directory.glob("*.SRT"))

    if not srt_files:
        logger.warning("No SRT files found in %s", directory)
        return

    gpx_dir = directory / "gpx_tracks"
    gpx_dir.mkdir(exist_ok=True)

    logger.info("Found %d SRT files to convert", len(srt_files))
    with Progress() as progress:
        task = progress.add_task("Converting", total=len(srt_files))
        for srt_file in srt_files:
            output_file = gpx_dir / f"{srt_file.stem}.gpx"
            try:
                extract_telemetry_to_gpx(srt_file, output_file)
            except Exception as e:
                logger.error("Error converting %s: %s", srt_file.name, e)
            progress.advance(task)

    logger.info("GPX files saved to: %s", gpx_dir)

convert_to_csv(srt_file, output_file=None)

Wrapper for backwards compatibility.

Converts srt_file to CSV using :func:extract_telemetry_to_csv.

Source code in src/dji_metadata_embedder/telemetry_converter.py
520
521
522
523
524
525
def convert_to_csv(srt_file: str | Path, output_file: str | Path | None = None) -> Path:
    """Wrapper for backwards compatibility.

    Converts ``srt_file`` to CSV using :func:`extract_telemetry_to_csv`.
    """
    return extract_telemetry_to_csv(srt_file, output_file)

convert_to_gpx(srt_file, output_file=None)

Wrapper for backwards compatibility.

Converts srt_file to GPX using :func:extract_telemetry_to_gpx.

Source code in src/dji_metadata_embedder/telemetry_converter.py
512
513
514
515
516
517
def convert_to_gpx(srt_file: str | Path, output_file: str | Path | None = None) -> Path:
    """Wrapper for backwards compatibility.

    Converts ``srt_file`` to GPX using :func:`extract_telemetry_to_gpx`.
    """
    return extract_telemetry_to_gpx(srt_file, output_file)

extract_telemetry_to_csv(srt_file, output_file=None, tz_offset=None, extract_home=False, redact='none')

Extract all telemetry data from a DJI SRT file to CSV.

When blocks carry an absolute wall-clock datetime, three extra columns are filled: datetime_utc (ISO 8601; local->UTC via tz_offset or mtime auto-detect) and the solar sun_azimuth / sun_elevation for that instant and position. Formats without an absolute datetime leave those three columns blank.

Source code in src/dji_metadata_embedder/telemetry_converter.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
def extract_telemetry_to_csv(
    srt_file: Path | str,
    output_file: Path | str | None = None,
    tz_offset: timedelta | None = None,
    extract_home: bool = False,
    redact: str = "none",
) -> Path:
    """Extract all telemetry data from a DJI SRT file to CSV.

    When blocks carry an absolute wall-clock datetime, three extra columns are
    filled: ``datetime_utc`` (ISO 8601; local->UTC via *tz_offset* or mtime
    auto-detect) and the solar ``sun_azimuth`` / ``sun_elevation`` for that
    instant and position. Formats without an absolute datetime leave those three
    columns blank.
    """
    srt_path = Path(srt_file)
    output_path = Path(output_file) if output_file else srt_path.with_suffix(".csv")

    from .mp4_telemetry import is_video
    from .utilities import load_samples

    if is_video(srt_path):
        return _csv_from_samples(load_samples(srt_path), output_path, redact)

    home_cols: list[str] = []
    home = None
    if extract_home:
        home = redact_home(parse_home(srt_path.read_text(encoding="utf-8")), redact)
        home_cols = ["home_lat", "home_lon", "home_alt"]
    columns = list(_CSV_COLUMNS) + home_cols

    rows = []
    # Per-row solar inputs, index-aligned with ``rows``: (abs_dt, lat, lon).
    solar_inputs: list[tuple[datetime | None, float | None, float | None]] = []

    with open(srt_path, "r", encoding="utf-8") as f:
        content = f.read()

    blocks = content.strip().split("\n\n")

    for block in blocks:
        lines = block.strip().split("\n")
        if len(lines) >= 3:
            timestamp_line = lines[1]
            timestamp_match = re.search(r"(\d{2}:\d{2}:\d{2},\d{3})", timestamp_line)

            telemetry_line = " ".join(lines[2:])
            if "<font" in telemetry_line:
                telemetry_line = re.sub(r"<[^>]+>", "", telemetry_line)

            row = {c: "" for c in columns}
            if home is not None:
                row["home_lat"] = f"{home.lat}"
                row["home_lon"] = f"{home.lon}"
                row["home_alt"] = "" if home.alt is None else f"{home.alt}"
            row["timestamp"] = timestamp_match.group(1) if timestamp_match else ""

            # GPS coordinates
            lat_match = re.search(r"\[latitude:\s*([+-]?\d+\.?\d*)\]", telemetry_line)
            lon_match = re.search(r"\[longitude:\s*([+-]?\d+\.?\d*)\]", telemetry_line)
            lat_val: float | None = None
            lon_val: float | None = None
            if lat_match and lon_match:
                lat_val = float(lat_match.group(1))
                lon_val = float(lon_match.group(1))
                # Track redaction (#248): fuzz -> 3 decimals (~100 m, same
                # policy as redact_coords); drop -> blank GPS cells. Fuzzing
                # lat_val/lon_val here also feeds the fuzzed position into
                # the solar pass below.
                if redact == "fuzz":
                    lat_val, lon_val = round(lat_val, 3), round(lon_val, 3)
                if redact == "drop":
                    lat_val = None
                    lon_val = None
                else:
                    row["latitude"] = f"{lat_val}"
                    row["longitude"] = f"{lon_val}"

            # Altitude
            alt_match = re.search(
                r"\[rel_alt:\s*([+-]?\d+\.?\d*)\s*abs_alt:\s*([+-]?\d+\.?\d*)\]",
                telemetry_line,
            )
            if alt_match:
                row["rel_altitude"] = alt_match.group(1)
                row["abs_altitude"] = alt_match.group(2)

            # Camera settings
            iso_match = re.search(r"\[iso\s*:\s*(\d+)\]", telemetry_line)
            shutter_match = re.search(r"\[shutter\s*:\s*([^\]]+)\]", telemetry_line)
            fnum_match = re.search(r"\[fnum\s*:\s*(\d+)\]", telemetry_line)
            ev_match = re.search(r"\[ev\s*:\s*([^\]]+)\]", telemetry_line)
            ct_match = re.search(r"\[ct\s*:\s*([^\]]+)\]", telemetry_line)
            color_md_match = re.search(r"\[color_md\s*:\s*([^\]]+)\]", telemetry_line)
            focal_len_match = re.search(r"\[focal_len\s*:\s*([^\]]+)\]", telemetry_line)

            if iso_match:
                row["iso"] = iso_match.group(1)
            if shutter_match:
                row["shutter"] = shutter_match.group(1)
            if fnum_match:
                row["fnum"] = fnum_match.group(1)
            if ev_match:
                row["ev"] = ev_match.group(1)
            if ct_match:
                row["ct"] = ct_match.group(1)
            if color_md_match:
                row["color_md"] = color_md_match.group(1)
            if focal_len_match:
                row["focal_len"] = focal_len_match.group(1)

            rows.append(row)
            solar_inputs.append(
                (_parse_srt_datetime(telemetry_line), lat_val, lon_val)
            )

    # Resolve the single local->UTC offset, then fill UTC + solar columns.
    abs_times = [dt for dt, _, _ in solar_inputs if dt is not None]
    mtime_utc = datetime.fromtimestamp(
        srt_path.stat().st_mtime, tz=timezone.utc
    ).replace(tzinfo=None)
    offset = resolve_utc_offset(abs_times, tz_offset, mtime_utc)
    if offset is not None:
        for row, (abs_dt, lat_val, lon_val) in zip(rows, solar_inputs):
            if abs_dt is None:
                continue
            utc = abs_dt - offset
            # datetime_utc reveals when, not where — filled even when the
            # coordinates are redacted away (#248).
            row["datetime_utc"] = utc.strftime("%Y-%m-%dT%H:%M:%SZ")
            if lat_val is None or lon_val is None or not is_gps_fix(lat_val, lon_val):
                continue
            az, el = sun_position(lat_val, lon_val, utc)
            row["sun_azimuth"] = f"{az:.3f}"
            row["sun_elevation"] = f"{el:.3f}"

    # Write CSV
    import csv

    with open(output_path, "w", newline="", encoding="utf-8") as f:
        if rows:
            writer = csv.DictWriter(f, fieldnames=columns)
            writer.writeheader()
            writer.writerows(rows)

    logger.info("CSV file created: %s", output_path)
    return output_path

extract_telemetry_to_gpx(srt_file, output_file=None, tz_offset=None, extract_home=False, redact='none')

Extract GPS telemetry from a DJI SRT file and create a GPX file.

DJI SRT timestamps are local wall-clock time with no timezone. When a block carries an absolute datetime, its <time> is emitted as proper UTC ISO 8601. The local-to-UTC offset is taken from tz_offset when given, otherwise auto-detected from the SRT file's mtime (see :func:estimate_utc_offset). Formats without an absolute datetime fall back to the raw subtitle cue timestamp, as before.

Source code in src/dji_metadata_embedder/telemetry_converter.py
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
def extract_telemetry_to_gpx(
    srt_file: Path | str,
    output_file: Path | str | None = None,
    tz_offset: timedelta | None = None,
    extract_home: bool = False,
    redact: str = "none",
) -> Path:
    """Extract GPS telemetry from a DJI SRT file and create a GPX file.

    DJI SRT timestamps are local wall-clock time with no timezone. When a block
    carries an absolute datetime, its ``<time>`` is emitted as proper UTC
    ISO 8601. The local-to-UTC offset is taken from *tz_offset* when given,
    otherwise auto-detected from the SRT file's mtime (see
    :func:`estimate_utc_offset`). Formats without an absolute datetime fall back
    to the raw subtitle cue timestamp, as before.
    """
    srt_path = Path(srt_file)
    output_path = Path(output_file) if output_file else srt_path.with_suffix(".gpx")

    gps_points, is_utc = load_gps_points(srt_path)

    # Needed by the metadata <time> block below regardless of source.
    abs_times = [p["datetime"] for p in gps_points if p["datetime"] is not None]
    offset: timedelta | None
    if is_utc:
        # Video GPSDateTime is already UTC -> zero offset.
        offset = timedelta(0)
    else:
        # Resolve the local->UTC offset for points that carry an absolute datetime.
        mtime_utc = datetime.fromtimestamp(
            srt_path.stat().st_mtime, tz=timezone.utc
        ).replace(tzinfo=None)
        offset = resolve_utc_offset(abs_times, tz_offset, mtime_utc)

    def _point_time(point: dict) -> str | None:
        """Return the GPX <time> string for a point (UTC if datetime known)."""
        if point["datetime"] is not None and offset is not None:
            utc = point["datetime"] - offset
            return utc.strftime("%Y-%m-%dT%H:%M:%SZ")
        return point["time"]

    # The metadata <time> is the first point's UTC time when known, otherwise
    # the current time (legacy behaviour for datetime-less formats).
    if abs_times and offset is not None:
        metadata_time = (abs_times[0] - offset).strftime("%Y-%m-%dT%H:%M:%SZ")
    else:
        metadata_time = datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")

    # Write GPX file
    gpx_header = """<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="DJI SRT to GPX Converter"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://www.topografix.com/GPX/1/1"
    xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
    <name>{}</name>
    <time>{}</time>
</metadata>
""".format(Path(srt_file).stem, metadata_time)

    trk_open = """<trk>
    <name>DJI Flight Path</name>
    <trkseg>
"""

    gpx_footer = """    </trkseg>
</trk>
</gpx>"""

    home_wpt = ""
    if extract_home:
        home = redact_home(parse_home(srt_path.read_text(encoding="utf-8")), redact)
        if home is not None:
            ele = f"        <ele>{home.alt}</ele>\n" if home.alt is not None else ""
            home_wpt = (
                f'    <wpt lat="{home.lat}" lon="{home.lon}">\n'
                f"{ele}"
                f"        <name>HOME</name>\n"
                f"    </wpt>\n"
            )

    with open(output_path, "w", newline="", encoding="utf-8") as f:
        f.write(gpx_header)
        f.write(home_wpt)
        f.write(trk_open)
        # Track redaction (#248): drop -> empty <trkseg/>; fuzz -> 3 decimals
        # (~100 m), the same policy as redact_coords/redact_home.
        if redact != "drop":
            for point in gps_points:
                lat, lon = point["lat"], point["lon"]
                if redact == "fuzz":
                    lat, lon = round(lat, 3), round(lon, 3)
                f.write(f'        <trkpt lat="{lat}" lon="{lon}">\n')
                f.write(f'            <ele>{point["ele"]}</ele>\n')
                time_str = _point_time(point)
                if time_str:
                    f.write(f'            <time>{time_str}</time>\n')
                f.write("        </trkpt>\n")
        f.write(gpx_footer)

    logger.info("GPX file created: %s", output_path)
    return output_path

load_gps_points(path)

Return GPS points (_parse_gps_points shape) and whether they are UTC.

SRT: parse the text (datetimes are local wall-clock) -> is_utc=False. Video: read via load_samples (ExifTool GPSDateTime is absolute UTC) -> is_utc=True, so callers apply a zero local->UTC offset.

Source code in src/dji_metadata_embedder/telemetry_converter.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def load_gps_points(path: Path) -> tuple[list[dict[str, Any]], bool]:
    """Return GPS points (``_parse_gps_points`` shape) and whether they are UTC.

    SRT: parse the text (datetimes are local wall-clock) -> ``is_utc=False``.
    Video: read via ``load_samples`` (ExifTool ``GPSDateTime`` is absolute UTC)
    -> ``is_utc=True``, so callers apply a zero local->UTC offset.
    """
    from .mp4_telemetry import is_video
    from .utilities import load_samples

    if is_video(path):
        points = [
            {"lat": s.lat, "lon": s.lon, "ele": s.alt, "time": s.cue, "datetime": s.dt}
            for s in load_samples(path)
        ]
        return points, True
    return _parse_gps_points(path.read_text(encoding="utf-8")), False

summarize_sun(srt_file, tz_offset=None)

Summarise the sun's track over a clip for footage verification.

Resolves each GPS point's UTC time (via tz_offset or mtime auto-detect), computes solar azimuth/elevation, and returns aggregate stats plus flags: night (peak elevation below the horizon), very_low_sun (peak under 5 degrees), or sun_not_computable (no resolvable UTC time). Angular stats are None when nothing could be computed.

Source code in src/dji_metadata_embedder/telemetry_converter.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
def summarize_sun(
    srt_file: Path | str, tz_offset: timedelta | None = None
) -> dict[str, Any]:
    """Summarise the sun's track over a clip for footage verification.

    Resolves each GPS point's UTC time (via *tz_offset* or mtime auto-detect),
    computes solar azimuth/elevation, and returns aggregate stats plus flags:
    ``night`` (peak elevation below the horizon), ``very_low_sun`` (peak under
    5 degrees), or ``sun_not_computable`` (no resolvable UTC time). Angular stats
    are ``None`` when nothing could be computed.
    """
    srt_path = Path(srt_file)
    points, is_utc = load_gps_points(srt_path)

    offset: timedelta | None
    if is_utc:
        offset = timedelta(0)
    else:
        abs_times = [p["datetime"] for p in points if p["datetime"] is not None]
        mtime_utc = datetime.fromtimestamp(
            srt_path.stat().st_mtime, tz=timezone.utc
        ).replace(tzinfo=None)
        offset = resolve_utc_offset(abs_times, tz_offset, mtime_utc)

    track: list[tuple[datetime, float, float]] = []  # (utc, azimuth, elevation)
    if offset is not None:
        for p in points:
            if p["datetime"] is None or not is_gps_fix(p["lat"], p["lon"]):
                continue
            utc = p["datetime"] - offset
            az, el = sun_position(p["lat"], p["lon"], utc)
            track.append((utc, az, el))

    summary: dict[str, Any] = {
        "file": srt_path.name,
        "points": len(points),
        "sun_computed": len(track),
        "utc_start": None,
        "utc_end": None,
        "elevation_min": None,
        "elevation_max": None,
        "azimuth_start": None,
        "azimuth_end": None,
        "flags": [],
    }
    if not track:
        summary["flags"] = ["sun_not_computable"]
        return summary

    elevations = [el for _, _, el in track]
    summary["utc_start"] = track[0][0].strftime("%Y-%m-%dT%H:%M:%SZ")
    summary["utc_end"] = track[-1][0].strftime("%Y-%m-%dT%H:%M:%SZ")
    summary["elevation_min"] = round(min(elevations), 3)
    summary["elevation_max"] = round(max(elevations), 3)
    summary["azimuth_start"] = round(track[0][1], 3)
    summary["azimuth_end"] = round(track[-1][1], 3)
    if summary["elevation_max"] < 0:
        summary["flags"] = ["night"]
    elif summary["elevation_max"] < _VERY_LOW_SUN_DEG:
        summary["flags"] = ["very_low_sun"]
    return summary

Home dataclass

The drone's recorded HOME (launch) point — the operator's location.

Extracted only on explicit opt-in (--extract-home) and always passed through :func:redact_home. Never written into the embedded MP4.

Source code in src/dji_metadata_embedder/utilities.py
288
289
290
291
292
293
294
295
296
297
298
@dataclass
class Home:
    """The drone's recorded HOME (launch) point — the operator's location.

    Extracted only on explicit opt-in (``--extract-home``) and always passed
    through :func:`redact_home`. Never written into the embedded MP4.
    """

    lat: float
    lon: float
    alt: float | None = None

TelemetrySample dataclass

One GPS-fixed SRT block: position, raw cue time, absolute datetime, and optional footprint inputs (relative altitude, 35mm-equivalent focal length, gimbal yaw/pitch) when the format carries them.

Source code in src/dji_metadata_embedder/utilities.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
@dataclass
class TelemetrySample:
    """One GPS-fixed SRT block: position, raw cue time, absolute datetime, and
    optional footprint inputs (relative altitude, 35mm-equivalent focal length,
    gimbal yaw/pitch) when the format carries them."""

    lat: float
    lon: float
    alt: float
    cue: str
    dt: datetime | None
    rel_alt: float | None = None
    focal_len: float | None = None
    gimbal_yaw: float | None = None
    gimbal_pitch: float | None = None

apply_redaction(telemetry, mode)

Modify telemetry in place according to the redaction mode.

Source code in src/dji_metadata_embedder/utilities.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
def apply_redaction(telemetry: dict, mode: str) -> None:
    """Modify ``telemetry`` in place according to the redaction ``mode``."""
    telemetry["gps_coords"] = redact_coords(telemetry.get("gps_coords", []), mode)
    if "home" in telemetry:
        telemetry["home"] = redact_home(telemetry["home"], mode)

    if mode == "drop":
        telemetry["first_gps"] = None
        telemetry["avg_gps"] = None
    elif mode == "fuzz":
        if telemetry.get("first_gps"):
            lat, lon = telemetry["first_gps"]
            telemetry["first_gps"] = (round(lat, 3), round(lon, 3))
        if telemetry.get("avg_gps"):
            lat, lon = telemetry["avg_gps"]
            telemetry["avg_gps"] = (round(lat, 3), round(lon, 3))

check_dependencies()

Return (True, []) if external tools are available.

Source code in src/dji_metadata_embedder/utilities.py
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
def check_dependencies() -> Tuple[bool, list[str]]:
    """Return ``(True, [])`` if external tools are available."""
    import os
    import platform
    from pathlib import Path

    tools = {"ffmpeg": ["ffmpeg", "-version"], "exiftool": ["exiftool", "-ver"]}
    missing: list[str] = []

    # Add dji-embed bin directory to PATH temporarily (Windows only)
    original_path = os.environ.get("PATH", "")
    path_modified = False

    if platform.system() == "Windows":
        bin_dir = Path.home() / "AppData" / "Local" / "dji-embed" / "bin"
        if bin_dir.exists() and str(bin_dir) not in original_path:
            os.environ["PATH"] = str(bin_dir) + os.pathsep + original_path
            path_modified = True

    try:
        for name, cmd in tools.items():
            # Check environment variables first (set by bootstrap script)
            env_var = f"DJIEMBED_{name.upper()}_PATH"
            tool_path = os.environ.get(env_var)

            if tool_path and Path(tool_path).exists():
                # Use the explicit path from environment variable
                test_cmd = [tool_path] + cmd[1:]
                try:
                    subprocess.run(test_cmd, capture_output=True, check=True)
                    continue  # Tool found, skip to next
                except (subprocess.CalledProcessError, FileNotFoundError):
                    pass  # Fall through to normal check

            # Copy provisioned via `dji-embed doctor --install exiftool`
            if name == "exiftool":
                from .utils.provision import provisioned_exiftool

                prov = provisioned_exiftool()
                if prov is not None:
                    try:
                        subprocess.run(
                            [str(prov), "-ver"], capture_output=True, check=True
                        )
                        continue  # Tool found, skip to next
                    except (subprocess.CalledProcessError, OSError):
                        pass  # Fall through to normal check

            # Normal check
            try:
                # Use shell=True on Windows to find executables in PATH
                subprocess.run(
                    cmd,
                    capture_output=True,
                    check=True,
                    shell=(platform.system() == "Windows"),
                )
            except (subprocess.CalledProcessError, FileNotFoundError):
                missing.append(name)
    finally:
        # Restore original PATH if we modified it
        if path_modified:
            os.environ["PATH"] = original_path

    return (not missing), missing

estimate_utc_offset(first_local, last_local, file_mtime_utc)

Estimate the UTC offset of naive DJI SRT timestamps.

DJI SRT wall-clock timestamps are local with no timezone, while the source file's mtime is UTC. We don't know whether the mtime marks the start or the end of the recording, so both hypotheses are tested: for each anchor the raw offset anchor - file_mtime_utc is rounded to the nearest quarter hour (covering offsets such as UTC+5:30 and UTC+5:45), and the residual distance from that boundary is kept. The hypothesis with the smaller residual wins, since a genuine offset lands cleanly on a quarter hour.

Returns None when the best hypothesis falls outside the plausible UTC-12..UTC+14 range (clamped symmetrically to +/-14h): the mtime then cannot be the recording time — typically reset by a zip or cloud transfer (#259). Callers fall back to raw cue timestamps.

All three datetimes must be naive (file_mtime_utc expressed in UTC).

Re-implemented from the heuristic described in GPStitch (https://github.com/Romancha/GPStitch, GPLv3) — idea only, no code copied.

Source code in src/dji_metadata_embedder/utilities.py
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def estimate_utc_offset(
    first_local: datetime,
    last_local: datetime,
    file_mtime_utc: datetime,
) -> timedelta | None:
    """Estimate the UTC offset of naive DJI SRT timestamps.

    DJI SRT wall-clock timestamps are local with no timezone, while the source
    file's mtime is UTC. We don't know whether the mtime marks the start or the
    end of the recording, so both hypotheses are tested: for each anchor the
    raw offset ``anchor - file_mtime_utc`` is rounded to the nearest quarter
    hour (covering offsets such as UTC+5:30 and UTC+5:45), and the residual
    distance from that boundary is kept. The hypothesis with the smaller
    residual wins, since a genuine offset lands cleanly on a quarter hour.

    Returns ``None`` when the best hypothesis falls outside the plausible
    UTC-12..UTC+14 range (clamped symmetrically to +/-14h): the mtime then
    cannot be the recording time — typically reset by a zip or cloud transfer
    (#259). Callers fall back to raw cue timestamps.

    All three datetimes must be naive (``file_mtime_utc`` expressed in UTC).

    Re-implemented from the heuristic described in GPStitch
    (https://github.com/Romancha/GPStitch, GPLv3) — idea only, no code copied.
    """
    best_offset = timedelta(0)
    best_residual: float | None = None
    for anchor in (first_local, last_local):
        raw = (anchor - file_mtime_utc).total_seconds()
        rounded = round(raw / _QUARTER_HOUR) * _QUARTER_HOUR
        residual = abs(raw - rounded)
        if best_residual is None or residual < best_residual:
            best_residual = residual
            best_offset = timedelta(seconds=rounded)
    if abs(best_offset) > _MAX_PLAUSIBLE_OFFSET:
        logger.warning(
            "Timezone auto-detection failed: the file mtime does not match the "
            "recording window (implied offset %+.1fh; real timezones are within "
            "UTC-12..UTC+14). This is common after zip/cloud transfers. Pass "
            "--tz-offset for absolute UTC times.",
            best_offset.total_seconds() / 3600,
        )
        return None
    return best_offset

get_tool_versions()

Get versions of external tools if available.

Source code in src/dji_metadata_embedder/utilities.py
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def get_tool_versions() -> dict[str, str]:
    """Get versions of external tools if available."""
    import os
    import platform
    from pathlib import Path

    tools = {
        "ffmpeg": ["ffmpeg", "-version"],
        "exiftool": ["exiftool", "-ver"]
    }
    versions: dict[str, str] = {}

    # Add dji-embed bin directory to PATH temporarily (Windows only)
    original_path = os.environ.get("PATH", "")
    path_modified = False

    if platform.system() == "Windows":
        bin_dir = Path.home() / "AppData" / "Local" / "dji-embed" / "bin"
        if bin_dir.exists() and str(bin_dir) not in original_path:
            os.environ["PATH"] = str(bin_dir) + os.pathsep + original_path
            path_modified = True

    try:
        for name, cmd in tools.items():
            # Check environment variables first (set by bootstrap script)
            env_var = f"DJIEMBED_{name.upper()}_PATH"
            tool_path = os.environ.get(env_var)

            if name == "exiftool":
                # Shared resolver: env override → provisioned copy → PATH
                from .utils.exiftool import exiftool_exe

                test_cmd = [exiftool_exe()] + cmd[1:]
            elif tool_path and Path(tool_path).exists():
                test_cmd = [tool_path] + cmd[1:]
            else:
                test_cmd = cmd

            try:
                result = subprocess.run(
                    test_cmd, 
                    capture_output=True, 
                    text=True, 
                    timeout=5
                )
                if result.returncode == 0:
                    output = result.stdout or result.stderr
                    # Parse version from output
                    if name == "ffmpeg":
                        # Extract version from "ffmpeg version X.Y.Z" line
                        import re
                        match = re.search(r"ffmpeg version ([^\s]+)", output)
                        if match:
                            versions[name] = match.group(1)
                        else:
                            versions[name] = "unknown"
                    elif name == "exiftool":
                        # ExifTool returns just the version number
                        versions[name] = output.strip()
                    else:
                        versions[name] = "detected"
                else:
                    versions[name] = "not available"
            except (subprocess.TimeoutExpired, subprocess.SubprocessError, FileNotFoundError):
                versions[name] = "not available"
    finally:
        # Restore original PATH
        if path_modified:
            os.environ["PATH"] = original_path

    return versions

is_gps_fix(lat, lon)

Return True when (lat, lon) is a real GPS fix.

DJI firmware emits [latitude: 0.000000] [longitude: 0.000000] for every frame recorded before the receiver acquires a fix. That exact (0, 0) sentinel (Null Island) is never a genuine drone location, so it must be excluded from geotagging and exported tracks.

Source code in src/dji_metadata_embedder/utilities.py
148
149
150
151
152
153
154
155
156
def is_gps_fix(lat: float, lon: float) -> bool:
    """Return ``True`` when ``(lat, lon)`` is a real GPS fix.

    DJI firmware emits ``[latitude: 0.000000] [longitude: 0.000000]`` for every
    frame recorded before the receiver acquires a fix. That exact ``(0, 0)``
    sentinel (Null Island) is never a genuine drone location, so it must be
    excluded from geotagging and exported tracks.
    """
    return not (lat == 0.0 and lon == 0.0)

iso6709(lat, lon, alt=0.0)

Return an ISO 6709 location string for QuickTime metadata.

Source code in src/dji_metadata_embedder/utilities.py
143
144
145
def iso6709(lat: float, lon: float, alt: float = 0.0) -> str:
    """Return an ISO 6709 location string for QuickTime metadata."""
    return f"{lat:+08.4f}{lon:+09.4f}{alt:+07.1f}/"

load_samples(path)

Load telemetry samples from a DJI .SRT or a video (MP4/MOV) source.

SRT files are parsed directly; videos are read via the ExifTool-backed :mod:dji_metadata_embedder.mp4_telemetry extractor. Imported lazily to avoid a module-load cycle (mp4_telemetry imports from this module).

Source code in src/dji_metadata_embedder/utilities.py
254
255
256
257
258
259
260
261
262
263
264
265
266
def load_samples(path: Path) -> List[TelemetrySample]:
    """Load telemetry samples from a DJI ``.SRT`` or a video (MP4/MOV) source.

    SRT files are parsed directly; videos are read via the ExifTool-backed
    :mod:`dji_metadata_embedder.mp4_telemetry` extractor. Imported lazily to
    avoid a module-load cycle (``mp4_telemetry`` imports from this module).
    """
    from .mp4_telemetry import extract_samples, is_video

    path = Path(path)
    if is_video(path):
        return extract_samples(path)
    return parse_telemetry_samples(path)

parse_dji_srt(srt_path)

Standalone wrapper around :class:DJIMetadataEmbedder parsing.

Source code in src/dji_metadata_embedder/utilities.py
536
537
538
539
540
541
def parse_dji_srt(srt_path: Path) -> dict:
    """Standalone wrapper around :class:`DJIMetadataEmbedder` parsing."""
    from .embedder import DJIMetadataEmbedder

    embedder = DJIMetadataEmbedder(str(srt_path.parent))
    return embedder.parse_dji_srt(srt_path)

parse_home(text)

Return the first HOME point in SRT text, or None.

The caller is responsible for gating on the opt-in flag; this function does not check it. HOME is constant within a file, so the first match is used.

Source code in src/dji_metadata_embedder/utilities.py
310
311
312
313
314
315
316
317
318
319
320
def parse_home(text: str) -> "Home | None":
    """Return the first HOME point in SRT *text*, or ``None``.

    The caller is responsible for gating on the opt-in flag; this function does
    not check it. HOME is constant within a file, so the first match is used.
    """
    m = _HOME_RE.search(text)
    if not m:
        return None
    alt = float(m.group(3)) if m.group(3) is not None else None
    return Home(lat=float(m.group(1)), lon=float(m.group(2)), alt=alt)

parse_telemetry_points(srt_path)

Parse an SRT file into a list of (lat, lon, alt, timestamp).

Backwards-compatible 4-tuple view over :func:parse_telemetry_samples.

Source code in src/dji_metadata_embedder/utilities.py
269
270
271
272
273
274
def parse_telemetry_points(srt_path: Path) -> List[Tuple[float, float, float, str]]:
    """Parse an SRT file into a list of (lat, lon, alt, timestamp).

    Backwards-compatible 4-tuple view over :func:`parse_telemetry_samples`.
    """
    return [(s.lat, s.lon, s.alt, s.cue) for s in parse_telemetry_samples(srt_path)]

parse_telemetry_samples(srt_path)

Parse an SRT file into :class:TelemetrySample records.

Same GPS extraction as the legacy 4-tuple parser (bracket format, GPS(...) compact form, M300 altitude unit suffix, (0,0) no-fix filtering) plus the block's absolute wall-clock datetime when present.

Source code in src/dji_metadata_embedder/utilities.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def parse_telemetry_samples(srt_path: Path) -> List[TelemetrySample]:
    """Parse an SRT file into :class:`TelemetrySample` records.

    Same GPS extraction as the legacy 4-tuple parser (bracket format, ``GPS(...)``
    compact form, M300 altitude unit suffix, ``(0,0)`` no-fix filtering) plus the
    block's absolute wall-clock datetime when present.
    """
    content = srt_path.read_text(encoding="utf-8")
    blocks = content.strip().split("\n\n")
    samples: List[TelemetrySample] = []
    for block in blocks:
        lines = block.strip().split("\n")
        if len(lines) < 3:
            continue
        ts_line = lines[1]
        ts_match = re.search(r"(\d{2}:\d{2}:\d{2},\d{3})", ts_line)
        timestamp = ts_match.group(1) if ts_match else ""
        tele_line = " ".join(lines[2:])
        if "<font" in tele_line:
            tele_line = re.sub(r"<[^>]+>", "", tele_line)
        dt = _parse_srt_datetime(tele_line)
        rel_match = re.search(r"rel_alt:\s*([+-]?\d+\.?\d*)", tele_line)
        focal_match = re.search(r"\[focal_len\s*:\s*([+-]?\d+\.?\d*)", tele_line)
        gb_yaw_match = re.search(r"gb_yaw:\s*([+-]?\d+\.?\d*)", tele_line)
        gb_pitch_match = re.search(r"gb_pitch:\s*([+-]?\d+\.?\d*)", tele_line)
        rel_alt = float(rel_match.group(1)) if rel_match else None
        focal_len = _normalize_focal_len(focal_match.group(1)) if focal_match else None
        gimbal_yaw = float(gb_yaw_match.group(1)) if gb_yaw_match else None
        gimbal_pitch = float(gb_pitch_match.group(1)) if gb_pitch_match else None
        lat_match = re.search(r"\[latitude:\s*([+-]?\d+\.?\d*)\]", tele_line)
        lon_match = re.search(r"\[longitude:\s*([+-]?\d+\.?\d*)\]", tele_line)
        alt_match = re.search(r"abs_alt:\s*([+-]?\d+\.?\d*)\]", tele_line)
        if lat_match and lon_match and alt_match is None:
            # Every documented bracket format carries abs_alt right after the
            # coordinates, so its absence means the block was cut mid-write
            # (drone powered off while flushing the final block). Drop the
            # sample rather than fabricate alt=0.0. The GPS(...) compact form
            # (no abs_alt token at all) takes the branch below instead.
            continue
        if not (lat_match and lon_match):
            # Tolerate optional altitude unit suffix (M300 ``0.0M``) and an
            # optional space before ``(`` used by the P4 RTK compact format.
            gps = re.search(
                r"GPS\s*\(([+-]?\d+\.?\d*),\s*([+-]?\d+\.?\d*),\s*([+-]?\d+\.?\d*)[A-Za-z]*\)",
                tele_line,
            )
            if gps:
                lat_match, lon_match = gps, gps
                alt_match = gps
        if lat_match and lon_match:
            lat = float(lat_match.group(1))
            lon = float(
                lon_match.group(2)
                if len(lon_match.groups()) > 1
                else lon_match.group(1)
            )
            alt = float(
                alt_match.group(3)
                if alt_match and len(alt_match.groups()) > 1
                else (alt_match.group(1) if alt_match else 0.0)
            )
            # Skip pre-GPS-lock ``(0, 0)`` no-fix frames so exported tracks do
            # not include Null Island points.
            if is_gps_fix(lat, lon):
                samples.append(
                    TelemetrySample(
                        lat,
                        lon,
                        alt,
                        timestamp,
                        dt,
                        rel_alt=rel_alt,
                        focal_len=focal_len,
                        gimbal_yaw=gimbal_yaw,
                        gimbal_pitch=gimbal_pitch,
                    )
                )
    return samples

parse_utc_offset(value)

Parse a CLI UTC-offset string into a :class:timedelta.

Returns None (meaning "auto-detect") for None, an empty string, or "auto". Otherwise accepts a signed HH or HH:MM form such as "+05:30", "5:45" or "-8". Raises :class:ValueError on any other input.

Source code in src/dji_metadata_embedder/utilities.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def parse_utc_offset(value: str | None) -> timedelta | None:
    """Parse a CLI UTC-offset string into a :class:`timedelta`.

    Returns ``None`` (meaning "auto-detect") for ``None``, an empty string, or
    ``"auto"``. Otherwise accepts a signed ``HH`` or ``HH:MM`` form such as
    ``"+05:30"``, ``"5:45"`` or ``"-8"``. Raises :class:`ValueError` on any
    other input.
    """
    if value is None:
        return None
    value = value.strip()
    if value == "" or value.lower() == "auto":
        return None
    m = re.fullmatch(r"([+-]?)(\d{1,2})(?::(\d{2}))?", value)
    if not m:
        raise ValueError(f"Invalid UTC offset: {value!r}")
    sign = -1 if m.group(1) == "-" else 1
    hours = int(m.group(2))
    minutes = int(m.group(3) or 0)
    return sign * timedelta(hours=hours, minutes=minutes)

redact_coords(coords, mode)

Redact or fuzz coordinate list based on mode.

Source code in src/dji_metadata_embedder/utilities.py
277
278
279
280
281
282
283
284
285
def redact_coords(
    coords: List[Tuple[float, float]], mode: str
) -> List[Tuple[float, float]]:
    """Redact or fuzz coordinate list based on ``mode``."""
    if mode == "drop":
        return []
    if mode == "fuzz":
        return [(round(lat, 3), round(lon, 3)) for lat, lon in coords]
    return coords

redact_home(home, mode)

Apply GPS redaction to a HOME point: drop -> None; fuzz -> coordinates rounded to 3 decimals (~100 m); none -> unchanged.

Source code in src/dji_metadata_embedder/utilities.py
323
324
325
326
327
328
329
330
331
332
333
334
def redact_home(home: "Home | None", mode: str) -> "Home | None":
    """Apply GPS redaction to a HOME point: ``drop`` -> ``None``; ``fuzz`` ->
    coordinates rounded to 3 decimals (~100 m); ``none`` -> unchanged."""
    if home is None or mode == "drop":
        return None
    if mode == "fuzz":
        return Home(
            lat=round(home.lat, 3),
            lon=round(home.lon, 3),
            alt=round(home.alt, 3) if home.alt is not None else None,
        )
    return home

resolve_utc_offset(abs_datetimes, tz_offset, file_mtime_utc)

Resolve the single local->UTC offset for an SRT file.

Returns None when the file carries no absolute datetime (callers then fall back to the raw cue time). An explicit tz_offset wins; otherwise the offset is auto-detected from the file mtime via :func:estimate_utc_offset.

Source code in src/dji_metadata_embedder/utilities.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def resolve_utc_offset(
    abs_datetimes: list[datetime],
    tz_offset: timedelta | None,
    file_mtime_utc: datetime,
) -> timedelta | None:
    """Resolve the single local->UTC offset for an SRT file.

    Returns ``None`` when the file carries no absolute datetime (callers then
    fall back to the raw cue time). An explicit *tz_offset* wins; otherwise the
    offset is auto-detected from the file mtime via :func:`estimate_utc_offset`.
    """
    if not abs_datetimes:
        return None
    if tz_offset is not None:
        return tz_offset
    return estimate_utc_offset(abs_datetimes[0], abs_datetimes[-1], file_mtime_utc)

setup_logging(verbose=False, quiet=False, json_logs=False)

Configure application wide logging.

All log output goes to stderr (issue #282): stdout is reserved for real command output — summaries, exported data, --progress jsonl events. The json_logs branch already logs to stderr (logging's default); the Rich branch needs an explicit stderr console because RichHandler's default console writes to stdout.

Source code in src/dji_metadata_embedder/utilities.py
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def setup_logging(
    verbose: bool = False,
    quiet: bool = False,
    json_logs: bool = False,
) -> None:
    """Configure application wide logging.

    All log output goes to stderr (issue #282): stdout is reserved for real
    command output — summaries, exported data, ``--progress jsonl`` events.
    The json_logs branch already logs to stderr (logging's default); the
    Rich branch needs an explicit stderr console because RichHandler's
    default console writes to stdout.
    """
    level = logging.INFO
    if verbose:
        level = logging.DEBUG
    elif quiet:
        level = logging.WARNING

    if json_logs:
        # Use standard logging for JSON output
        logging.basicConfig(
            level=level,
            format="%(message)s",
            datefmt="[%X]",
        )
    else:
        # Use Rich for pretty output
        logging.basicConfig(
            level=level,
            format="%(message)s",
            datefmt="[%X]",
            handlers=[
                RichHandler(
                    rich_tracebacks=True,
                    console=Console(stderr=True),
                )
            ],
        )