Apple Photos reads lens info from the [VideoKeys] atom in the video track, not from [Keys] or XMP. Add VideoKeys:LensModel and VideoKeys:FocalLengthIn35mmFormat alongside existing camera tags. Camera info (Make/Model): - Keys:Make/Model (QuickTime movie-level, Photos camera info) - UserData:Make/Model (legacy QuickTime atom) Lens info (inferred from Garmin's declared 140-degree FOV): - VideoKeys:LensModel = 'Fixed Wide Angle 140 deg' - VideoKeys:FocalLengthIn35mmFormat = 7 (closest integer, FOV ~137 deg) - XMP-exif:FocalLength = 6.5 mm - XMP-exif:FocalLengthIn35mmFormat = 7 - XMP-exifEX:LensModel = 'Fixed Wide Angle 140 deg' Verified: Apple Photos shows 'Garmin Varia RCT715 / Fixed Wide Angle 140 deg' in the Info panel after import.
@@ -12,6 +12,7 @@ set -euo pipefail |
||
| 12 | 12 |
# Prefer CRF (software) or constrained VBR (hardware), as used below. |
| 13 | 13 |
|
| 14 | 14 |
SCRIPT_NAME="$(basename "$0")" |
| 15 |
+TOOL_NAME="ffmpeg" |
|
| 15 | 16 |
|
| 16 | 17 |
DEFAULT_SOURCE="." |
| 17 | 18 |
DEFAULT_MODE="hardware" |
@@ -19,6 +20,13 @@ DEFAULT_EXTENSIONS="mp4,mov,avi,m4v" |
||
| 19 | 20 |
DEFAULT_CRF_HEVC=20 |
| 20 | 21 |
DEFAULT_CRF_H264=19 |
| 21 | 22 |
|
| 23 |
+# Garmin publicly declares a 140-degree field of view. |
|
| 24 |
+# We infer practical focal metadata from this single spec. |
|
| 25 |
+# 35mm-equivalent focal length is integer-only in EXIF, so we round to 7 mm. |
|
| 26 |
+GARMIN_DECLARED_FOV_DEG=140 |
|
| 27 |
+GARMIN_INFERRED_FOCAL_MM="6.5" |
|
| 28 |
+GARMIN_INFERRED_FOCAL_35MM_MM="7" |
|
| 29 |
+ |
|
| 22 | 30 |
SOURCE_DIR="$DEFAULT_SOURCE" |
| 23 | 31 |
DEST_DIR="$DEFAULT_SOURCE" |
| 24 | 32 |
MODE="$DEFAULT_MODE" |
@@ -50,6 +58,7 @@ JSON_COPIED=0 |
||
| 50 | 58 |
JSON_SKIPPED=0 |
| 51 | 59 |
ERRORS=0 |
| 52 | 60 |
TOTAL_VIDEO_TIME_SEC=0 |
| 61 |
+TOTAL_FILE_REAL_TIME_SEC=0 |
|
| 53 | 62 |
|
| 54 | 63 |
DURATION_TOLERANCE_SEC=1.0 |
| 55 | 64 |
|
@@ -94,6 +103,10 @@ Encoding Modes: |
||
| 94 | 103 |
Behavior: |
| 95 | 104 |
- Output is always .mp4 (H.264 or HEVC depending on mode, always Apple-compatible) |
| 96 | 105 |
- HEVC outputs are tagged hvc1 for QuickTime / Apple Photos compatibility |
| 106 |
+ - Metadata is restored from source to output with exiftool |
|
| 107 |
+ - Garmin camera model is also mapped to standard Make/Model tags for compatibility |
|
| 108 |
+ - Inferred lens metadata is written from Garmin's declared 140-degree FOV |
|
| 109 |
+ - Transcoding encoder/mode is written in metadata (Software) |
|
| 97 | 110 |
- Original directory structure is preserved under destination |
| 98 | 111 |
- When --source points to a file, only that file is processed |
| 99 | 112 |
- JSON sidecar files found in source are copied 1:1 to destination |
@@ -126,10 +139,11 @@ vlog_msg() {
|
||
| 126 | 139 |
# Quiet-mode per-file progress line |
| 127 | 140 |
log_progress() {
|
| 128 | 141 |
local input_file="$1" |
| 129 |
- local elapsed_sec="$2" |
|
| 142 |
+ local real_elapsed_sec="$2" |
|
| 143 |
+ local encode_elapsed_sec="$3" |
|
| 130 | 144 |
local ts |
| 131 | 145 |
ts="$(date '+%Y-%m-%d %H:%M:%S')" |
| 132 |
- echo "$ts : Transcoding $input_file ... done in ${elapsed_sec}s"
|
|
| 146 |
+ echo "$ts : Transcoding $input_file ... done in ${real_elapsed_sec}s (encode ${encode_elapsed_sec}s)"
|
|
| 133 | 147 |
} |
| 134 | 148 |
|
| 135 | 149 |
die() {
|
@@ -285,6 +299,106 @@ parse_args() {
|
||
| 285 | 299 |
check_tools() {
|
| 286 | 300 |
command -v ffmpeg >/dev/null 2>&1 || die "ffmpeg not found in PATH" |
| 287 | 301 |
command -v ffprobe >/dev/null 2>&1 || die "ffprobe not found in PATH" |
| 302 |
+ command -v exiftool >/dev/null 2>&1 || die "exiftool not found in PATH" |
|
| 303 |
+} |
|
| 304 |
+ |
|
| 305 |
+restore_metadata_with_exiftool() {
|
|
| 306 |
+ local input_file="$1" |
|
| 307 |
+ local output_file="$2" |
|
| 308 |
+ |
|
| 309 |
+ vlog_msg "CHECKPOINT" "metadata_start: $output_file" |
|
| 310 |
+ |
|
| 311 |
+ if [[ "$VERBOSE" == true ]]; then |
|
| 312 |
+ if exiftool -overwrite_original -m -TagsFromFile "$input_file" -all:all -unsafe "$output_file"; then |
|
| 313 |
+ vlog_msg "CHECKPOINT" "metadata_end: $output_file" |
|
| 314 |
+ return 0 |
|
| 315 |
+ fi |
|
| 316 |
+ else |
|
| 317 |
+ if exiftool -overwrite_original -m -q -q -TagsFromFile "$input_file" -all:all -unsafe "$output_file" >/dev/null 2>&1; then |
|
| 318 |
+ vlog_msg "CHECKPOINT" "metadata_end: $output_file" |
|
| 319 |
+ return 0 |
|
| 320 |
+ fi |
|
| 321 |
+ fi |
|
| 322 |
+ |
|
| 323 |
+ log_msg "ERROR" "Failed to restore metadata with exiftool: $output_file" |
|
| 324 |
+ return 1 |
|
| 325 |
+} |
|
| 326 |
+ |
|
| 327 |
+map_garmin_model_to_standard_tags() {
|
|
| 328 |
+ local input_file="$1" |
|
| 329 |
+ local output_file="$2" |
|
| 330 |
+ local garmin_model |
|
| 331 |
+ |
|
| 332 |
+ garmin_model="$(exiftool -s3 -UserData:GarminModel "$input_file" 2>/dev/null | head -n1 || true)" |
|
| 333 |
+ if [[ -z "$garmin_model" ]]; then |
|
| 334 |
+ vlog_msg "CHECKPOINT" "model_map_skip: no GarminModel in source: $input_file" |
|
| 335 |
+ return 0 |
|
| 336 |
+ fi |
|
| 337 |
+ |
|
| 338 |
+ vlog_msg "CHECKPOINT" "model_map_start: $output_file (GarminModel=$garmin_model)" |
|
| 339 |
+ |
|
| 340 |
+ if [[ "$VERBOSE" == true ]]; then |
|
| 341 |
+ if exiftool -overwrite_original -m \ |
|
| 342 |
+ -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \ |
|
| 343 |
+ -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \ |
|
| 344 |
+ -Make="Garmin" -Model="Varia RCT715" \ |
|
| 345 |
+ -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
|
|
| 346 |
+ -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ |
|
| 347 |
+ -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
|
|
| 348 |
+ -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ |
|
| 349 |
+ -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
|
|
| 350 |
+ "$output_file"; then |
|
| 351 |
+ vlog_msg "CHECKPOINT" "model_map_end: $output_file" |
|
| 352 |
+ return 0 |
|
| 353 |
+ fi |
|
| 354 |
+ else |
|
| 355 |
+ if exiftool -overwrite_original -m -q -q \ |
|
| 356 |
+ -UserData:Make="Garmin" -UserData:Model="Varia RCT715" \ |
|
| 357 |
+ -Keys:Make="Garmin" -Keys:Model="Varia RCT715" \ |
|
| 358 |
+ -Make="Garmin" -Model="Varia RCT715" \ |
|
| 359 |
+ -VideoKeys:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
|
|
| 360 |
+ -VideoKeys:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ |
|
| 361 |
+ -XMP-exif:FocalLength="${GARMIN_INFERRED_FOCAL_MM} mm" \
|
|
| 362 |
+ -XMP-exif:FocalLengthIn35mmFormat="$GARMIN_INFERRED_FOCAL_35MM_MM" \ |
|
| 363 |
+ -XMP-exifEX:LensModel="Fixed Wide Angle ${GARMIN_DECLARED_FOV_DEG} deg" \
|
|
| 364 |
+ "$output_file" >/dev/null 2>&1; then |
|
| 365 |
+ vlog_msg "CHECKPOINT" "model_map_end: $output_file" |
|
| 366 |
+ return 0 |
|
| 367 |
+ fi |
|
| 368 |
+ fi |
|
| 369 |
+ |
|
| 370 |
+ log_msg "ERROR" "Failed to map GarminModel to standard Make/Model tags: $output_file" |
|
| 371 |
+ return 1 |
|
| 372 |
+} |
|
| 373 |
+ |
|
| 374 |
+write_transcode_encoder_metadata() {
|
|
| 375 |
+ local output_file="$1" |
|
| 376 |
+ local encoder_meta |
|
| 377 |
+ |
|
| 378 |
+ encoder_meta="$TOOL_NAME mode=$ENCODER_KIND codec=$VIDEO_CODEC" |
|
| 379 |
+ |
|
| 380 |
+ vlog_msg "CHECKPOINT" "encoder_meta_start: $output_file ($encoder_meta)" |
|
| 381 |
+ |
|
| 382 |
+ if [[ "$VERBOSE" == true ]]; then |
|
| 383 |
+ if exiftool -overwrite_original -m \ |
|
| 384 |
+ -Software="$encoder_meta" \ |
|
| 385 |
+ -UserData:Software="$encoder_meta" \ |
|
| 386 |
+ "$output_file"; then |
|
| 387 |
+ vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file" |
|
| 388 |
+ return 0 |
|
| 389 |
+ fi |
|
| 390 |
+ else |
|
| 391 |
+ if exiftool -overwrite_original -m -q -q \ |
|
| 392 |
+ -Software="$encoder_meta" \ |
|
| 393 |
+ -UserData:Software="$encoder_meta" \ |
|
| 394 |
+ "$output_file" >/dev/null 2>&1; then |
|
| 395 |
+ vlog_msg "CHECKPOINT" "encoder_meta_end: $output_file" |
|
| 396 |
+ return 0 |
|
| 397 |
+ fi |
|
| 398 |
+ fi |
|
| 399 |
+ |
|
| 400 |
+ log_msg "ERROR" "Failed to write encoder metadata: $output_file" |
|
| 401 |
+ return 1 |
|
| 288 | 402 |
} |
| 289 | 403 |
|
| 290 | 404 |
detect_encoders() {
|
@@ -521,6 +635,12 @@ collect_video_files() {
|
||
| 521 | 635 |
process_video_file() {
|
| 522 | 636 |
local input_file="$1" |
| 523 | 637 |
local rel_path output_file out_dir |
| 638 |
+ local file_started_at file_ended_at file_real_elapsed_sec |
|
| 639 |
+ local encode_started_at encode_ended_at encode_elapsed_sec=0 |
|
| 640 |
+ local post_elapsed_sec |
|
| 641 |
+ |
|
| 642 |
+ file_started_at="$(date +%s)" |
|
| 643 |
+ vlog_msg "CHECKPOINT" "file_start: $input_file" |
|
| 524 | 644 |
|
| 525 | 645 |
rel_path="$(rel_path_from_source "$input_file")" |
| 526 | 646 |
output_file="$DEST_DIR/${rel_path%.*}.mp4"
|
@@ -571,10 +691,9 @@ process_video_file() {
|
||
| 571 | 691 |
return 0 |
| 572 | 692 |
fi |
| 573 | 693 |
|
| 574 |
- local started_at ended_at elapsed_sec elapsed_fmt |
|
| 575 |
- started_at="$(date +%s)" |
|
| 576 |
- |
|
| 577 | 694 |
vlog_msg "INFO" "Encoding: $input_file -> $output_file" |
| 695 |
+ encode_started_at="$(date +%s)" |
|
| 696 |
+ vlog_msg "CHECKPOINT" "encode_start: $input_file" |
|
| 578 | 697 |
|
| 579 | 698 |
local ffmpeg_rc=0 |
| 580 | 699 |
if [[ "$VERBOSE" == true ]]; then |
@@ -600,24 +719,46 @@ process_video_file() {
|
||
| 600 | 719 |
fi |
| 601 | 720 |
fi |
| 602 | 721 |
|
| 603 |
- ended_at="$(date +%s)" |
|
| 604 |
- elapsed_sec=$((ended_at - started_at)) |
|
| 605 |
- elapsed_fmt="$(format_seconds "$elapsed_sec")" |
|
| 722 |
+ encode_ended_at="$(date +%s)" |
|
| 723 |
+ encode_elapsed_sec=$((encode_ended_at - encode_started_at)) |
|
| 724 |
+ vlog_msg "CHECKPOINT" "encode_end: $input_file (encode_elapsed=${encode_elapsed_sec}s)"
|
|
| 606 | 725 |
|
| 607 | 726 |
if [[ "$ffmpeg_rc" -ne 0 ]]; then |
| 727 |
+ file_ended_at="$(date +%s)" |
|
| 728 |
+ file_real_elapsed_sec=$((file_ended_at - file_started_at)) |
|
| 729 |
+ local encode_elapsed_fmt |
|
| 730 |
+ local real_elapsed_fmt |
|
| 731 |
+ encode_elapsed_fmt="$(format_seconds "$encode_elapsed_sec")" |
|
| 732 |
+ real_elapsed_fmt="$(format_seconds "$file_real_elapsed_sec")" |
|
| 733 |
+ |
|
| 608 | 734 |
ERRORS=$((ERRORS + 1)) |
| 609 | 735 |
if [[ "$VERBOSE" == true ]]; then |
| 610 |
- log_msg "ERROR" "ffmpeg failed: $input_file (elapsed=${elapsed_sec}s / $elapsed_fmt, rc=$ffmpeg_rc)"
|
|
| 736 |
+ log_msg "ERROR" "ffmpeg failed: $input_file (real=${file_real_elapsed_sec}s / $real_elapsed_fmt, encode=${encode_elapsed_sec}s / $encode_elapsed_fmt, rc=$ffmpeg_rc)"
|
|
| 611 | 737 |
else |
| 612 | 738 |
local ts |
| 613 | 739 |
ts="$(date '+%Y-%m-%d %H:%M:%S')" |
| 614 |
- echo "$ts : Transcoding $input_file ... FAILED (rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
|
|
| 740 |
+ echo "$ts : Transcoding $input_file ... FAILED after ${file_real_elapsed_sec}s (encode ${encode_elapsed_sec}s, rc=$ffmpeg_rc, log=${ffmpeg_log:-n/a})"
|
|
| 615 | 741 |
rm -f "${ffmpeg_log:-}"
|
| 616 | 742 |
fi |
| 617 | 743 |
return 1 |
| 618 | 744 |
fi |
| 619 | 745 |
|
| 620 |
- TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + elapsed_sec)) |
|
| 746 |
+ TOTAL_VIDEO_TIME_SEC=$((TOTAL_VIDEO_TIME_SEC + encode_elapsed_sec)) |
|
| 747 |
+ |
|
| 748 |
+ if ! restore_metadata_with_exiftool "$input_file" "$output_file"; then |
|
| 749 |
+ ERRORS=$((ERRORS + 1)) |
|
| 750 |
+ return 1 |
|
| 751 |
+ fi |
|
| 752 |
+ |
|
| 753 |
+ if ! map_garmin_model_to_standard_tags "$input_file" "$output_file"; then |
|
| 754 |
+ ERRORS=$((ERRORS + 1)) |
|
| 755 |
+ return 1 |
|
| 756 |
+ fi |
|
| 757 |
+ |
|
| 758 |
+ if ! write_transcode_encoder_metadata "$output_file"; then |
|
| 759 |
+ ERRORS=$((ERRORS + 1)) |
|
| 760 |
+ return 1 |
|
| 761 |
+ fi |
|
| 621 | 762 |
|
| 622 | 763 |
if [[ "$MOVE_SOURCE" == true ]]; then |
| 623 | 764 |
if ! validate_transcoded_output "$input_file" "$output_file"; then |
@@ -638,13 +779,22 @@ process_video_file() {
|
||
| 638 | 779 |
fi |
| 639 | 780 |
fi |
| 640 | 781 |
|
| 782 |
+ file_ended_at="$(date +%s)" |
|
| 783 |
+ file_real_elapsed_sec=$((file_ended_at - file_started_at)) |
|
| 784 |
+ post_elapsed_sec=$((file_real_elapsed_sec - encode_elapsed_sec)) |
|
| 785 |
+ if [[ "$post_elapsed_sec" -lt 0 ]]; then |
|
| 786 |
+ post_elapsed_sec=0 |
|
| 787 |
+ fi |
|
| 788 |
+ TOTAL_FILE_REAL_TIME_SEC=$((TOTAL_FILE_REAL_TIME_SEC + file_real_elapsed_sec)) |
|
| 789 |
+ vlog_msg "CHECKPOINT" "file_done: $input_file (real=${file_real_elapsed_sec}s encode=${encode_elapsed_sec}s post=${post_elapsed_sec}s)"
|
|
| 790 |
+ |
|
| 641 | 791 |
VIDEOS_PROCESSED=$((VIDEOS_PROCESSED + 1)) |
| 642 | 792 |
|
| 643 | 793 |
if [[ "$VERBOSE" == true ]]; then |
| 644 |
- log_msg "INFO" "Transcoded: $output_file (elapsed=${elapsed_sec}s / $elapsed_fmt)"
|
|
| 794 |
+ log_msg "INFO" "Transcoded: $output_file (real=${file_real_elapsed_sec}s / $(format_seconds "$file_real_elapsed_sec"), encode=${encode_elapsed_sec}s / $(format_seconds "$encode_elapsed_sec"), post=${post_elapsed_sec}s / $(format_seconds "$post_elapsed_sec"))"
|
|
| 645 | 795 |
else |
| 646 | 796 |
local display_path="${input_file#$PWD/}"
|
| 647 |
- log_progress "$display_path" "$elapsed_sec" |
|
| 797 |
+ log_progress "$display_path" "$file_real_elapsed_sec" "$encode_elapsed_sec" |
|
| 648 | 798 |
fi |
| 649 | 799 |
|
| 650 | 800 |
return 0 |
@@ -802,13 +952,32 @@ main() {
|
||
| 802 | 952 |
total_elapsed_fmt="$(format_seconds "$total_elapsed_sec")" |
| 803 | 953 |
|
| 804 | 954 |
local avg_video_time_sec=0 avg_video_time_fmt="00:00:00" |
| 955 |
+ local avg_file_real_time_sec=0 avg_file_real_time_fmt="00:00:00" |
|
| 956 |
+ local file_post_total_sec=0 file_post_total_fmt="00:00:00" |
|
| 957 |
+ local run_non_file_overhead_sec=0 run_non_file_overhead_fmt="00:00:00" |
|
| 958 |
+ |
|
| 805 | 959 |
if [[ "$VIDEOS_PROCESSED" -gt 0 ]]; then |
| 806 | 960 |
avg_video_time_sec=$((TOTAL_VIDEO_TIME_SEC / VIDEOS_PROCESSED)) |
| 807 | 961 |
avg_video_time_fmt="$(format_seconds "$avg_video_time_sec")" |
| 962 |
+ avg_file_real_time_sec=$((TOTAL_FILE_REAL_TIME_SEC / VIDEOS_PROCESSED)) |
|
| 963 |
+ avg_file_real_time_fmt="$(format_seconds "$avg_file_real_time_sec")" |
|
| 964 |
+ fi |
|
| 965 |
+ |
|
| 966 |
+ file_post_total_sec=$((TOTAL_FILE_REAL_TIME_SEC - TOTAL_VIDEO_TIME_SEC)) |
|
| 967 |
+ if [[ "$file_post_total_sec" -lt 0 ]]; then |
|
| 968 |
+ file_post_total_sec=0 |
|
| 969 |
+ fi |
|
| 970 |
+ file_post_total_fmt="$(format_seconds "$file_post_total_sec")" |
|
| 971 |
+ |
|
| 972 |
+ run_non_file_overhead_sec=$((total_elapsed_sec - TOTAL_FILE_REAL_TIME_SEC)) |
|
| 973 |
+ if [[ "$run_non_file_overhead_sec" -lt 0 ]]; then |
|
| 974 |
+ run_non_file_overhead_sec=0 |
|
| 808 | 975 |
fi |
| 976 |
+ run_non_file_overhead_fmt="$(format_seconds "$run_non_file_overhead_sec")" |
|
| 809 | 977 |
|
| 810 | 978 |
log_msg "INFO" "Summary: videos_processed=$VIDEOS_PROCESSED videos_skipped=$VIDEOS_SKIPPED json_copied=$JSON_COPIED json_skipped=$JSON_SKIPPED errors=$ERRORS" |
| 811 |
- log_msg "INFO" "Timing: total_elapsed=${total_elapsed_sec}s / $total_elapsed_fmt, video_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), video_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
|
|
| 979 |
+ log_msg "INFO" "Timing: run_real=${total_elapsed_sec}s / $total_elapsed_fmt, files_real_total=${TOTAL_FILE_REAL_TIME_SEC}s / $(format_seconds "$TOTAL_FILE_REAL_TIME_SEC"), files_encode_total=${TOTAL_VIDEO_TIME_SEC}s / $(format_seconds "$TOTAL_VIDEO_TIME_SEC"), files_post_total=${file_post_total_sec}s / $file_post_total_fmt, run_non_file_overhead=${run_non_file_overhead_sec}s / $run_non_file_overhead_fmt"
|
|
| 980 |
+ log_msg "INFO" "Timing(avg): file_real_avg=${avg_file_real_time_sec}s / $avg_file_real_time_fmt, file_encode_avg=${avg_video_time_sec}s / $avg_video_time_fmt"
|
|
| 812 | 981 |
|
| 813 | 982 |
if [[ "$ERRORS" -gt 0 ]]; then |
| 814 | 983 |
exit 1 |