Last active
August 11, 2026 19:33
-
-
Save coldcue/dd846b84b5d8818587db3e9112dbee7e to your computer and use it in GitHub Desktop.
Batch re-encode DJI Osmo Action 5 Pro footage to HEVC (libx265, HLG-safe, metadata transfer)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # Batch re-encode DJI action-camera footage to HEVC (libx265, crf 24, medium | |
| # preset). Developed and tested with DJI Osmo Action 5 Pro recordings: | |
| # 4K 29.97 fps HEVC ~56 Mbps, 10-bit HLG HDR (BT.2020 / ARIB STD-B67), | |
| # AAC-LC stereo audio, plus DJI extra tracks (djmd/dbgi telemetry with | |
| # per-frame accelerometer, shutter speed and color temperature; a tmcd | |
| # timecode track; embedded cover-art/preview JPEGs). | |
| # | |
| # What it does per file: | |
| # - keeps only the main video + audio streams; audio is copied untouched | |
| # - drops the DJI telemetry tracks and embedded cover art (telemetry is a | |
| # timed data stream only readable by DJI's own apps; it cannot be | |
| # preserved through a re-encode) | |
| # - 10-bit depth and HLG color signaling pass through automatically | |
| # (verified with ffprobe: Main 10, bt2020/arib-std-b67 tags survive) | |
| # - transfers container metadata with exiftool afterwards: capture dates, | |
| # camera name ("Encoder: DJI OsmoAction5 Pro"), original SD-card path, | |
| # firmware info, and the source file's modification time | |
| # - output is .mp4 (same filename, new extension) for wide player/TV | |
| # compatibility | |
| # | |
| # Already-encoded files are skipped, so the script can be re-run to resume. | |
| # If an encode fails or is interrupted, the partial output is removed and the | |
| # script stops instead of moving on to the next file. | |
| # | |
| # Encoder settings were chosen from measured preset/crf matrices on 60 s test | |
| # segments of Osmo Action 5 Pro motorcycle POV footage (hardware VideoToolbox | |
| # encoding was rejected after visual comparison). | |
| # | |
| # Usage: encode_dji_video.sh SRC_DIR DST_DIR | |
| # SRC_DIR folder with the source .mp4/.MP4 files | |
| # DST_DIR output folder, created if missing (must differ from SRC_DIR) | |
| # | |
| # Recommended invocation for a multi-day run on macOS (prevents idle/system | |
| # sleep while on AC power; keep the lid open or use clamshell mode): | |
| # caffeinate -is ./encode_dji_video.sh /path/to/source /path/to/output | |
| # Treat unset variables as errors. | |
| set -u | |
| if [ $# -ne 2 ]; then | |
| echo "Usage: ${0##*/} SRC_DIR DST_DIR" >&2 | |
| exit 1 | |
| fi | |
| SRC="$1" | |
| DST="$2" | |
| if [ ! -d "$SRC" ]; then | |
| echo "Source folder does not exist: $SRC" >&2 | |
| exit 1 | |
| fi | |
| # Outputs keep the source basename, so encoding into the source folder would | |
| # collide with the originals. | |
| if [ "$(cd "$SRC" && pwd)" = "$(cd "$DST" 2>/dev/null && pwd)" ]; then | |
| echo "SRC_DIR and DST_DIR must be different folders" >&2 | |
| exit 1 | |
| fi | |
| # Check required tools are installed before starting a multi-day run. | |
| missing="" | |
| for cmd in ffmpeg ffprobe exiftool; do | |
| command -v "$cmd" >/dev/null 2>&1 || missing="$missing $cmd" | |
| done | |
| if [ -n "$missing" ]; then | |
| echo "Missing required tools:$missing" >&2 | |
| echo "Install with: brew install$missing" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p "$DST" | |
| # Collect sources: *.mp4 in any case (DJI names files DJI_*.MP4), no error | |
| # if none. | |
| shopt -s nullglob nocaseglob | |
| files=("$SRC"/*.mp4) | |
| shopt -u nullglob nocaseglob | |
| n=${#files[@]} | |
| if [ "$n" -eq 0 ]; then | |
| echo "No .mp4/.MP4 files found in $SRC" >&2 | |
| exit 1 | |
| fi | |
| i=0 | |
| # Total length of the whole batch, shown alongside each file's own duration. | |
| total_dur=$(for f in "${files[@]}"; do \ | |
| ffprobe -v error -show_entries format=duration -of csv=p=0 "$f"; \ | |
| done | awk '{s+=$1} END{printf "%d:%02d:%02d", s/3600, (s%3600)/60, s%60}') | |
| for f in "${files[@]}"; do | |
| i=$((i + 1)) | |
| # Output as .mp4 (same basename, extension normalized to lowercase .mp4). | |
| name="${f##*/}" | |
| base="${name%.*}" | |
| out="$DST/$base.mp4" | |
| # Skip files that already have an output — makes the script resumable. | |
| # A completed file is never touched; partial files are deleted below on | |
| # failure, so anything present here is a finished encode. | |
| if [ -e "$out" ]; then | |
| echo "[$i/$n] Skipping (already exists): $out" | |
| continue | |
| fi | |
| dur=$(ffprobe -v error -pretty -show_entries format=duration -of csv=p=0 "$f") | |
| echo "[$i/$n] Encoding: $name ($(du -h "$f" | cut -f1), $dur of $total_dur total)" | |
| # -map 0:v:0 -map 0:a:0 keep only the main video and audio streams; | |
| # drops the DJI djmd/dbgi telemetry tracks and the | |
| # embedded cover-art JPEG (a timecode track is still | |
| # added automatically from the source's timecode | |
| # metadata) | |
| # -c:v libx265 software HEVC encoder | |
| # -preset medium -crf 24 chosen from measured preset/crf comparisons on | |
| # Osmo Action 5 Pro test segments | |
| # no-sao=1 disable the SAO in-loop filter, which trades | |
| # ringing suppression for detail retention; keeps | |
| # more road/foliage texture in this footage | |
| # -tag:v hvc1 Apple-compatible codec tag (default hev1 won't play | |
| # in QuickTime/Photos) | |
| # -c:a copy pass the AAC audio through untouched | |
| # -movflags +faststart move the moov atom to the front so playback can | |
| # start before the whole file is downloaded/streamed | |
| # (https://trac.ffmpeg.org/wiki/Encode/H.264#faststartforwebvideo) | |
| ffmpeg -nostdin -loglevel error -stats -i "$f" -map 0:v:0 -map 0:a:0 \ | |
| -c:v libx265 -preset medium -crf 24 -x265-params no-sao=1 \ | |
| -tag:v hvc1 \ | |
| -c:a copy \ | |
| -movflags +faststart \ | |
| "$out" | |
| # Stop on any failure, including Ctrl+C (ffmpeg exits nonzero on SIGINT). | |
| # Delete the partial output so the next run resumes at this file. | |
| status=$? | |
| if [ "$status" -ne 0 ]; then | |
| echo "Encode failed or interrupted (exit $status): $f" >&2 | |
| rm -f "$out" | |
| exit "$status" | |
| fi | |
| # Transfer container metadata from the source: capture dates (movie, track | |
| # and media level — ffmpeg writes them as zeros), camera name (Encoder: | |
| # "DJI OsmoAction5 Pro"), original SD-card file path, DJI firmware Category | |
| # string, and the source file's modification time. | |
| # Excluded: embedded preview JPEGs (deliberately stripped in the encode) and | |
| # the old container's brand tags. The DJI telemetry track is a data stream, | |
| # not tags — it is dropped by the encode and cannot be transferred. | |
| # exiftool preserves the faststart layout (moov stays at the front). | |
| echo "[$i/$n] Transferring metadata: $name" | |
| if ! exiftool -q -overwrite_original -api LargeFileSupport=1 \ | |
| -TagsFromFile "$f" -All:All \ | |
| --ItemList:CoverArt --ItemList:PreviewImage --ItemList:ThumbnailImage \ | |
| --Keys:MajorBrand --Keys:MinorVersion --Keys:CompatibleBrands \ | |
| "-FileModifyDate<FileModifyDate" "$out"; then | |
| echo "Metadata transfer failed: $out (encode kept)" >&2 | |
| exit 1 | |
| fi | |
| echo "[$i/$n] Done: $out ($(du -h "$out" | cut -f1))" | |
| done | |
| echo "All files encoded." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment