301 lines
8.1 KiB
Bash
Executable File
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: 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") <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 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 "========================================"
|