Created
September 14, 2026 11:35
-
-
Save mathieu-b/ce4d3bcc168a3b85f8456a417d92f527 to your computer and use it in GitHub Desktop.
Reusable script for converting arbitrary videos into WhatsApp-compatible MP4 files. The script uses ffmpeg to produce H.264/AAC output with yuv420p pixel format, fast-start MP4 metadata, and a conservative default max dimension of 1280 px. It supports macOS/Homebrew and Ubuntu/apt-get dependency installation paths.
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
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| readonly DEFAULT_MAX_DIMENSION_PX=1280 | |
| readonly DEFAULT_CRF=23 | |
| readonly DEFAULT_PRESET="medium" | |
| readonly DEFAULT_SUFFIX="_whatsapp" | |
| max_dimension_px="${DEFAULT_MAX_DIMENSION_PX}" | |
| crf="${DEFAULT_CRF}" | |
| preset="${DEFAULT_PRESET}" | |
| suffix="${DEFAULT_SUFFIX}" | |
| output_dir="" | |
| output_file="" | |
| overwrite=false | |
| auto_install_dependencies=true | |
| input_files=() | |
| function show_usage() { | |
| echo "Usage: $0 [options] <video-file> [video-file ...]" | |
| echo | |
| echo "Converts videos to WhatsApp-compatible MP4 files:" | |
| echo " - H.264 video" | |
| echo " - AAC audio" | |
| echo " - yuv420p pixel format" | |
| echo " - fast-start MP4 metadata" | |
| echo " - longest side capped to 1280 px by default, without upscaling" | |
| echo | |
| echo "Options:" | |
| echo " --output <file> Output file path. Only valid with one input." | |
| echo " --output-dir <dir> Directory for converted files." | |
| echo " --suffix <suffix> Output filename suffix before .mp4. Default: ${DEFAULT_SUFFIX}" | |
| echo " --max-dimension <px> Cap the longest side to this size. Default: ${DEFAULT_MAX_DIMENSION_PX}" | |
| echo " --crf <value> H.264 quality: lower is larger/better. Default: ${DEFAULT_CRF}" | |
| echo " --preset <preset> libx264 preset. Default: ${DEFAULT_PRESET}" | |
| echo " --overwrite Replace existing output files." | |
| echo " --no-install-deps Fail instead of installing/reinstalling ffmpeg." | |
| echo " --help, -h Show this help." | |
| echo | |
| echo "Examples:" | |
| echo " $0 VID_20260908_093552.mp4" | |
| echo " $0 --overwrite --output-dir /tmp *.mov" | |
| echo " $0 --max-dimension 1920 --crf 21 input.mkv" | |
| } | |
| function parse_args() { | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --output) | |
| output_file="${2:-}" | |
| if [[ -z "${output_file}" ]]; then | |
| echo "ERROR: --output requires a file path." | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| --output-dir) | |
| output_dir="${2:-}" | |
| if [[ -z "${output_dir}" ]]; then | |
| echo "ERROR: --output-dir requires a directory path." | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| --suffix) | |
| suffix="${2:-}" | |
| if [[ -z "${suffix}" ]]; then | |
| echo "ERROR: --suffix requires a non-empty value." | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| --max-dimension) | |
| max_dimension_px="${2:-}" | |
| if ! [[ "${max_dimension_px}" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "ERROR: --max-dimension must be a positive integer." | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| --crf) | |
| crf="${2:-}" | |
| if ! [[ "${crf}" =~ ^[0-9]+$ ]] || [[ "${crf}" -gt 51 ]]; then | |
| echo "ERROR: --crf must be an integer between 0 and 51." | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| --preset) | |
| preset="${2:-}" | |
| if [[ -z "${preset}" ]]; then | |
| echo "ERROR: --preset requires a value." | |
| exit 1 | |
| fi | |
| shift 2 | |
| ;; | |
| --overwrite) | |
| overwrite=true | |
| shift | |
| ;; | |
| --no-install-deps) | |
| auto_install_dependencies=false | |
| shift | |
| ;; | |
| --help|-h) | |
| show_usage | |
| exit 0 | |
| ;; | |
| --) | |
| shift | |
| while [[ $# -gt 0 ]]; do | |
| input_files+=("$1") | |
| shift | |
| done | |
| ;; | |
| -*) | |
| echo "ERROR: Unknown option: $1" | |
| show_usage | |
| exit 1 | |
| ;; | |
| *) | |
| input_files+=("$1") | |
| shift | |
| ;; | |
| esac | |
| done | |
| } | |
| function command_works() { | |
| local command_name="$1" | |
| command -v "${command_name}" >/dev/null 2>&1 && "${command_name}" -version >/dev/null 2>&1 | |
| } | |
| function install_or_repair_ffmpeg_on_macos() { | |
| if ! command -v brew >/dev/null 2>&1; then | |
| echo "ERROR: ffmpeg is unavailable and Homebrew is not installed." | |
| echo "Install Homebrew first: https://brew.sh/" | |
| exit 1 | |
| fi | |
| echo "Installing or repairing ffmpeg with Homebrew ..." | |
| if command -v ffmpeg >/dev/null 2>&1; then | |
| brew reinstall ffmpeg || brew reinstall ffmpeg-full || brew install ffmpeg | |
| else | |
| brew install ffmpeg || brew install ffmpeg-full | |
| fi | |
| } | |
| function install_or_repair_ffmpeg_on_linux() { | |
| if ! command -v apt-get >/dev/null 2>&1; then | |
| echo "ERROR: ffmpeg is unavailable and apt-get is not installed." | |
| exit 1 | |
| fi | |
| local sudo_command="" | |
| if [[ "$(id -u)" -ne 0 ]]; then | |
| sudo_command="sudo" | |
| fi | |
| echo "Installing or repairing ffmpeg with apt-get ..." | |
| ${sudo_command} apt-get update | |
| ${sudo_command} apt-get install -y ffmpeg | |
| } | |
| function ensure_ffmpeg_available() { | |
| if command_works ffmpeg; then | |
| return | |
| fi | |
| if ! ${auto_install_dependencies}; then | |
| echo "ERROR: ffmpeg is not available or does not run correctly." | |
| exit 1 | |
| fi | |
| case "$(uname -s)" in | |
| Darwin) | |
| install_or_repair_ffmpeg_on_macos | |
| ;; | |
| Linux) | |
| install_or_repair_ffmpeg_on_linux | |
| ;; | |
| *) | |
| echo "ERROR: Unsupported OS: $(uname -s)" | |
| echo "Install ffmpeg manually, then rerun this script." | |
| exit 1 | |
| ;; | |
| esac | |
| if ! command_works ffmpeg; then | |
| echo "ERROR: ffmpeg still does not run correctly after installation attempt." | |
| exit 1 | |
| fi | |
| } | |
| function validate_args() { | |
| if [[ "${#input_files[@]}" -eq 0 ]]; then | |
| show_usage | |
| exit 1 | |
| fi | |
| if [[ -n "${output_file}" && "${#input_files[@]}" -ne 1 ]]; then | |
| echo "ERROR: --output can only be used with one input file." | |
| exit 1 | |
| fi | |
| if [[ -n "${output_file}" && -n "${output_dir}" ]]; then | |
| echo "ERROR: Use either --output or --output-dir, not both." | |
| exit 1 | |
| fi | |
| for input_file in "${input_files[@]}"; do | |
| if [[ ! -f "${input_file}" ]]; then | |
| echo "ERROR: Input file does not exist: ${input_file}" | |
| exit 1 | |
| fi | |
| done | |
| if [[ -n "${output_dir}" ]]; then | |
| mkdir -p "${output_dir}" | |
| fi | |
| } | |
| function get_output_path() { | |
| local input_file="$1" | |
| if [[ -n "${output_file}" ]]; then | |
| printf "%s\n" "${output_file}" | |
| return | |
| fi | |
| local input_dir | |
| local input_basename | |
| local input_stem | |
| input_dir="$(dirname "${input_file}")" | |
| input_basename="$(basename "${input_file}")" | |
| input_stem="${input_basename%.*}" | |
| if [[ -n "${output_dir}" ]]; then | |
| printf "%s/%s%s.mp4\n" "${output_dir%/}" "${input_stem}" "${suffix}" | |
| else | |
| printf "%s/%s%s.mp4\n" "${input_dir}" "${input_stem}" "${suffix}" | |
| fi | |
| } | |
| function convert_file() { | |
| local input_file="$1" | |
| local output_path | |
| output_path="$(get_output_path "${input_file}")" | |
| if [[ "${input_file}" == "${output_path}" ]]; then | |
| echo "ERROR: Refusing to overwrite input file: ${input_file}" | |
| exit 1 | |
| fi | |
| if [[ -e "${output_path}" && "${overwrite}" != true ]]; then | |
| echo "ERROR: Output file already exists: ${output_path}" | |
| echo "Use --overwrite to replace it." | |
| exit 1 | |
| fi | |
| local overwrite_flag="-n" | |
| if ${overwrite}; then | |
| overwrite_flag="-y" | |
| fi | |
| echo "Converting '${input_file}' -> '${output_path}'" | |
| ffmpeg \ | |
| -hide_banner \ | |
| "${overwrite_flag}" \ | |
| -i "${input_file}" \ | |
| -map 0:v:0 \ | |
| -map "0:a?" \ | |
| -vf "scale='if(gt(iw,ih),min(${max_dimension_px},iw),-2)':'if(gt(iw,ih),-2,min(${max_dimension_px},ih))',format=yuv420p" \ | |
| -c:v libx264 \ | |
| -preset "${preset}" \ | |
| -crf "${crf}" \ | |
| -profile:v high \ | |
| -level 4.0 \ | |
| -c:a aac \ | |
| -b:a 128k \ | |
| -ac 2 \ | |
| -movflags +faststart \ | |
| "${output_path}" | |
| } | |
| function main() { | |
| parse_args "$@" | |
| validate_args | |
| ensure_ffmpeg_available | |
| for input_file in "${input_files[@]}"; do | |
| convert_file "${input_file}" | |
| done | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment