compress-video.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. #!/usr/bin/env bash
  2. ## Compresses a video to reduce it's file size.
  3. ## by Adnan Shameem; MIT (Expat) license
  4. ##
  5. ## Usage:
  6. ## Run /path/to/compress-video.sh --help
  7. [ -z "$(command -v ffmpeg)" ] && echo 'ffmpeg not found, please install and continue' && exit 11
  8. ## Default config values
  9. # Video dimensions. Check ref below for other 16:9 and 4:3 dimensions
  10. # ref: https://studio.support.brightcove.com/general/optimal-video-dimensions.html
  11. max_width=960
  12. max_height=540
  13. # MKV supports good range of audio, especially vorbis.
  14. # ref: https://trac.ffmpeg.org/wiki/Encode/HighQualityAudio#Containerformats
  15. # If extension changed, you may have trouble with subtitles. Only mkv, mp4 and
  16. # mov support embedding subtitles. Although setting "burn_subtitles" to 1 may
  17. # be an option.
  18. # Single quotes are intentional so that later it can be replaced with actual
  19. # value.
  20. dest_filename='compressed_$source_filename_wo_ext.mkv'
  21. # Single quotes are intentional so that later it can be replaced with actual
  22. # value.
  23. dest_path='$source_path'
  24. # Burn subtitles into the video. 0 disables, 1 enables. Default: 0.
  25. # Shaves some kilobytes off if enabled, but removes the ability to turn
  26. # subtitles off, change language or ever to remove it again from video frames.
  27. # Disabled by default as a safety measure.
  28. burn_subtitles=0
  29. # Keep original audio and do not compress
  30. keep_audio=0
  31. ## Compresses given video file
  32. function _process_file() (
  33. ## Internal variables - no need to change these
  34. source="$1"
  35. source_path="$(dirname "$1")"
  36. source_filename="$(basename "$1")"
  37. source_filename_wo_ext="${source_filename%.*}"
  38. dest_filename_parsed="${dest_filename//\$source_filename_wo_ext/$source_filename_wo_ext}"
  39. dest_path_parsed="${dest_path//\$source_path/$source_path}"
  40. ## Check existing file/directory
  41. [ ! -f "${source_path}/${source_filename}" ] && echo "Input file '${source_path}/${source_filename}' doesn't exist" && exit 14
  42. [ -d "${dest_path_parsed}/${dest_filename_parsed}" ] && echo "A directory '$(realpath "${dest_path_parsed}/${dest_filename_parsed}")' already exists. Please delete or rename it and try again." && exit 45
  43. [ ! -d "${dest_path_parsed}" ] && echo "The output directory '$(realpath "${dest_path_parsed}")' does not exist. Creating it..." && mkdir -p "${dest_path_parsed}"
  44. [ -f "${dest_path_parsed}/${dest_filename_parsed}" ] && echo "'$(realpath "${dest_path_parsed}/${dest_filename_parsed}")' already exists. Press enter to override, or Ctrl+C to cancel..." && read && echo 'Chosen to override...'
  45. ## Notification
  46. echo "Processing ${source_path}/${source_filename}..."
  47. ## Video filters
  48. # All array members will be concatenated with "," in between
  49. video_filters=(
  50. # "force_divisible_by=2" saves us when the calculated width/height based on
  51. # aspect ratio is not divisable by 2
  52. "scale='min(${max_width},iw)':'min(${max_height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2"
  53. )
  54. ## Audio arguments
  55. if [ "$keep_audio" -eq '0' ]; then # "keep-audio" disabled, so compress...
  56. # ref:
  57. # - https://trac.ffmpeg.org/wiki/Encode/HighQualityAudio
  58. # - https://trac.ffmpeg.org/wiki/TheoraVorbisEncodingGuide
  59. # - https://trac.ffmpeg.org/wiki/Encode/AAC
  60. audio_args=(-codec:a libvorbis)
  61. else
  62. audio_args=(-codec:a copy)
  63. fi
  64. # Handle subtitles
  65. if [ "$burn_subtitles" -ne '0' ]; then # Burn Subtitles
  66. # Check for .srt subtitle files
  67. if [ -f "${source_path}/${source_filename_wo_ext}.srt" ]; then
  68. video_filters+=("subtitles=${source_path}/${source_filename_wo_ext}.srt")
  69. # Check for .ass subtitle files
  70. elif [ -f "${source_path}/${source_filename_wo_ext}.ass" ]; then
  71. video_filters+=("ass=${source_path}/${source_filename_wo_ext}.ass")
  72. # Take any subtitle data from source file itself
  73. else
  74. video_filters+=("subtitles='${source}'")
  75. fi
  76. else # Embed subtitles
  77. # Check for .srt subtitle files
  78. if [ -f "${source_path}/${source_filename_wo_ext}.srt" ]; then
  79. subtitle_options=(-f srt -i "${source_path}/${source_filename_wo_ext}.srt")
  80. # Check for .ass subtitle files
  81. elif [ -f "${source_path}/${source_filename_wo_ext}.ass" ]; then
  82. subtitle_options=(-f ass -i "${source_path}/${source_filename_wo_ext}.ass")
  83. # Take any subtitle data from source file itself
  84. else
  85. # Set subtitle codec based on container
  86. # ref: https://en.m.wikibooks.org/wiki/FFMPEG_An_Intermediate_Guide/subtitle_options#Set_Subtitle_Codec
  87. ext="${dest_filename_parsed: -4}"
  88. if [ "$ext" = '.mp4' ] || [ "$ext" = '.mov' ]; then
  89. subtitle_options+=(-codec:s mov_text)
  90. elif [ "$ext" = '.mkv' ]; then
  91. subtitle_options+=(-codec:s srt)
  92. else
  93. echo "WARNING: ${ext} format does not support embedding subtitles. Please try setting 'burn_subtitles' to 1 as a remedy."
  94. fi
  95. fi
  96. fi
  97. ## Arguments being passed to ffmpeg.
  98. # Feel free to comment and change any lines you want!
  99. ffmpeg_args=(
  100. # Add arguments to ffmpeg to aid in getting progress
  101. -progress /dev/stdout
  102. # Overwrite dest file without asking, because we asked already at the
  103. # beginning
  104. -y
  105. -nostdin
  106. # Show less output
  107. -hide_banner -loglevel error -nostats
  108. # Pass the source file
  109. -i "$source"
  110. # Subtitles options determined earlier
  111. "${subtitle_options[@]}"
  112. # Set codec as HEVC/H.265 for reduced file size
  113. # ref: https://spadebee.com/2020/06/06/how-to-highly-compress-videos-using-ffmpeg/
  114. -codec:v libx265 -crf 28
  115. # Resize video to fit inside $max_width x $max_height.
  116. # "force_original_aspect_ratio" is to maintain aspect ratio.
  117. # Ref: https://trac.ffmpeg.org/wiki/Scaling
  118. -filter:v "$(IFS=, ; echo "${video_filters[*]}")"
  119. # Audio arguments
  120. "${audio_args[@]}"
  121. # Enable progressive download - for streaming situations
  122. # Especially for mp4/m4a outputs
  123. # ref: https://trac.ffmpeg.org/wiki/Encode/AAC#ProgressiveDownload
  124. -movflags +faststart
  125. )
  126. # Get total frames in the source video
  127. # Ref: https://stackoverflow.com/a/28376817
  128. total_frames=$(ffprobe -v error -select_streams v:0 -count_packets -show_entries stream=nb_read_packets -of csv=p=0 "$source")
  129. # Add the output at the end
  130. ffmpeg_args+=("${dest_path_parsed}/${dest_filename_parsed}")
  131. ## Reset bash time counter to zero.
  132. # $SECONDS is incremented every second by bash. If this is set to zero, it
  133. # starts counting from 0 onwards. e.g. 1, 2, 3... This can be used as a time
  134. # counting measure.
  135. SECONDS=0
  136. ## Progress bar related vars
  137. current_frame=0
  138. encode_fps=0
  139. prev_progress_print_buffer=''
  140. ## Draw progressbar
  141. function update_progress() {
  142. # This is to prevent blinking cursor all over the line when progress is
  143. # being updated. Instead of printing on screen directly, text is added to
  144. # this and printed with one single echo -n call.
  145. print_buffer=''
  146. # Calculations
  147. # A clever little trick to calculate float numbers with bash
  148. # ref: https://stackoverflow.com/a/22406193
  149. ( [ "$current_frame" != '0' ] && [ "$total_frames" != '0' ] ) && progress_percentage=$(awk "BEGIN {printf \"%.2f\",${current_frame}/${total_frames}*100}") || return
  150. # Progress width in integer. Divide by 3 to make it smaller.
  151. [ "$progress_percentage" != '0' ] && progress_done_chars=$(( ${progress_percentage%.*} / 3 )) || return
  152. frames_left=$(( $total_frames - $current_frame ))
  153. ( [ "$frames_left" != '0' ] && [ "${encode_fps%.*}" != '0' ] ) && seconds_left=$(( ( $frames_left / ${encode_fps%.*} ) )) || return
  154. [ "$seconds_left" != '0' ] && time_left=$(printf '%02d:%02d:%02d' $((seconds_left/3600)) $((seconds_left%3600/60)) $((seconds_left%60))) || return
  155. # Progress bar
  156. print_buffer+='['
  157. for ((i = 0 ; i <= $progress_done_chars; i++)); do print_buffer+='#'; done
  158. for ((j = i ; j <= 33 ; j++)); do print_buffer+='-'; done # 100 / 3 = 33
  159. print_buffer+='] '
  160. # Progress text
  161. [ -z "$COLUMNS" ] && TERM_COLUMNS="$(tput cols)" || TERM_COLUMNS="$COLUMNS"
  162. if [ "$TERM_COLUMNS" -gt '110' ]; then
  163. print_buffer+="frames: ${current_frame}/${total_frames} (${progress_percentage}%) left: ${frames_left}, ${time_left} - ${encode_fps}fps"
  164. else
  165. print_buffer+="${current_frame}/${total_frames}fr (${progress_percentage}%) - ${time_left}"
  166. fi
  167. # If same data, do not bother printing it.
  168. if [ "$prev_progress_print_buffer" != "$print_buffer" ]; then
  169. echo -n "${print_buffer}" $'\r'
  170. prev_progress_print_buffer="${print_buffer}"
  171. fi
  172. }
  173. echo -e 'Beginning the compression process, press Ctrl+C anytime to cancel...\n'
  174. ## Run ffmpeg
  175. ffmpeg "${ffmpeg_args[@]}" | while IFS='=' read -r key value; do
  176. # There is no guarantee how many lines will come through and which order.
  177. # But if it is the one we want, we update vars and draw progressbar.
  178. [ "$key" = 'frame' ] && current_frame="$value" && update_progress
  179. [ "$key" = 'fps' ] && encode_fps="$value" && update_progress
  180. done
  181. ## If inturrupted (e.g. pressed Ctrl+C)
  182. if [ 0 -ne "${PIPESTATUS[0]}" ]; then
  183. echo 'Process inturrupted. Exiting...'
  184. exit 1012
  185. fi
  186. ## Done message
  187. echo 'Done.'
  188. echo "Time taken: $(($SECONDS / 3600))hrs $((($SECONDS / 60) % 60))min $(($SECONDS % 60))sec"
  189. )
  190. ## Prints the help text (e.g. when --help is passed)
  191. function _show_help_text() {
  192. echo "Usage: compress-video.sh [OPTION]... [FILE]...
  193. Compress video FILE(s) to reduce filesize.
  194. -b, --burn-subtitles burn subtitles into video
  195. WARNING: Cannot be undone!
  196. -d, --dest-dir DIR set output directory to DIR
  197. -h, --max-height HEIGHT set maximum height of output to HEIGHT
  198. --help show this help and exit
  199. -k, --keep-audio keep audio as is, without compressing
  200. -w, --max-width WIDTH set maximum width of output to WIDTH
  201. Examples:
  202. compress-video.sh somevideo.mp4 Compress video and output as
  203. compressed_somevideo.mkv
  204. compress-video.sh -w 200 -h 200 somevideo.mp4 Compress video as usual and
  205. keep the resolution to 200x200 px
  206. compress-video.sh *.mp4 Compress all matching .mp4 files
  207. Script location:
  208. <https://notabug.org/adnan360/code-backups/src/master/bash/compress-video.sh>"
  209. }
  210. ## Process command line arguments
  211. while [[ "$#" -gt 0 ]]; do
  212. case $1 in
  213. -w|--max-width) max_width="$2"; shift ;;
  214. -h|--max-height) max_height="$2"; shift ;;
  215. -b|--burn-subtitles) burn_subtitles=1 ;;
  216. -k|--keep-audio) keep_audio=1 ;;
  217. -d|--dest-dir) dest_path="$2"; shift ;;
  218. --help) _show_help_text ;;
  219. # The for loop is to interpret patterns like *.mp4.
  220. # [ "$?" -eq 244 ] is to stop when Ctrl+C is pressed and previous file
  221. # was skipped.
  222. *) for i in "$1"; do _process_file "$i"; [ "$?" -eq 244 ] && exit 2446; done ;;
  223. esac
  224. shift
  225. done