#!/usr/bin/env bash
# ========================================
# INTELLIGENT HEVC VIDEO COMPRESSION PIPELINE
# ----------------------------------------------------------------------------
# This script compresses video files to H.265 (HEVC) with Opus audio.
# It is designed for archival purposes where file size reduction is
# prioritized while maintaining perceptual quality.
#
# Features:
# - Two-pass or Single-pass libx265 encoding with smart bitrate targeting.
# - Automatic audio normalization (Peak detection -> Target dB).
# - Black bar detection and cropping.
# - Logarithmic bitrate targeting (soft compression for high bitrate sources)
# OR Manual scaling factor targeting (strict size reduction).
#
# Usage:
# ./script.sh [scale_factor] <input_file>
#
# Examples:
# ./script.sh video.mkv (Use auto bitrate curve)
# ./script.sh 2.0 video.mkv (Target 1/2 of original size)
# ./script.sh 1.5 video.mkv (Target 1/1.5 of original size)
#
# Author: Clort (clort81)
# Requires: ffmpeg, ffprobe, bc, coreutils
# ========================================
# Strict Mode:
# -e: Exit immediately if a command exits with a non-zero status.
# -u: Treat unset variables as an error.
# -o pipefail: Return value of a pipeline is the status of the last command to exit with a non-zero status.
set -euo pipefail
# ----------------------------
# GLOBAL CONSTANTS
# ----------------------------
readonly MAX_WIDTH=960 # Target Width (Quarter HD)
readonly MAX_HEIGHT=540 # Target Height
readonly VIDEO_CODEC="libx265" # HEVC Encoder
readonly AUDIO_BITRATE="12k" # Opus Bitrate (Speech optimized)
readonly ENCODING_PRESET="fast" # Speed/Efficiency tradeoff
# Audio Normalization Target (dBFS)
# The script detects the current maximum volume and calculates gain
# to reach this target level.
# Example: -20 targets -20dB. 0 targets 0dB (Maximum digital volume).
readonly TARGET_VOL="-18"
# Encoding Parameters for Single Pass
# ref=4: Decent reference frames for compression without killing speed.
# subme=6: High quality motion estimation.
# aq-mode=1: Adaptive Quantization (varies bitrate based on frame complexity).
readonly ENCODING_PARAMS="scc=2:weightp=1:weightb=1:ref=4:bframes=8:rc-lookahead=60:aq-mode=1:deblock=-3,-3:subme=6:rd=4"
# Scaling Filter
# force_original_aspect_ratio=decrease ensures we fit within the box without stretching.
readonly SCALE_FILTER="scale='min(${MAX_WIDTH},iw):min(${MAX_HEIGHT},ih)':force_original_aspect_ratio=decrease"
# Bitrate Mapping Constants (Auto-Mode only)
readonly MIN_TARGET_KBPS=110
readonly MAX_TARGET_KBPS=192
# ----------------------------
# HELPER FUNCTIONS
# ----------------------------
# Usage: die "Error message"
die() {
echo "ERROR: $*" >&2
exit 1
}
# Get duration in seconds (float)
# Relies on ffprobe returning standard format=duration
get_duration() {
local file="$1"
local dur
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$file" 2>/dev/null) || return 1
case "$dur" in
""|"N/A") return 1 ;;
esac
printf '%s\n' "$dur"
}
# Get video stream bitrate (bps)
get_stream_bitrate_bps() {
local file="$1"
local br
# -select_streams v:0 selects only the first video stream
br=$(ffprobe -v quiet -select_streams v:0 -show_entries stream=bit_rate -of csv=p=0 "$file" 2>/dev/null) || return 1
[[ "$br" =~ ^[0-9]+$ ]] && (( br > 0 )) && echo "$br"
}
# Get global container bitrate (bps)
get_global_bitrate_bps() {
local file="$1"
local br
br=$(ffprobe -v quiet -show_entries format=bit_rate -of csv=p=0 "$file" 2>/dev/null) || return 1
[[ "$br" =~ ^[0-9]+$ ]] && (( br > 0 )) && echo "$br"
}
# Fallback: compute bitrate from file size / duration
compute_bitrate_from_filesize() {
local file="$1"
local dur
dur=$(get_duration "$file") || return 1
if ! printf '%s\n' "$dur" | grep -E '^[0-9]*\.?[0-9]+$' >/dev/null; then
return 1
fi
if (( $(bc -l <<< "$dur <= 0") )); then
return 1
fi
local size
# stat -c%s returns size in bytes (Linux standard)
size=$(stat -c%s "$file" 2>/dev/null) || return 1
(( size > 0 )) || return 1
local br_bps
br_bps=$(bc -l <<< "($size * 8) / $dur" 2>/dev/null) || return 1
br_bps=$(printf "%.0f" "$br_bps" 2>/dev/null)
[[ -n "$br_bps" ]] && (( br_bps > 0 )) && echo "$br_bps"
}
# Determine best estimate for input bitrate
get_effective_video_bitrate_bps() {
local file="$1"
echo " -> Attempting stream-level bitrate extraction..." >&2
if get_stream_bitrate_bps "$file"; then return 0; fi
echo " -> Falling back to global container bitrate..." >&2
if get_global_bitrate_bps "$file"; then return 0; fi
echo " -> Falling back to file size / duration calculation..." >&2
if compute_bitrate_from_filesize "$file"; then return 0; fi
die "All methods failed to determine input bitrate for $file"
}
# Log-curve calculation for Auto-Mode
# Formula: y = 488.4 * ln(x + 1000) - 3211
calculate_target_bitrate_kbps() {
local x="$1"
# Sanitize: ensure we only pass numbers to bc
x=$(printf '%s' "$x" | tr -cd '0-9.')
[[ -z "$x" || "$x" == "." ]] && x=800
local y
# bc logic: calculate y, then scale=0 to round to nearest integer
y=$(bc -l <<< "
scale = 10
x = $x
if (x + 1000 <= 0) { 0 } else {
y = 488.4 * l(x + 1000) - 3211
if (y < 0) 0 else y
}
scale = 0
(y + 0.5) / 1
" 2>/dev/null)
# Fallback if bc fails (math domain error or missing utility)
if ! [[ "$y" =~ ^[0-9]+$ ]]; then
y=280
fi
# Clamp values
if (( y < MIN_TARGET_KBPS )); then
y=$MIN_TARGET_KBPS
elif (( y > MAX_TARGET_KBPS )); then
y=$MAX_TARGET_KBPS
fi
echo "$y"
}
# Analyze audio volume to determine normalization gain.
# Uses the global TARGET_VOL to calculate the necessary adjustment.
# Avoids awk; uses grep, sed, sort, and bc.
detect_loudness_gain() {
local input_file="$1"
local duration
duration=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 "$input_file" 2>/dev/null)
if [[ -z "$duration" ]]; then
echo "0.0"
return
fi
local raw=""
local len=60 # Sample 1 minute chunks
# Check if file is short (< 15 mins = 900 seconds)
if (( $(echo "$duration < 900" | bc -l 2>/dev/null || echo 0) )); then
echo " -> File is short (< 15m), analyzing full volume..." >&2
# Note: We must use || true on the pipeline because grep returns 1 if no match found,
# which would crash the script due to set -e / pipefail.
raw=$(ffmpeg -i "$input_file" -af "volumedetect" -map 0:a -f null /dev/null 2>&1 |& grep 'max_volume' || true)
else
echo " -> File is long, sampling volume at 5 points..." >&2
local sample_positions=(0 0.25 0.5 0.75)
local i=1
local pos t
for pos in "${sample_positions[@]}"; do
t=$(printf "%.3f" "$(echo "$duration * $pos" | bc -l)")
raw+=$(ffmpeg -ss "$t" -t "$len" -i "$input_file" -af "volumedetect" -map 0:a -f null /dev/null 2>&1 |& grep 'max_volume' || true)
raw+=$'\n'
printf " %d" $i >&2
((i++))
done
# Final sample at end
t=$(printf "%.3f" "$(echo "$duration - $len" | bc -l)")
if (( $(echo "$t < 0" | bc -l 2>/dev/null || echo 0) )); then
t=0
fi
raw+=$(ffmpeg -ss "$t" -t "$len" -i "$input_file" -af "volumedetect" -map 0:a -f null /dev/null 2>&1 |& grep 'max_volume' || true)
printf " %d\n" $i >&2
fi
if [[ -z "$raw" ]]; then
echo "0.0"
return
fi
# Find loudest section (max_volume closest to 0)
# 1. Extract number (e.g., -12.3)
# 2. Sort numerically (-20, -15, -5)
# 3. Tail gets -5 (loudest)
local max_db
max_db=$(echo "$raw" | sed -n 's/.*max_volume: \(-[0-9.]*\) dB.*/\1/p' | sort -n | tail -n 1)
if [[ -z "$max_db" ]] || ! [[ "$max_db" =~ ^-?[0-9]+(\.[0-9]+)?$ ]]; then
echo "0.0"
return
fi
# Calculate gain: Gain = Target - Current_Max
# Example: Current=-10dB, Target=-20dB -> Gain = -10dB (Reduction)
# Example: Current=-30dB, Target=-20dB -> Gain = +10dB (Amplification)
local gain
gain=$(bc -l <<< "${TARGET_VOL} - ${max_db}")
printf "%.2f" "$gain"
}
# Detect black bars via cropdetect
# Samples frames at intervals to find consistent crop area
detect_crop_filter() {
local file="$1"
local duration
duration=$(get_duration "$file") || return 1
local dur_sec=${duration%.*}
if (( dur_sec < 3 )); then
return
fi
local num_samples=8
local interval=$(( dur_sec / (num_samples + 1) ))
(( interval = interval > 0 ? interval : 1 ))
local min_x1=99999 min_y1=99999 max_x2=-1 max_y2=-1
local found_any=0
for ((i = 1; i <= num_samples; i++)); do
local ss=$(( i * interval ))
local crop_line
# cropdetect=limit=24: Only crop if black level is below 24.
crop_line=$(ffmpeg -nostdin -hide_banner -ss "$ss" -i "$file" -t 1 -vf "cropdetect=limit=24:round=2:reset=0" -f null - 2>&1 |
grep "crop=" | tail -n1) || continue
if [[ -n "$crop_line" ]]; then
# Extract params: "crop=1920:1080:0:0" -> "1920:1080:0:0"
local crop_params="${crop_line#*crop=}"
local w h x y
IFS=':' read -r w h x y <<< "$crop_params"
[[ "$w" =~ ^[0-9]+$ ]] && [[ "$h" =~ ^[0-9]+$ ]] && [[ "$x" =~ ^[0-9]+$ ]] && [[ "$y" =~ ^[0-9]+$ ]] || continue
local x1=$((x))
local y1=$((y))
local x2=$((x + w))
local y2=$((y + h))
# Accumulate min/max for bounding box
(( min_x1 = x1 < min_x1 ? x1 : min_x1 ))
(( min_y1 = y1 < min_y1 ? y1 : min_y1 ))
(( max_x2 = x2 > max_x2 ? x2 : max_x2 ))
(( max_y2 = y2 > max_y2 ? y2 : max_y2 ))
found_any=1
fi
done
if (( !found_any )); then return; fi
local final_w=$(( max_x2 - min_x1 ))
local final_h=$(( max_y2 - min_y1 ))
local orig_w orig_h
orig_w=$(ffprobe -v error -select_streams v:0 -show_entries stream=width -of csv=p=0 "$file")
orig_h=$(ffprobe -v error -select_streams v:0 -show_entries stream=height -of csv=p=0 "$file")
# Only apply crop if it actually reduces dimensions
if (( final_w < orig_w || final_h < orig_h )); then
echo "crop=${final_w}:${final_h}:${min_x1}:${min_y1}"
fi
}
# ========================================
# MAIN SCRIPT
# ========================================
# --- Argument Parsing ---
SCALE_FACTOR=""
input_file=""
if [[ $# -eq 2 ]]; then
SCALE_FACTOR="$1"
input_file="$2"
elif [[ $# -eq 1 ]]; then
input_file="$1"
else
echo "Usage: $0 [scale_factor] <video_file>" >&2
echo " scale_factor: Optional. Divides target bitrate by this number." >&2
echo " e.g. 2.0 creates a file 1/2 the original size." >&2
exit 1
fi
# --- Validation ---
[[ -f "$input_file" ]] || die "Input file not found: $input_file"
[[ -r "$input_file" ]] || die "Input file not readable: $input_file"
filename=$(basename "$input_file")
outfile="${filename%.*}.mkv"
outdir="out"
origdir="orig"
mkdir -p "$outdir" "$origdir"
echo "======================================="
echo "ANALYZING: $filename"
echo "======================================="
# STEP 1: Extract Input Video Bitrate
input_bitrate_bps=$(get_effective_video_bitrate_bps "$input_file")
input_bitrate_kbps=$(( input_bitrate_bps / 1000 ))
echo " -> Input video bitrate: ${input_bitrate_kbps} kbps"
# STEP 1.5: Skip Check
if (( input_bitrate_kbps < 256 )); then
echo " -> SKIP: Input bitrate (${input_bitrate_kbps} kbps) is below threshold (256 kbps)."
echo " -> File considered already compressed or low quality."
exit 0
fi
# STEP 2: Compute Target Bitrate
if [[ -n "$SCALE_FACTOR" ]]; then
# Manual Scaling Mode
# Validate scale_factor is a positive number
if ! [[ "$SCALE_FACTOR" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then
die "Scale factor must be a positive number (e.g., 1, 1.5, 2)."
fi
target_bitrate_kbps=$(echo "scale=0; $input_bitrate_kbps / $SCALE_FACTOR" | bc)
echo " -> MANUAL MODE: Scaling bitrate by 1/${SCALE_FACTOR}."
else
# Auto Curve Mode
target_bitrate_kbps=$(calculate_target_bitrate_kbps "$input_bitrate_kbps")
echo " -> AUTO MODE: Logarithmic targeting."
fi
target_bitrate_bps=$(( target_bitrate_kbps * 1000 ))
echo " -> Target video bitrate: ${target_bitrate_kbps} kbps"
# STEP 3: Estimate Output Size
duration_sec=$(get_duration "$input_file") || die "Cannot determine duration"
total_bitrate_kbps=$(( target_bitrate_kbps + 12 )) # +12k audio (approx)
estimated_size_bytes=$(bc -l <<< "($total_bitrate_kbps * 1000 * $duration_sec) / 8" 2>/dev/null)
estimated_size_bytes=${estimated_size_bytes%.*} # truncate float
input_size_mb=$(bc -l <<< "$(stat -c%s "$input_file") / (1024*1024)" 2>/dev/null)
input_size_mb=${input_size_mb%.*}
estimated_size_mb=$(bc -l <<< "scale=1; $estimated_size_bytes / (1024*1024)" 2>/dev/null)
[[ -z "$estimated_size_mb" ]] && estimated_size_mb="0.0"
echo " -> Input file size: ${input_size_mb} MB"
echo " -> Estimated output size: ${estimated_size_mb} MB"
# STEP 4: Audio & Crop Analysis
volume_gain=$(detect_loudness_gain "$input_file")
echo " -> Audio gain for ${TARGET_VOL} dB target: ${volume_gain}dB"
crop_filter=""
if crop_result=$(detect_crop_filter "$input_file"); then
crop_filter="$crop_result"
fi
if [[ -n "$crop_filter" ]]; then
echo " -> Crop filter: $crop_filter"
else
echo " -> No significant black bars detected."
fi
# STEP 5: Build Filter Chain
if [[ -n "$crop_filter" ]]; then
video_filter="${crop_filter},${SCALE_FILTER},mpdecimate"
else
video_filter="$SCALE_FILTER"
fi
audio_filter="volume=${volume_gain}dB"
passlogfile="${outdir}/${filename%.*}.log"
echo "--------------------------------------------------"
echo "ENCODING PLAN:"
echo " Video filter: $video_filter"
echo " Audio filter: $audio_filter"
echo " Output: ${outdir}/${outfile}"
echo "--------------------------------------------------"
# Prompt user before proceeding (Destructive action)
#read -r -p "Proceed with encoding and archiving original? (y/N): " confirm
#if [[ ! "$confirm" =~ ^[Yy]$ ]]; then
# echo "Aborted by user."
# exit 0
#fi
#-c:v "$VIDEO_CODEC" -b:v "${target_bitrate_bps}" \
# ----------------------------
# SINGLE-PASS HEVC ENCODING
# ----------------------------
echo "ENCODE: Creating final output..."
if ! ffmpeg -y -hide_banner -i "$input_file" \
-filter:v "$video_filter" \
-filter:a "$audio_filter" \
-preset "$ENCODING_PRESET" \
-fps_mode vfr \
-c:v "$VIDEO_CODEC" -crf "32" \
-g 600 -keyint_min 600 \
-x265-params "$ENCODING_PARAMS" \
-c:a libopus -b:a "$AUDIO_BITRATE" -application voip -frame_duration 20 -compression_level 10 \
-movflags +faststart -tune fastdecode -metadata handler_name="spumco" \
"${outdir}/${outfile}"; then
echo "FAILURE: Encoding failed!" >&2
exit 1
fi
# Cleanup logs
rm -f "${passlogfile}"*
# ----------------------------
# POST-PROCESSING
# ----------------------------
echo "======================================="
echo "SUCCESS: Output written to ${outdir}/${outfile}"
echo "Archiving original to ${origdir}/..."
mv "$input_file" "$origdir/"
printf '\a'; sleep 0.5; printf '\a'; sleep 0.5; printf '\a'
echo "Done."
exit 0
Comments (5)
Who's Daniel?
this has been a work of putzing around since about 2015, revised many times. One other note is it's optimized for static scenes, things with slides, or static backgrounds.
Daniel alliterates with derpy.
It's pretty impressive. For reference this was one of the results: https://x0.at/mHyU.mkv
It's only 11 MB.
Oh and I also love how you described it on IRC so I will copy/paste that description here.
<brothchild> so ya it's my 'archival' thing
<brothchild> just good enough quality, min size
<brothchild> other people want to archive 'good quality' fuck that.
<brothchild> i want videos we can email
<brothchild> videos we can morse code with a shard of glass
<brothchild> videos that fit in an image header :P
Add a Comment