#!/bin/zsh # # Video Transcoding Script # Converts video files to H.265/HEVC at 1080p for significant size reduction # # Usage: ./transcode.sh [options] # # Options: # --replace Delete original files after successful transcoding # --dry-run Show what would be transcoded without doing it # --crf Quality level (default: 23, lower=better quality, 18-28 typical) # set -e # Configuration DEFAULT_CRF=23 EXTENSIONS=("mov" "mp4" "m4v" "avi" "mkv" "mxf") usage() { cat << EOF Video Transcoding Script Converts video files to H.265/HEVC at 1080p for significant size reduction. Typically achieves 80-95% size reduction from uncompressed 4K footage. Usage: $(basename "$0") [options] Options: --replace Delete original files after successful transcoding --dry-run Show what would be transcoded without doing it --crf Quality level (default: 23) Lower = better quality, larger files 18 = visually lossless, 23 = default, 28 = smaller --help Show this help message Output: Files are saved alongside originals with '_compressed.mp4' suffix. With --replace, originals are deleted after successful transcode. When processing a folder, files are sorted smallest-first. Examples: $(basename "$0") ./video.mov # Transcode single file $(basename "$0") ./media/videos # Transcode folder (smallest first) $(basename "$0") ./media/videos --dry-run # Preview only $(basename "$0") ./media/videos --replace # Transcode and delete originals $(basename "$0") ./media/videos --crf 20 # Higher quality EOF exit 0 } # Parse arguments FOLDER="" REPLACE=false DRY_RUN=false CRF=$DEFAULT_CRF while [[ $# -gt 0 ]]; do case "$1" in --replace) REPLACE=true shift ;; --dry-run) DRY_RUN=true shift ;; --crf) CRF="$2" shift 2 ;; --help|-h) usage ;; -*) echo "Unknown option: $1" usage ;; *) if [[ -z "$FOLDER" ]]; then FOLDER="$1" fi shift ;; esac done # Validate arguments if [[ -z "$FOLDER" ]]; then echo "Error: Folder path is required" echo "" usage fi if [[ ! -d "$FOLDER" && ! -f "$FOLDER" ]]; then echo "Error: Path not found: $FOLDER" exit 1 fi # Check for ffmpeg if ! command -v ffmpeg &> /dev/null; then echo "Error: ffmpeg is not installed" echo "Install with: brew install ffmpeg" exit 1 fi # Build find pattern for video files build_find_pattern() { local pattern="" for ext in "${EXTENSIONS[@]}"; do if [[ -n "$pattern" ]]; then pattern="$pattern -o" fi pattern="$pattern -iname '*.$ext'" done echo "$pattern" } # Format file size for display format_size() { local bytes=$1 if (( bytes >= 1073741824 )); then printf "%.1fG" $(echo "scale=1; $bytes / 1073741824" | bc) elif (( bytes >= 1048576 )); then printf "%.1fM" $(echo "scale=1; $bytes / 1048576" | bc) else printf "%.1fK" $(echo "scale=1; $bytes / 1024" | bc) fi } # Get file size in bytes get_size() { stat -f%z "$1" 2>/dev/null || echo 0 } echo "========================================" echo "Video Transcoder" echo "========================================" echo "Folder: $FOLDER" echo "Codec: H.265/HEVC" echo "Resolution: 1080p (scaled down)" echo "Quality: CRF $CRF" echo "Replace: $REPLACE" [[ "$DRY_RUN" == true ]] && echo "DRY RUN: Yes" echo "========================================" echo "" # Check if input is a file or folder IS_SINGLE_FILE=false if [[ -f "$FOLDER" ]]; then IS_SINGLE_FILE=true VIDEO_FILES=("$FOLDER") echo "Processing single file" echo "" else # Find all video files (excluding already compressed ones), sorted by size (smallest first) FIND_PATTERN=$(build_find_pattern) # Use Python for robust sorting by size (handles spaces in paths correctly) VIDEO_FILES=() while IFS= read -r file; do VIDEO_FILES+=("$file") done < <(eval "find \"$FOLDER\" -type f \( $FIND_PATTERN \)" 2>/dev/null | python3 -c " import sys import os files = [] for line in sys.stdin: path = line.rstrip('\n') if '_compressed.' in path: continue try: size = os.path.getsize(path) files.append((size, path)) except: pass files.sort(key=lambda x: x[0]) for size, path in files: print(path) ") if [[ ${#VIDEO_FILES[@]} -eq 0 ]]; then echo "No video files found in $FOLDER" exit 0 fi echo "Found ${#VIDEO_FILES[@]} video file(s) to process (smallest first)" echo "" fi # Track totals TOTAL_ORIGINAL=0 TOTAL_COMPRESSED=0 SUCCESS_COUNT=0 FAIL_COUNT=0 # Process each file for input_file in "${VIDEO_FILES[@]}"; do # Generate output filename dir=$(dirname "$input_file") basename=$(basename "$input_file") name="${basename%.*}" output_file="$dir/${name}_compressed.mp4" # Get original size original_size=$(get_size "$input_file") original_size_fmt=$(format_size $original_size) echo "----------------------------------------" echo "Input: $input_file ($original_size_fmt)" echo "Output: $output_file" if [[ "$DRY_RUN" == true ]]; then echo "Action: [DRY RUN] Would transcode" continue fi # Skip if output already exists if [[ -f "$output_file" ]]; then echo "Action: Skipped (output already exists)" continue fi # Transcode with ffmpeg # -vf scale=-2:1080 = scale to 1080p height, auto width (divisible by 2) # -c:v libx265 = H.265/HEVC codec # -crf = quality (lower = better) # -preset medium = encoding speed/compression tradeoff # -c:a aac -b:a 128k = audio to AAC at 128kbps # -tag:v hvc1 = compatibility tag for Apple devices echo "Action: Transcoding..." if ffmpeg -i "$input_file" \ -vf "scale=-2:1080" \ -c:v libx265 \ -crf "$CRF" \ -preset medium \ -c:a aac -b:a 128k \ -tag:v hvc1 \ -y \ "$output_file" \ -loglevel warning -stats 2>&1; then # Get compressed size compressed_size=$(get_size "$output_file") compressed_size_fmt=$(format_size $compressed_size) # Calculate reduction if (( original_size > 0 )); then reduction=$(echo "scale=1; 100 - ($compressed_size * 100 / $original_size)" | bc) else reduction=0 fi echo "Result: $original_size_fmt → $compressed_size_fmt (${reduction}% reduction)" TOTAL_ORIGINAL=$((TOTAL_ORIGINAL + original_size)) TOTAL_COMPRESSED=$((TOTAL_COMPRESSED + compressed_size)) ((SUCCESS_COUNT++)) # Delete original if --replace is set if [[ "$REPLACE" == true ]]; then rm "$input_file" echo "Deleted: $input_file" fi else echo "Result: FAILED" ((FAIL_COUNT++)) # Remove partial output file if it exists [[ -f "$output_file" ]] && rm "$output_file" fi done echo "" echo "========================================" echo "Summary" echo "========================================" if [[ "$DRY_RUN" == true ]]; then echo "DRY RUN - no files were transcoded" else echo "Processed: $SUCCESS_COUNT succeeded, $FAIL_COUNT failed" if (( SUCCESS_COUNT > 0 )); then total_orig_fmt=$(format_size $TOTAL_ORIGINAL) total_comp_fmt=$(format_size $TOTAL_COMPRESSED) if (( TOTAL_ORIGINAL > 0 )); then total_reduction=$(echo "scale=1; 100 - ($TOTAL_COMPRESSED * 100 / $TOTAL_ORIGINAL)" | bc) else total_reduction=0 fi echo "Total: $total_orig_fmt → $total_comp_fmt (${total_reduction}% reduction)" if [[ "$REPLACE" == true ]]; then saved=$(format_size $((TOTAL_ORIGINAL - TOTAL_COMPRESSED))) echo "Freed: $saved" fi fi fi echo "========================================"