Script for Video and Audio Re-encoding for Plex

I have been using Handbrake UI for re-encoding video (and audio) for my home media for Plex – the general workflow is makemkv -> handbrake. However, this becomes a little tedious with 4K HDR content that takes a while to re-encode. Additionally, I was not happy with the audio gain options in Handbrake as I can’t seem to find good settings to normalize for hearing dialog well without getting jolted out of my chair on louder music or scenes.

While I knew ffmpeg would be the way to go for automating this using scripting, ffmpeg’s command-line options are a steep learning curve that I never had the time to climb. Enter LLM and vibe coding and in about an hour or so, I was able to complete this task – all of the code is written by AI. The script below is for hardware accelerated decoding and encoding on nvidia video cards.

A few observations on AI based coding workflows as of the time of this post:

  • I think my lazyvim/neovim days are numbered. As much as I enjoy the interface, the fact is the AI assistant integrations are simpler, nicer and easier to use with visual studio code.
  • Using the AI with an IDE/editor is way better than using it as a stand-alone chat assistant and copy-pasting/going back and forth. I think for future projects I will completely forego the chat route – I simply stick with it because that’s where most things start (on the browser), but since development is iterative, it quickly becomes tedious/error-prone compared to pair programming in the IDE.
  • I am as of now not paying for any AI assistant, but I suspect I soon will be – I have perplexity pro free for a year. While this is nice, it has no integration with IDEs and in general isn’t very good at coding tasks. For this particular exercise, I used chatgpt and perplexity on the web, then Gemini on Visual Studio Code. I found Gemini to be particularly good and even a worthy guru to learn from about techniques in scripting!
  • AI’s biggest issue is still hallucinations or outdated facts – for example, it said nvidia video cards restrict number of parallel encodings to 2, but that is outdated. It also stated that the -stats would not show anything if loglevel was set to error but that does not seem to be true.
  • While AI made a mistake (using wait -n in zsh which doesn’t work) and doesn’t in general understand the notion of wait for at least one free slot to kick off the next job by itself, once I pointed out the problem, it came up with a pretty cool fifo based semaphore approach. I am sure that exists on the Internet, but that’s a great technique to keep in my back-pocket for future scripts.
  • Similarly, the mundane tasks around building the list of input files – this is another reusable block that I would take forward in other scripts.
  • Another one is the cool use of exec to create new numbered streams, use of trap instructions for setting up clean-up sections.
  • Overall, I am still uncomfortable that I am losing out on learning by using AI (for example, I have no idea if the ffmpeg options are the “best” or if there are better ones, what they do etc. I also don’t think I retain things that the AI did on its own as well as me doing it – so for shell techniques above, while I understand and learn from them, it’s not clear the retention will be same as me discovering or implementing them myself).
  • Finally, if you are a programmer of any kind, consider AI seriously. If not, you are almost certainly going to be left behind.

You can also find the latest version of this code at: https://github.com/ram-nat/scripts/blob/main/reencode.sh

Update: See this commit for an important bugfix – ctrl-c did not interrupt the processing pipeline as expected. At first, Gemini gaslit me by saying that this is because user is not patient enough for ffmpeg jobs to exit – instead of analyzing the source, I actually thought this was reasonable and tried it out to again to confirm that this is not really the case. The real issue is that the exit trap closes the semaphore fifo first – that means all the blocked reads would return and since the acquire process had no notion of success/failure, they would simply continue processing causing the script to not actually terminate on ctrl-c. The AI was nice enough to fix this once I pointed out the real root cause. I need to think through this a bit more, but the shutting down flag file is completely unnecessary and doesn’t really do much IMO, but Gemini threw that in as part of the fix when pointed to the root cause. I think the reads failing in acquire_token should be sufficient – the edge case of a process going past acquire_token or trying to read from a non-existent named file (3) is not really meaningful in the exit on interrupt scenario IMO. Copilot also thinks the shutting down flag file is an “improvement” but I really don’t see a decent reason why it would be an improvement. Comments on this topic are greatly welcome!

https://github.com/ram-nat/scripts/commit/079b9bb68eadfdd2b4fb5789f2e829a47d20ac7b

Update: See this commit for another important bugfix – this fixes input codec selection which was incorrectly fixed to hevc and therefore failed to work for non-hevc input.

https://github.com/ram-nat/scripts/commit/759c6e57bf55e6a3b69adf53d849d57297158688

#!/usr/bin/env zsh

# reencode.sh: Re-encode multiple MKVs for Plex with HDR preservation and normalized AC3 audio.

function usage() {
    echo "Usage:"
    echo "  $0 [--two-pass] input1.mkv input2.mkv ... OR $0 [--two-pass] directory/"
    echo ""
    echo "Options:"
    echo "  --two-pass    Use two-pass audio normalization (default: single-pass)"
    exit 1
}

# Check dependencies
if ! command -v ffmpeg &> /dev/null || ! command -v ffprobe &> /dev/null; then
    echo "Error: ffmpeg/ffprobe not found!" >&2
    exit 1
fi

# Configuration
MAX_JOBS=4  # NVIDIA consumer GPUs support 2 simultaneous NVENC sessions
AUDIO_BITRATE=640k
PRESET="p6"
USE_TWO_PASS=false
INPUT_FILES=()

# Semaphore setup using mktemp
SEMAPHORE_DIR=$(mktemp -d -p "${TMPDIR:-/tmp}" reencode_semaphore.XXXXXXXXXX) || exit 1
SEMAPHORE_FIFO="${SEMAPHORE_DIR}/control.fifo"

# Initialize semaphore
init_semaphore() {
    mkfifo -m 600 "$SEMAPHORE_FIFO" || {
        echo "Failed to create FIFO" >&2
        cleanup 1
    }
    exec 3<>"$SEMAPHORE_FIFO"
    
    # Fill semaphore with tokens
    local i
    for ((i=0; i<MAX_JOBS; i++)); do
        echo "token" >&3
    done
}

# Acquire semaphore token
acquire_token() {
    local token
    read token <&3
}

# Release semaphore token
release_token() {
    echo "token" >&3
}

# Cleanup function
cleanup() {
    exec 3>&-
    rm -rf "$SEMAPHORE_DIR"
    jobs -p | xargs -r kill 2>/dev/null
    wait 2>/dev/null
    exit ${1:-0}
}

# Set up cleanup trap
trap 'cleanup 1' INT TERM EXIT

# Parse arguments (same as original)
while [[ $# -gt 0 ]]; do
    case "$1" in
        --two-pass)
            USE_TWO_PASS=true
            shift
            ;;
        -h|--help)
            usage
            ;;
        *)
            INPUT_FILES+=("$1")
            shift
            ;;
    esac
done

