Showing 1 changed files with 183 additions and 14 deletions
+183 -14
garmin_varia_transcode.sh
@@ -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