Files
gnommo/transcode.sh
2026-03-26 10:46:05 +01:00

301 lines
8.1 KiB
Bash
Executable File

#!/bin/zsh
#
# Video Transcoding Script
# Converts video files to H.265/HEVC at 1080p for significant size reduction
#
# Usage: ./transcode.sh <folder> [options]
#
# Options:
# --replace Delete original files after successful transcoding
# --dry-run Show what would be transcoded without doing it
# --crf <N> Quality level (default: 20, lower=better quality, 18-28 typical)
#
set -e
# Configuration
DEFAULT_CRF=18
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") <folder|file> [options]
Options:
--replace Delete original files after successful transcoding
--dry-run Show what would be transcoded without doing it
--crf <N> 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 18 # 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 "========================================"