# Collect input files (same as original)
if [[ ${#INPUT_FILES[@]} -eq 0 ]]; then
    echo "Error: No input files/directories specified!" >&2
    usage
fi

# Expand directories to MKV files (same as original)
expanded_files=()
for input in "${INPUT_FILES[@]}"; do
    if [[ -d "$input" ]]; then
        while IFS= read -r -d $'\0' file; do
            expanded_files+=("$file")
        done < <(find "$input" -name '*.mkv' -print0 2>/dev/null)
    elif [[ -f "$input" ]]; then
        expanded_files+=("$input")
    else
        echo "Error: '$input' not found!" >&2
        exit 1
    fi
done

if [[ ${#expanded_files[@]} -eq 0 ]]; then
    echo "Error: No MKV files found!" >&2
    exit 1
fi

# Function to process a single file with semaphore control
process_file() {
    local input_file="$1"
    local output_file="${input_file:r}_normalized.mkv"
    local json_stats
    
    # Acquire semaphore token
    acquire_token
    
    # Detect video properties for color metadata handling
    local hdr_params=()
    local probed_info
    probed_info=$(ffprobe -v error -select_streams v:0 \
        -show_entries stream=color_transfer,stream=pix_fmt,stream=color_primaries,stream=colorspace \
        -of default=nw=1:nk=1 "${input_file}")

    # Read ffprobe output line by line into an array
    # Order: color_transfer, pix_fmt, color_primaries, colorspace
    local ffprobe_output_array=()
    while IFS= read -r line; do
        ffprobe_output_array+=("$line")
    done <<< "$probed_info"

    local color_trc="${ffprobe_output_array[0]}"
    local input_pix_fmt="${ffprobe_output_array[1]}"
    local color_primaries="${ffprobe_output_array[2]}" # May be "N/A" or "unknown"
    local colorspace="${ffprobe_output_array[3]}"    # May be "N/A" or "unknown"

    if [[ "$color_trc" == "smpte2084" || "$color_trc" == "arib-std-b67" ]]; then
        # Handle common HDR types (HDR10/PQ, HLG)
        hdr_params=(
            -color_primaries bt2020 # Standard for these HDR formats
            -color_trc "${color_trc}"
            -colorspace bt2020nc   # Standard for these HDR formats
            -pix_fmt p010le        # Common 10-bit format for HDR with NVENC
        )
    elif [[ "$input_pix_fmt" == *10le || "$input_pix_fmt" == *10be || "$input_pix_fmt" == "p010le" ]]; then
        # Handle 10-bit SDR: preserve original 10-bit pixel format and explicitly pass SDR color metadata.
        hdr_params=(
            -pix_fmt "$input_pix_fmt" # Preserve 10-bit depth
        )
        # Pass through original SDR color metadata if valid
        if [[ -n "$color_primaries" && "$color_primaries" != "unknown" && "$color_primaries" != "N/A" ]]; then
            hdr_params+=(-color_primaries "$color_primaries")
        fi
        # color_trc is known not to be smpte2084 or arib-std-b67 here.
        if [[ -n "$color_trc" && "$color_trc" != "unknown" && "$color_trc" != "N/A" ]]; then
            hdr_params+=(-color_trc "$color_trc")
        fi
        if [[ -n "$colorspace" && "$colorspace" != "unknown" && "$colorspace" != "N/A" ]]; then
            hdr_params+=(-colorspace "$colorspace")
        fi
    fi

    # Audio normalization (same as original)
    local audio_filter=()
    if [[ "$USE_TWO_PASS" == "true" ]]; then
        json_stats=$(mktemp)
        ffmpeg -nostdin -hide_banner -y -i "${input_file}" -map 0:a:0 \
            -af loudnorm=print_format=json -f null /dev/null 2> "${json_stats}"
        
        local measured_i=$(grep -oP '"input_i"\s*:\s*"\K[^"]+' "${json_stats}")
        local measured_tp=$(grep -oP '"input_tp"\s*:\s*"\K[^"]+' "${json_stats}")
        local measured_lra=$(grep -oP '"input_lra"\s*:\s*"\K[^"]+' "${json_stats}")
        local measured_thresh=$(grep -oP '"input_thresh"\s*:\s*"\K[^"]+' "${json_stats}")
        local offset=$(grep -oP '"target_offset"\s*:\s*"\K[^"]+' "${json_stats}")

        audio_filter=(
            -filter:a:1 "loudnorm=I=-23:LRA=7:TP=-2.0:
                measured_I=${measured_i}:measured_tp=${measured_tp}:
                measured_lra=${measured_lra}:measured_thresh=${measured_thresh}:
                offset=${offset}:linear=true"
        )
        rm "${json_stats}"
    else
        audio_filter=(
            -filter:a:1 "loudnorm=I=-23:LRA=7:TP=-2.0"
        )
    fi

    # Encoding command - stderr preserved for stats visibility
    ffmpeg -nostdin -loglevel error -stats -hide_banner -y \
        -c:v hevc_cuvid -i "${input_file}" \
        < /dev/null \
        -map 0:v:0 -map 0:a:0 -map 0:a:0 -map '0:s?' \
        -c:v hevc_nvenc \
        -preset "${PRESET}" \
        -rc:v vbr_hq \
        -b:v 15M -maxrate 25M -bufsize 30M \
        "${hdr_params[@]}" \
        -c:a:0 copy \
        -c:a:1 ac3 -b:a:1 "${AUDIO_BITRATE}" \
        "${audio_filter[@]}" \
        -c:s copy \
        -metadata:s:a:1 title="Normalized Audio" \
        "${output_file}"
    
    # Release semaphore token when job completes
    release_token
    echo "Completed: ${input_file}"
}

# Initialize semaphore
init_semaphore

# Process files in parallel
for input_file in "${expanded_files[@]}"; do
    echo "Starting: ${input_file}"
    (process_file "$input_file") &
done

# Wait for all jobs to complete
wait
echo "All conversions completed!"

# Clean up (trap will handle this)
exec 3>&-
trap - INT TERM EXIT

1 Reply to “Script for Video and Audio Re-encoding for Plex”

Leave a Reply

Your email address will not be published. Required fields are marked *