#!/usr/bin/env bash

# Converts one video. Every setting arrives as an environment variable;
# --help lists them.
# shellcheck disable=SC2154

if [[ -z "$ffmpeg_executable" ]]; then
	if [[ -e /usr/lib/jellyfin-ffmpeg/ffmpeg ]]; then
		ffmpeg_executable="/usr/lib/jellyfin-ffmpeg/ffmpeg"
	else
		ffmpeg_executable="ffmpeg"
	fi
fi

if [[ $show_executable == 1 ]]; then
	echo "$ffmpeg_executable"
	exit 0
fi

if [[ $# -eq 0 || $1 == -h || $1 == --help ]]; then
	cat <<EOF
Usage: [variable=value ...] ${0##*/} input_file

Settings are environment variables; unset ones take the default shown.

Output
  output_file=PATH          destination; a taken name gets "_1", "_2", ...
  output_folder=DIR         folder for the default name (ignored with output_file)
  output_format=EXT         container: mp4 (default), mkv, mov, webm, ...
  options="FFMPEG OPTIONS"  extra FFmpeg options, e.g. options="-ss 60 -t 30"
  segments=S+D,S+D,...      join these cuts (start+duration, seconds) into one video
  size_limit=BYTES          refuse a bigger output: encode once more at a fitting bitrate

Video
  video_encoder=NAME        h264 (default), h265, av1, vp9, prores
  uhd_h265=1                encode an H.264 output wider than 1920 px as H.265,
                            which televisions play
  video_quality=NAME        veryhigh, high, medium (default), low, verylow, superlow
  video_bitrate=BITS        average bitrate in bit/s instead of a quality (two passes in software)
  keyframe_interval=SECONDS at most this long between keyframes
  preset=NAME               ultrafast, veryfast, faster, medium, slow, veryslow
  video_resolution=WxH      fit the picture inside this box
  video_fps=RATE            output frame rate, e.g. 24 or 30000/1001
  video_filter=CHAIN        FFmpeg video filters, e.g. video_filter=crop=1280:720:0:0
  video_stabilize=1         stabilize (two passes)
  source_hdr=MODE           auto (default), pq, hlg or sdr when the file does not say
  keep_timecode=1           keep a camera timecode track in MP4
  force_copy_video=1        copy the video stream without encoding

Hardware
  gpu=NAME                  auto (default), nvidia, amd, intel, vulkan, software;
                            auto takes, among the GPUs that encode the codec, the
                            one that alone encodes AV1, else VP9, else NVIDIA,
                            AMD, Intel in that order
  gpu_device=PATH           device to use, e.g. /dev/dri/renderD129
  gpu_partial=1             decode in software, encode on the GPU
  force_software=1          decode and encode in software
  force_encoder=NAME        nvenc, vaapi, qsv, vulkan, software
  force_decoder=NAME        cuda, vaapi, qsv, vulkan, software
  gpu_smoke_test=0          skip the two-frame check of the GPU encoder
  gpu_probe_timeout=SECONDS limit for that check (default 30)
  stall_timeout=SECONDS     stop FFmpeg after this long without progress (default 600, 0 never)

Audio
  audio_handling=MODE       copy (default), reencode, none
  audio_codec=NAME          aac (default), opus, ac3
  audio_bitrate=RATE        e.g. 192k (default 32k per channel)
  audio_channels=N          channel count of the output
  hpf_enabled=1             high-pass filter at hpf_frequency Hz (default 80)
  compressor_enabled=1      compressor, compressor_intensity 0..1 (default 1)
  normalize_enabled=1       even out the speech volume
  noise_reduction=1         AI noise reduction: noise_model 0 (DeepFilterNet3) or 1 (DPDFNet),
                            noise_strength 0..1 (default 1)
  voice_only=1              with noise_reduction, audio only while a voice is heard;
                            voice_only_sensitivity 1 (keeps faint voices) to 5 (default 3)
  noise_gate=1              noise gate, gate_intensity 0..1 (default 0.5)
  eq_enabled=1              equalizer, eq_bands = ten gains in dB, 31 Hz to 16 kHz

Subtitles
  subtitle_extract=MODE     extract (default, .srt files beside the video), embedded, none
  only_extract_subtitles=1  only write the .srt files

Programs and presets
  ffmpeg_executable=PATH    FFmpeg to use (default ffmpeg, or jellyfin-ffmpeg when installed)
  ffprobe_executable=PATH   FFprobe to use (default: the one beside FFmpeg)
  show_executable=1         print the FFmpeg that would be used and exit
  preset_file=PATH          TOML preset filling in the settings left unset
  BVC_PRESET_SETTINGS_RESOLVED=1  the caller already applied the preset's settings
EOF
	exit 0
fi

input_file="$1"
# Absolute paths also keep leading '-' and ':' in filenames away from option
# parsing and FFmpeg's protocol selector. Do not normalize via command output:
# that would strip a trailing newline from an otherwise valid filename.
[[ $input_file == /* ]] || input_file="$PWD/$input_file"

if [[ ! -f "$input_file" ]]; then
	echo "ERROR: Input file not found: $input_file"
	exit 1
fi

if [[ ! -r "$input_file" ]]; then
	echo "ERROR: Input file is not readable: $input_file"
	exit 1
fi

# Everything this job writes goes next to the file it will publish. Never
# remove a file merely because its name resembles one of our outputs.
output_stage_dir=""
# The ffmpeg run by run_ffmpeg is a background job, so a signal that ends this
# script (Ctrl+C, the GUI's cancel) must not leave it encoding on its own.
ffmpeg_pid=""
claimed_name=""
cleanup() {
	if [[ -n $ffmpeg_pid ]] && kill -0 "$ffmpeg_pid" 2>/dev/null; then
		kill -TERM "$ffmpeg_pid" 2>/dev/null
		local i
		for ((i = 0; i < 50; i++)); do
			kill -0 "$ffmpeg_pid" 2>/dev/null || break
			sleep 0.1
		done
		kill -KILL "$ffmpeg_pid" 2>/dev/null
		wait "$ffmpeg_pid" 2>/dev/null
	fi
	[[ -z $output_stage_dir ]] || rm -rf -- "$output_stage_dir"
	if [[ -n $claimed_name && -f $claimed_name && ! -s $claimed_name ]]; then
		rm -f -- "$claimed_name"
	fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM

# The encode happens in a private directory and only a finished file takes a
# name, so a failed or cancelled job never leaves a truncated video behind.
# Nothing is ever replaced: the name is claimed first with an exclusive create
# (noclobber opens a new file O_EXCL, so two jobs cannot both win it) and the
# finished file is then renamed over that empty placeholder. Renaming needs no
# free space and, unlike link(2), works on FAT, exFAT and most FUSE mounts.
# -T keeps a destination that became a directory from receiving the file.
# Returns 0 when published, 1 when the name is taken, 2 on any other failure.
publish_as() { # <staged file> <destination>
	[[ ! -e $2 && ! -L $2 ]] || return 1
	if ! (
		set -o noclobber
		: >"$2"
	) 2>/dev/null; then
		[[ ! -e $2 && ! -L $2 ]] || return 1
		echo "ERROR: Cannot create $2" >&2
		return 2
	fi
	claimed_name=$2
	if mv -fT -- "$1" "$2"; then
		claimed_name=""
		return 0
	fi
	rm -f -- "$2"
	claimed_name=""
	return 2
}

# A taken name gets a counter, "movie_1.mp4", as the GUI's own batches do;
# the name used is left in published_file.
published_file=""
publish() { # <staged file> <desired destination>
	local candidate=$2 counter status
	for ((counter = 1; counter <= 10000; counter++)); do
		publish_as "$1" "$candidate"
		status=$?
		if [[ $status == 0 ]]; then
			published_file=$candidate
			return 0
		fi
		[[ $status == 1 ]] || return 1
		candidate="${2%.*}_$counter.${2##*.}"
	done
	echo "ERROR: No unused output name next to $2" >&2
	return 1
}

script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)

# Use the same FFprobe distribution as the selected FFmpeg whenever possible.
if [[ -z ${ffprobe_executable:-} ]]; then
	ffmpeg_path=$(command -v -- "$ffmpeg_executable") || exit 1
	if [[ -x ${ffmpeg_path%/*}/ffprobe ]]; then
		ffprobe_executable="${ffmpeg_path%/*}/ffprobe"
	else
		ffprobe_executable=ffprobe
	fi
fi

# A preset file (TOML, read by engine/presets.py) fills in whatever the caller
# left unset and prepends its own FFmpeg options; an explicit variable always
# wins, so the GUI's widgets stay the truth when both are present. The helper
# validates the file and answers NUL-separated, never as shell text.
preset_reader="$script_dir/../share/big-video-converter/engine/presets.py"
if [[ -n ${preset_file:-} ]]; then
	python3 "$preset_reader" --validate "$preset_file" >/dev/null || exit 2
	while IFS= read -r -d '' pair; do
		name=${pair%%=*}
		value=${pair#*=}
		[[ $name =~ ^[a-z_]+$ ]] || continue
		if [[ $name == preset_options ]]; then
			options="$value ${options:-}"
		elif [[ ${BVC_PRESET_SETTINGS_RESOLVED:-0} != 1 && -z ${!name:-} ]]; then
			printf -v "$name" '%s' "$value"
		fi
	done < <(python3 "$preset_reader" --env "$preset_file")
	echo "Preset: $preset_file"
fi

# Arguments a preset pins for one encoder go after the script's own, so
# FFmpeg's last-one-wins rule lets them override a single knob.
append_preset_encoder_args() { # <encoder array name>
	[[ -n ${preset_file:-} ]] || return 0
	local -n encoder_argv=$1
	local i name=""
	for ((i = 0; i < ${#encoder_argv[@]} - 1; i++)); do
		[[ ${encoder_argv[$i]} == -c:v ]] && name=${encoder_argv[$((i + 1))]}
	done
	[[ -n $name && $name != copy ]] || return 0
	local -a extra=()
	if ! python3 "$preset_reader" --encoder-args "$preset_file" "$name" >"$output_stage_dir/encoder-args"; then
		echo "ERROR: Cannot read the encoder arguments of $preset_file" >&2
		exit 2
	fi
	mapfile -d '' -t extra <"$output_stage_dir/encoder-args"
	[[ ${#extra[@]} -eq 0 ]] || encoder_argv+=("${extra[@]}")
}

# Scalar values used in arithmetic must not become awk expressions.
for name in hpf_frequency compressor_intensity gate_intensity noise_strength noise_model; do
	value=${!name}
	if [[ -n $value && ! $value =~ ^[0-9]+([.][0-9]+)?$ ]]; then
		echo "ERROR: Invalid numeric setting: $name" >&2
		exit 2
	fi
done
if [[ -n ${voice_only_sensitivity:-} && ! $voice_only_sensitivity =~ ^[1-5]$ ]]; then
	echo "ERROR: Invalid voice sensitivity (1 to 5): $voice_only_sensitivity" >&2
	exit 2
fi
if [[ -n ${video_bitrate:-} && ! $video_bitrate =~ ^[1-9][0-9]{4,9}$ ]]; then
	echo "ERROR: Invalid video bitrate (bits per second): $video_bitrate" >&2
	exit 2
fi
if [[ -n ${size_limit:-} && ! $size_limit =~ ^[1-9][0-9]{5,12}$ ]]; then
	echo "ERROR: Invalid size limit (bytes): $size_limit" >&2
	exit 2
fi
if [[ -n ${keyframe_interval:-} && ! $keyframe_interval =~ ^([1-9][0-9]?|0[.][1-9][0-9]*|[1-9][0-9]?[.][0-9]+)$ ]]; then
	echo "ERROR: Invalid keyframe interval (seconds): $keyframe_interval" >&2
	exit 2
fi
if [[ -n $audio_channels && ! $audio_channels =~ ^([1-9]|[1-5][0-9]|6[0-4])$ ]]; then
	echo "ERROR: Invalid audio channel count" >&2
	exit 2
fi
if [[ -n $eq_bands ]]; then
	IFS=, read -ra eq_values <<<"$eq_bands"
	[[ ${#eq_values[@]} -eq 10 ]] || {
		echo "ERROR: Expected ten equalizer bands" >&2
		exit 2
	}
	for value in "${eq_values[@]}"; do
		[[ $value =~ ^-?[0-9]+([.][0-9]+)?$ ]] || {
			echo "ERROR: Invalid equalizer gain" >&2
			exit 2
		}
	done
fi

# Probing. ffprobe exits non-zero and prints nothing when it cannot open a
# decoder for ANY stream of the file. Some MKVs with dvd_subtitle tracks do
# this on FFmpeg 9 ("Could not open codec for input stream N") although
# ffmpeg converts them without trouble, so an ffprobe failure never means
# "corrupted": the helpers below fall back to parsing `ffmpeg -i` output.
ffmpeg_probe_output=""

# ffprobe 9 answers "-show_entries stream=..." with the stream_groups section
# as well (MOV files carry a Track Reference group for their timecode track).
# The CSV writer then prints an empty row for the group and repeats the streams
# it references, without their tags: a stereo track reads as "\n2". Keep one
# row per leading field; the last one wins because the real stream entries,
# tags included, are printed after the group's copies.
probe_rows() { # <stream specifier> <show_entries> → clean CSV rows
	"$ffprobe_executable" -v error -select_streams "$1" -show_entries "$2" -of csv=p=0 "$input_file" 2>/dev/null |
		awk -F, 'NF && $1 != "" { if (!($1 in row)) order[++n] = $1; row[$1] = $0 }
		         END { for (i = 1; i <= n; i++) print row[order[i]] }' | sed 's/,$//'
}

probe_value() { # <stream specifier> <entry> → a single scalar
	probe_rows "$1" "$2" | head -n1
}

# Raw `ffmpeg -i` report for the input file (cached).
ffmpeg_probe() {
	if [[ -z $ffmpeg_probe_output ]]; then
		ffmpeg_probe_output=$("$ffmpeg_executable" -hide_banner -i "$input_file" -t 0 -f null - 2>&1)
	fi
	printf '%s\n' "$ffmpeg_probe_output"
}

# Only the input stream declarations, so the Output/mapping lines don't leak in.
ffmpeg_probe_input_block() {
	ffmpeg_probe | sed -n '/^Input #0/,/^Output #/p'
}

# Emits "index|language|codec" for every input stream of the given kind
# (Video, Audio or Subtitle), parsed from ffmpeg's own report.
ffmpeg_probe_streams() {
	local kind="$1" line
	local stream_re='Stream #0:([0-9]+)(\(([a-zA-Z]{2,3})\))?[^:]*: ([A-Za-z]+): ([A-Za-z0-9_]+)'
	while IFS= read -r line; do
		if [[ $line =~ $stream_re ]]; then
			[[ ${BASH_REMATCH[4]} == "$kind" ]] || continue
			printf '%s|%s|%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[3]}" "${BASH_REMATCH[5]}"
		fi
	done < <(ffmpeg_probe_input_block)
}

# Fallbacks mirroring the CSV layout of the ffprobe calls they replace.
fb_audio_info() { # index,language
	local idx lang codec
	while IFS='|' read -r idx lang codec; do
		printf '%s,%s\n' "$idx" "$lang"
	done < <(ffmpeg_probe_streams Audio)
}

fb_subtitle_codecs() { # index,codec_name
	local idx lang codec
	while IFS='|' read -r idx lang codec; do
		printf '%s,%s\n' "$idx" "$codec"
	done < <(ffmpeg_probe_streams Subtitle)
}

# Channel count / layout of an audio stream, by absolute stream index.
fb_audio_layout() {
	local want="$1" line
	local layout_re="Stream #0:${want}[^:]*: Audio: [^,]+, [0-9]+ Hz, ([^,]+)"
	while IFS= read -r line; do
		[[ $line =~ $layout_re ]] || continue
		printf '%s\n' "${BASH_REMATCH[1]}"
		return
	done < <(ffmpeg_probe_input_block)
}

fb_audio_channels() {
	local layout
	layout=$(fb_audio_layout "$1")
	case ${layout%%(*} in
	mono) echo 1 ;;
	stereo) echo 2 ;;
	2.1 | 3.0) echo 3 ;;
	quad | 4.0 | 3.1) echo 4 ;;
	5.0) echo 5 ;;
	5.1 | 6.0) echo 6 ;;
	6.1 | 7.0) echo 7 ;;
	7.1) echo 8 ;;
	# "3 channels": ffmpeg's form for a layout without a name.
	[0-9]*\ channels) echo "${layout%% *}" ;;
	*) echo "" ;;
	esac
}

# Is ffprobe usable for this particular file?
ffprobe_usable=true
if [[ -z $("$ffprobe_executable" -v error -show_entries format=format_name -of csv=p=0 "$input_file" 2>/dev/null) ]]; then
	ffprobe_usable=false
fi

# Validate the input: only reject the file when ffmpeg itself sees no video.
if [[ $ffprobe_usable == false ]]; then
	if ! grep -qE 'Stream #0:[0-9]+.*: Video:' <<<"$(ffmpeg_probe_input_block)"; then
		echo "ERROR: Input file is corrupted or not a valid video file: $input_file"
		echo "The file container could not be read. It may be a corrupted download or an unsupported format."
		exit 2
	fi
	echo "Warning: ffprobe could not analyze this file; using ffmpeg metadata instead."
fi

echo "Analyzing input video..."
if [[ $ffprobe_usable == true ]]; then
	pix_fmt=$(probe_value v:0 stream=pix_fmt)
	video_profile=$(probe_value v:0 stream=profile)
else
	# "Video: h264 (Main), yuv420p(tv, bt709), 1920x820" → profile / pixel format
	probe_video_line=$(ffmpeg_probe_input_block | grep -m1 -E 'Stream #0:[0-9]+.*: Video:')
	pix_fmt=""
	video_profile=""
	profile_re='Video: [A-Za-z0-9_]+ \(([^)]*)\)'
	pix_fmt_re='Video: [^,]+, ([a-z0-9]+)'
	if [[ $probe_video_line =~ $profile_re ]]; then
		video_profile="${BASH_REMATCH[1]}"
	fi
	if [[ $probe_video_line =~ $pix_fmt_re ]]; then
		pix_fmt="${BASH_REMATCH[1]}"
	fi
fi

is_10bit=false
if [[ $pix_fmt =~ yuv.*p10 || $pix_fmt =~ yuv.*10le || $pix_fmt =~ .*10bit.* || $pix_fmt =~ yuv.*p12 || $pix_fmt =~ yuv.*12le ]]; then
	is_10bit=true
	echo "Detected high bit depth video input (pixel format: $pix_fmt)"
elif [[ $video_profile =~ .*10.* || $video_profile =~ .*High\ 10.* || $video_profile =~ .*Main\ 10.* ]]; then
	is_10bit=true
	echo "Detected 10-bit video input (profile: $video_profile)"
else
	if [[ -z "$pix_fmt" ]]; then
		echo "Warning: Could not detect pixel format - file may be corrupted or use an unusual format"
	fi
	echo "Detected 8-bit video ($pix_fmt) - using standard profile"
fi

# Detect pixel formats with implicit GBR color matrix (e.g. ProRes 4444/XQ yuv444p12le)
# These formats cause swscaler conversion failures and need zscale to override color matrix
needs_gbr_matrix_fix=false
if [[ $pix_fmt =~ 444p12 || $pix_fmt =~ ^gbrp ]]; then
	needs_gbr_matrix_fix=true
	echo "Detected video with GBR-incompatible pixel format ($pix_fmt) - zscale color fix will be applied"
fi

echo "Detecting source video codec..."
if [[ $ffprobe_usable == true ]]; then
	source_video_codec=$("$ffprobe_executable" -v error -select_streams v:0 -show_entries stream=codec_name -of default=noprint_wrappers=1:nokey=1 "$input_file" 2>/dev/null | head -n1)
else
	source_video_codec=$(ffmpeg_probe_streams Video | head -n1 | cut -d'|' -f3)
fi
echo "Source video codec detected: $source_video_codec"

# Source geometry, frame rate and dynamic range. Three decisions below depend
# on them: the H.264 level, whether 4K output may stay in H.264 at all, and
# whether an HDR source needs tone mapping to become a valid SDR file.
source_width=""
source_height=""
source_fps_num=""
source_fps_den=""
source_transfer=""
source_primaries="" source_matrix=""
source_rotation=0 source_field_order=""
if [[ $ffprobe_usable == true ]]; then
	# key=value lines: ffprobe's CSV writer orders fields its own way, not
	# the way they were requested, so positional parsing mislabels them.
	while IFS='=' read -r key value; do
		case $key in
		width) source_width=$value ;;
		height) source_height=$value ;;
		r_frame_rate)
			source_fps_num=${value%%/*}
			source_fps_den=${value##*/}
			;;
		color_transfer) source_transfer=$value ;;
		color_primaries) source_primaries=$value ;;
		color_space) source_matrix=$value ;;
		rotation) source_rotation=$value ;;
		field_order) source_field_order=$value ;;
		esac
	done < <("$ffprobe_executable" -v error -select_streams v:0 \
		-show_entries stream=width,height,r_frame_rate,color_transfer,color_primaries,color_space,field_order:stream_side_data=rotation \
		-of default=noprint_wrappers=1 "$input_file" 2>/dev/null)
else
	dims_re='([0-9]{3,5})x([0-9]{3,5})'
	fps_re='([0-9.]+) fps'
	[[ $probe_video_line =~ $dims_re ]] && source_width=${BASH_REMATCH[1]} && source_height=${BASH_REMATCH[2]}
	[[ $probe_video_line =~ $fps_re ]] && source_fps_num=${BASH_REMATCH[1]%%.*} && source_fps_den=1
	[[ $probe_video_line == *smpte2084* ]] && source_transfer=smpte2084
	[[ $probe_video_line == *arib-std-b67* ]] && source_transfer=arib-std-b67
	[[ $probe_video_line =~ (top|bottom)\ (coded\ )?first ]] && source_field_order=tt
	rotation_re='rotation of (-?[0-9]+)'
	[[ $(ffmpeg_probe_input_block) =~ $rotation_re ]] && source_rotation=${BASH_REMATCH[1]}
fi
[[ $source_fps_num =~ ^[0-9]+$ ]] || source_fps_num=30
[[ $source_fps_den =~ ^[0-9]+$ && $source_fps_den -gt 0 ]] || source_fps_den=1

# source_hdr=pq|hlg|sdr overrides the detection. A recording can carry no
# color description at all (a DVB sample: none in the stream, none per
# frame), and FFprobe builds differ on the container's (jellyfin-ffmpeg 7.1
# ignored Matroska's): such HDR video was converted as if it were SDR.
source_hdr_given=false
case ${source_hdr:-auto} in
auto) ;;
pq) source_transfer=smpte2084 source_hdr_given=true ;;
hlg) source_transfer=arib-std-b67 source_hdr_given=true ;;
sdr) source_transfer="" ;;
*)
	echo "ERROR: Invalid source_hdr: $source_hdr" >&2
	exit 2
	;;
esac

is_hdr=false
if [[ $source_transfer == smpte2084 || $source_transfer == arib-std-b67 ]]; then
	is_hdr=true
	echo "Detected HDR video input (transfer: $source_transfer$([[ $source_hdr_given == false ]] || echo ", set by the user"))"
fi

# Output geometry: the requested resolution when there is one, else the source.
output_width=$source_width
output_height=$source_height
fit_size=""
if [[ $video_resolution =~ ^([0-9]+)x([0-9]+)$ ]]; then
	output_width=${BASH_REMATCH[1]}
	output_height=${BASH_REMATCH[2]}
	# The requested resolution is a box: the picture keeps its aspect ratio
	# inside it, with the even dimensions encoders need, instead of being
	# stretched. iw/ih are the frames reaching the scaler, already rotated.
	# Expressions, because scale_qsv and scale_vulkan lack
	# force_original_aspect_ratio.
	fit_size="w='min($output_width,trunc(iw*$output_height/ih/2)*2)':h='min($output_height,trunc(ih*$output_width/iw/2)*2)'"
elif [[ -n $video_resolution ]]; then
	echo "ERROR: Invalid video resolution: $video_resolution" >&2
	exit 2
fi

# Output frame rate (video_fps, "24" or "30000/1001"). Fewer frames drop the
# extra ones; more frames blend neighbors, since FFmpeg's motion interpolation
# (minterpolate) takes hours for a 1080p film. Within half a percent (29.97
# against 30) nothing changes.
output_fps_num=$source_fps_num output_fps_den=$source_fps_den
fps_filter=""
if [[ -n ${video_fps:-} ]]; then
	if [[ ! $video_fps =~ ^([1-9][0-9]{0,5})(/([1-9][0-9]{0,4}))?$ ]] ||
		((BASH_REMATCH[1] > 240 * ${BASH_REMATCH[3]:-1})); then
		echo "ERROR: Invalid frame rate: $video_fps" >&2
		exit 2
	fi
	fps_target=$((BASH_REMATCH[1] * source_fps_den)) fps_source=$((source_fps_num * ${BASH_REMATCH[3]:-1}))
	if ((fps_target * 1000 < fps_source * 995)); then
		fps_filter="fps=$video_fps"
	elif ((fps_target * 1000 > fps_source * 1005)); then
		fps_filter="framerate=fps=$video_fps"
	fi
	if [[ -n $fps_filter ]]; then
		output_fps_num=${BASH_REMATCH[1]} output_fps_den=${BASH_REMATCH[3]:-1}
		echo "Frame rate: $source_fps_num/$source_fps_den to $video_fps (${fps_filter%%=*})"
	fi
fi

# Interlaced sources (DVD, television) show combing on every moving edge.
# deint=interlaced leaves the frames flagged progressive untouched.
deinterlace_filter=""
case $source_field_order in
tt | bb | tb | bt)
	deinterlace_filter="bwdif=mode=send_frame:deint=interlaced"
	echo "Interlaced video ($source_field_order): deinterlacing"
	;;
esac

# Derive the destination without treating dots in parent directories as an
# extension. User-specified paths are literal data, never shell fragments.
input_file_without_extension=${input_file##*/}
input_file_without_extension=${input_file_without_extension%.*}
if [[ -z $output_file ]]; then
	input_dir=${input_file%/*}
	output_file="${output_folder:-$input_dir}/$input_file_without_extension.${output_format:-mp4}"
elif [[ -n $output_format ]]; then
	output_dir=${output_file%/*}
	output_name=${output_file##*/}
	[[ $output_dir != "$output_file" ]] || output_dir=.
	output_file="$output_dir/${output_name%.*}.$output_format"
elif [[ ! ${output_file,,} =~ \.(mp4|avi|mkv|mov|wmv|flv|webm|m4v|mpeg|mpg)$ ]]; then
	output_file+=.mp4
fi
[[ $output_file == /* ]] || output_file="$PWD/$output_file"
# "-ef" compares device and inode through symlinks, so this one test covers
# every spelling of "the output is the input", including a symlinked path and a
# hard link. Encoding over the source would destroy it while still reading it.
if [[ $output_file -ef $input_file ]]; then
	echo "ERROR: The output file is the same as the input file: $output_file" >&2
	echo "Choose a different output folder or format." >&2
	exit 1
fi

# WebM holds only VP8, VP9 or AV1 video and Vorbis or Opus audio, and FFmpeg
# rejects any other codec only after the whole encode, so the legal codecs are
# chosen up front.
if [[ ${output_file,,} == *.webm ]]; then
	if [[ $force_copy_video != 1 && $video_encoder != vp9 && $video_encoder != av1 ]]; then
		echo "WebM output: encoding the video as VP9 instead of ${video_encoder:-h264}"
		video_encoder=vp9
	fi
	audio_codec=opus
	if [[ ${audio_handling:-copy} == copy ]]; then
		if [[ $ffprobe_usable == true ]]; then
			webm_audio=$(probe_rows a stream=codec_name)
		else
			webm_audio=$(ffmpeg_probe_streams Audio | cut -d'|' -f3)
		fi
		if grep -qvxE 'opus|vorbis' <<<"$webm_audio" && [[ -n $webm_audio ]]; then
			echo "WebM output: encoding the audio as Opus"
			audio_handling=reencode
		fi
	fi
fi

# Above 1080p, H.264 is a PC-only format: televisions decode UHD in HEVC (or
# VP9/AV1), never in H.264. With uhd_h265=1 (opt-in, the GUI's "H.265 for 4K
# videos" switch) an H.264 encode whose output is wider than 1920 px becomes
# H.265, so the file opens on a TV. Off by default: the codec the user picked
# is the codec they get.
if [[ $uhd_h265 == 1 && $force_copy_video != 1 && ($video_encoder == "h264" || -z "$video_encoder") && $output_width =~ ^[0-9]+$ && $output_width -gt 1920 ]]; then
	video_encoder=h265
	echo "Output is wider than 1920 px: switching from H.264 to H.265 (televisions do not decode H.264 above 1080p)"
fi

# The smallest H.264 level whose macroblock budget fits the picture and frame
# rate. Television decoders trust the level in the header, so a 4K stream
# marked 4.1 (a 1080p limit) fails to open on them.
h264_level_for() {
	local w=$1 h=$2 num=${3:-30} den=${4:-1}
	[[ $w =~ ^[0-9]+$ && $h =~ ^[0-9]+$ ]] || {
		echo 4.1
		return
	}
	local mbs=$((((w + 15) / 16) * ((h + 15) / 16)))
	local mbps=$((mbs * num / den))
	local entry level max_mbs max_mbps
	for entry in "3.1:3600:108000" "3.2:5120:216000" "4.1:8192:245760" "4.2:8704:522240" \
		"5.0:22080:589824" "5.1:36864:983040" "5.2:36864:2073600" "6.0:139264:4177920" \
		"6.1:139264:8355840" "6.2:139264:16711680"; do
		IFS=: read -r level max_mbs max_mbps <<<"$entry"
		if ((mbs <= max_mbs && mbps <= max_mbps)); then
			echo "$level"
			return
		fi
	done
	echo 6.2
}
h264_level=$(h264_level_for "$output_width" "$output_height" "$output_fps_num" "$output_fps_den")
# The hardware encoders take level_idc, ten times the level: "5.0" parsed as a
# number is level 5 on VAAPI and "4.1" is truncated to 4 on QSV.
h264_hw_level=${h264_level/./}

# H.264 high profile is 8-bit only.
needs_10bit_to_8bit_conversion=false
if [[ $is_10bit == true && ($video_encoder == "h264" || -z "$video_encoder") ]]; then
	needs_10bit_to_8bit_conversion=true
	echo "Detected 10-bit input. Forcing 8-bit pixel format (nv12) for H.264 compatibility."
elif [[ ($video_encoder == "h264" || -z "$video_encoder") && "$source_video_codec" != "h264" && $is_10bit == false ]]; then
	echo "Converting from $source_video_codec to h264 (8-bit input, no bit depth conversion needed)."
fi

# An HDR source (PQ or HLG) going to an 8-bit encoder must be tone mapped, not
# just truncated: dropping bits keeps the PQ curve, so the picture comes out
# washed out, and ffmpeg copies the bt2020/PQ tags onto the 8-bit stream --
# an "HDR10 in 8 bits" file that television decoders refuse to open. The
# software chain below converts to linear light, maps highlights (hable),
# and lands in bt709 8-bit with matching tags. Hardware decode is skipped
# for these files (Stage 2 onwards) so the filter sees full-depth frames.
needs_tonemap=false
# The decoder tags frames only from the bitstream's own color description;
# a stream that has none (a DVB recording) or carries it only in the
# container reached the tone mappers as "unknown", and zscale refused it.
# The frames get what FFprobe read, bt2020 where that is missing too.
[[ $source_primaries =~ ^[a-z0-9-]+$ && $source_primaries != unknown ]] || source_primaries=bt2020
[[ $source_matrix =~ ^[a-z0-9-]+$ && $source_matrix != unknown ]] || source_matrix=bt2020nc
hdr_tags="setparams=color_primaries=$source_primaries:color_trc=$source_transfer:colorspace=$source_matrix"
tonemap_filters="$hdr_tags,zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
output_color_tags=() source_tag_filter=""
if [[ $is_hdr == true && ($video_encoder == "h264" || -z "$video_encoder" || $video_encoder == "vp9") ]]; then
	needs_tonemap=true
	output_color_tags=(-color_primaries bt709 -color_trc bt709 -colorspace bt709)
	echo "HDR source with an 8-bit target: applying tone mapping to SDR (bt709)."
elif [[ $source_hdr_given == true ]]; then
	# Kept HDR: the output says what the source did not, or players show it
	# as washed-out SDR. The frames carry it too: FFmpeg 9 otherwise tries to
	# convert untagged frames to the requested tags, which GPU frames refuse.
	output_color_tags=(-color_primaries "$source_primaries" -color_trc "$source_transfer" -colorspace "$source_matrix")
	source_tag_filter=$hdr_tags
fi

# The job's scratch directory sits beside its own output, so publishing is a
# rename within one filesystem and nothing large is ever written to /tmp,
# which is RAM on most systems. mktemp creates a directory owned only by this
# invocation; a stale workspace must never be deleted to claim its name.
output_stage_dir=$(mktemp -d -- "${output_file%/*}/.bvc-XXXXXXXXXX") || exit 1
# One line, escaped as the "Output file:" line below.
escaped=${output_stage_dir//\\/\\\\} escaped=${escaped//$'\n'/\\n} escaped=${escaped//$'\r'/\\r}
printf 'Job workspace: %s\n' "$escaped"
staged_output="$output_stage_dir/video.${output_file##*.}"

option_parser="$script_dir/../share/big-video-converter/engine/ffmpeg_options.py"
if ! python3 "$option_parser" --null "${options:-}" >"$output_stage_dir/options"; then
	echo "ERROR: Invalid additional FFmpeg options" >&2
	exit 2
fi
mapfile -d '' -t option_args <"$output_stage_dir/options"
input_options=()
output_options=()
# Seek options belong before -i. After it, FFmpeg decodes and throws away
# everything up to the cut, which costs the whole offset on every attempt and,
# in copy mode, leaves the requested opening seconds missing from the output.
# The duration (-t) stays an output option: only there does FFmpeg also clip
# the chapters to the cut instead of copying every chapter of the source.
for ((opt = 0; opt < ${#option_args[@]}; opt++)); do
	case ${option_args[$opt]} in
	-ss | -to | -sseof | -itsoffset | -hwaccel | -hwaccel_output_format | -probesize | -analyzeduration | -thread_queue_size)
		input_options+=("${option_args[$opt]}" "${option_args[$((opt + 1))]}")
		((opt += 1))
		;;
	*) output_options+=("${option_args[$opt]}") ;;
	esac
done

# A join of several cuts (the GUI's segment batch) is one encode: every cut is
# its own input, trimmed exactly while decoding, and the concat filter joins
# them. Joining cuts copied or encoded one by one would put a keyframe's worth
# of extra frames or an AAC priming gap into every seam.
# segments="START+DURATION,..." in seconds; the caller handles subtitles.
# Each duration is cut to whole frames: the concat filter pads a cut whose
# audio ends before its last frame with silence, an audible gap at the seam.
# Cuts close together share one input, split and trimmed in the filtergraph.
# Every input decodes at once and queues its frames until the concat filter
# reaches it, so hundreds of inputs exhaust memory and threads. A cut opens a
# new input only after more than 30 s removed, which seeking skips instead of
# decoding.
# segment_cuts[n] lists input n's cuts, "START+DURATION ..." from its start.
segment_inputs=()
segment_cuts=()
if [[ -n ${segments:-} ]]; then
	if [[ $force_copy_video == 1 ]]; then
		echo "ERROR: Joined cuts must be re-encoded" >&2
		exit 2
	fi
	IFS=, read -ra segment_list <<<"$segments"
	for segment in "${segment_list[@]}"; do
		if [[ ! $segment =~ ^[0-9]+([.][0-9]+)?[+][0-9]+([.][0-9]+)?$ ]]; then
			echo "ERROR: Invalid segment: $segment" >&2
			exit 2
		fi
	done
	while read -r start length cuts; do
		segment_inputs+=(-ss "$start" -t "$length" -i "$input_file")
		segment_cuts+=("$cuts")
	done < <(printf '%s\n' "${segment_list[@]}" |
		awk -F+ -v n="$source_fps_num" -v m="$source_fps_den" '
        function flush() { if (count) printf "%.6f %.6f%s\n", first, last - first, cuts }
        {
            f = int($2 * n / m + 1e-6); d = f > 0 ? f * m / n : $2
            if (count && ($1 - last > 30 || $1 < first)) {
                flush(); count = 0; cuts = ""
            }
            if (!count) { first = $1; last = $1 }
            cuts = cuts sprintf(" %.6f+%.6f", $1 - first, d)
            if ($1 + d > last) last = $1 + d
            count++
        }
        END { flush() }')
	subtitle_extract=none
fi

# Subtitles
subtitle_cmd=()
subtitle_outputs=()
subtitle_languages=()
subtitle_counts=()
subtitle_suffixes=()
: "${subtitle_extract:=extract}"
if [[ $ffprobe_usable == true ]]; then
	subtitle_codec_info=$(probe_rows s stream=index,codec_name)
else
	subtitle_codec_info=$(fb_subtitle_codecs)
fi
subtitle_input_options=()
subtitle_packet_options=()
defer_subtitles=false
if [[ -n $subtitle_codec_info && ($subtitle_extract != none || $only_extract_subtitles == 1) ]]; then
	if ! python3 "$script_dir/../share/big-video-converter/engine/subtitle_timing.py" "${options:-}" >"$output_stage_dir/subtitle-timing"; then
		echo "ERROR: Invalid subtitle trim interval" >&2
		exit 2
	fi
	mapfile -d '' -t subtitle_timing <"$output_stage_dir/subtitle-timing"
	[[ -z ${subtitle_timing[0]} ]] || subtitle_input_options=(-t "${subtitle_timing[0]}")
	if [[ -n ${subtitle_timing[1]} ]]; then
		subtitle_packet_options=(-bsf:s "${subtitle_timing[1]}")
		[[ $subtitle_extract != embedded ]] || defer_subtitles=true
	fi
fi
if [[ $only_extract_subtitles == 1 ]]; then
	subtitle_extract=extract
fi
# Subtitle codecs with a text form; the others (PGS, DVD, DVB) are pictures.
text_subtitle_re='^(subrip|ass|ssa|mov_text|text|webvtt)$'
if [[ $subtitle_extract == embedded ]]; then
	case ${output_file##*.} in
	mp4 | mov | m4v | MP4 | MOV | M4V) subtitle_encoder=mov_text ;;
	webm | WEBM) subtitle_encoder=webvtt ;;
	*) subtitle_encoder=copy ;;
	esac
	subtitle_codec_overrides=()
	embedded_count=0
	while IFS=, read -r index codec_name; do
		[[ $index =~ ^[0-9]+$ ]] || continue
		# A bitmap subtitle has no text form: keep the rest of the conversion
		# rather than refusing the whole job over one track. The GUI keeps
		# the original when it reads this line.
		if [[ $subtitle_encoder != copy && ! $codec_name =~ $text_subtitle_re ]]; then
			echo "Warning: skipping subtitle stream $index ($codec_name): not supported in .${output_file##*.}; select MKV or extract subtitles."
			continue
		fi
		subtitle_cmd+=(-map "0:$index")
		# Matroska has no mov_text: an MP4's text track is stored as SubRip.
		[[ $subtitle_encoder != copy || $codec_name != mov_text ]] || subtitle_codec_overrides+=("-c:s:$embedded_count" srt)
		((embedded_count += 1))
	done <<<"$subtitle_codec_info"
	if [[ ${#subtitle_cmd[@]} -eq 0 ]]; then
		defer_subtitles=false
	else
		subtitle_cmd+=(-c:s "$subtitle_encoder" "${subtitle_codec_overrides[@]}")
	fi
elif [[ $subtitle_extract == extract || $only_extract_subtitles == 1 ]]; then
	# Names players recognize: "<video>.<lang>.srt", with ".forced" for a
	# forced track. A repeated language gets a counter from the second track
	# on, counted per name, so two forced tracks get two files. Every text
	# codec (ASS, mov_text, WebVTT) becomes SubRip, as in the GUI's segment path.
	declare -A sidecar_count=()
	while IFS=, read -r index codec_name; do
		[[ $index =~ ^[0-9]+$ ]] || continue
		if [[ ! $codec_name =~ $text_subtitle_re ]]; then
			echo "Warning: skipping subtitle stream $index ($codec_name): a picture subtitle cannot be saved as .srt; select MKV and embedded subtitles to keep it."
			continue
		fi
		language_code=und forced=0 title=""
		if [[ $ffprobe_usable == true ]]; then
			stream_info=$("$ffprobe_executable" -v error -select_streams "$index" -show_entries stream_disposition=forced:stream_tags=language,title -of default=nw=1 "$input_file") || exit 2
			while IFS="=" read -r key value; do
				case $key in
				TAG:language) language_code=$value ;;
				TAG:title) title=$value ;;
				DISPOSITION:forced) forced=$value ;;
				esac
			done <<<"$stream_info"
			[[ $language_code =~ ^[a-zA-Z]{2,3}$ ]] || language_code=und
		fi
		suffix=""
		[[ $forced == 1 || $title == *"(Forced)"* ]] && suffix=.forced
		count=$((${sidecar_count[$language_code$suffix]:-0} + 1))
		sidecar_count[$language_code$suffix]=$count
		sidecar="$output_stage_dir/subtitle-$index.srt"
		subtitle_cmd+=(-map "0:$index" -c:s srt "${subtitle_packet_options[@]}" "$sidecar")
		subtitle_outputs+=("$sidecar")
		subtitle_languages+=("$language_code")
		subtitle_counts+=("$count")
		subtitle_suffixes+=("$suffix")
	done <<<"$subtitle_codec_info"
fi

# Sidecars are named after the video actually published. An existing one may
# hold manual corrections, so a track whose name is taken gets the next number
# ("movie.eng2.srt") instead of replacing it or failing the finished job.
publish_subtitles() { # <path of the video the sidecars belong to>
	local i count number status=0
	for i in "${!subtitle_outputs[@]}"; do
		for ((count = subtitle_counts[i]; count < subtitle_counts[i] + 10000; count++)); do
			number=$count
			[[ $count != 1 ]] || number=""
			publish_as "${subtitle_outputs[$i]}" "${1%.*}.${subtitle_languages[$i]}$number${subtitle_suffixes[$i]}.srt"
			status=$?
			[[ $status == 1 ]] || break
		done
		[[ $status == 0 ]] || return 1
	done
}

# Audio
: "${audio_handling:=copy}"
# The concat filter joins decoded audio, which then has to be encoded.
[[ ${#segment_inputs[@]} -eq 0 || $audio_handling != copy ]] || audio_handling=reencode
original_audio_handling=$audio_handling

: "${audio_codec:=aac}"

# Floating point maths in awk syntax (exp, log, sqrt); bc is not in every
# base system.
calc() {
	awk "BEGIN { printf \"%.6f\", $1 }"
}

# The audio chain, each filter optional: high-pass, compressor and speechnorm
# first, so the noise reduction hears an even level; then the gate, whose
# full-band detection works on what is mostly speech after it; then the
# equalizer. ui/video_edit_page.py builds the same chain for the preview.
noise_audio_filter=""
local_filters=()

if [[ $hpf_enabled == "1" ]]; then
	: "${hpf_frequency:=80}"
	local_filters+=("highpass=f=${hpf_frequency}:poles=2")
	echo "HPF enabled: frequency=${hpf_frequency}Hz"
fi

if [[ $compressor_enabled == "1" ]]; then
	: "${compressor_intensity:=1.0}"
	comp_threshold_db=$(calc "-20 - ${compressor_intensity} * 20")
	comp_ratio=$(calc "3 + ${compressor_intensity} * 7")
	comp_makeup_db=$(calc "6 + ${compressor_intensity} * 12")
	comp_knee_db=$(calc "12 + ${compressor_intensity} * 4")
	comp_threshold_linear=$(calc "exp(${comp_threshold_db}/20 * log(10))")
	comp_makeup_linear=$(calc "exp(${comp_makeup_db}/20 * log(10))")
	comp_knee_linear=$(calc "exp(${comp_knee_db}/20 * log(10))")
	local_filters+=("acompressor=threshold=${comp_threshold_linear}:ratio=${comp_ratio}:attack=150:release=800:makeup=${comp_makeup_linear}:knee=${comp_knee_linear}:detection=rms")
	echo "Compressor enabled: intensity=${compressor_intensity} threshold=${comp_threshold_db}dB ratio=${comp_ratio}"
fi

# speechnorm: no internal resampling and no pumping.
if [[ $normalize_enabled == "1" ]]; then
	local_filters+=("speechnorm=e=12.5:r=0.0001:l=1")
	echo "Volume normalization enabled (speechnorm)"
fi

# AI noise reduction: DeepFilterNet3 (noise_model=0) or DPDFNet-2 48 kHz
# (noise_model=1). Both are mono 48 kHz LADSPA plugins; FFmpeg runs one
# instance per channel, so the chain applies to any layout.
# - The plugins delay their output (DFN3 1919 samples, DPDFNet 2880): the end
#   is padded and the start trimmed by that much, keeping sync and last words.
# - A plugin starts from an empty model state and under-restores the first
#   ~150 ms, so it first runs over the opening half second played backwards,
#   which is cut away again. That lead-in ends at timestamp 0, so concat keeps
#   the stream's own timeline (a seek in the preview, a cut, an offset start).
# - The labels start with "bvcnr"; a graph with several streams renames them.
# constants.noise_reduction_filter builds the same chain for the preview.
if [[ $noise_reduction == "1" ]]; then
	case ${noise_model:-0} in
	1)
		nr_plugin=/usr/lib/ladspa/libdpdfnet_native.so nr_label=dpdfnet_native_48hr
		nr_delay=2880 nr_extra="" nr_full_db=48 nr_gate=(1 2 3)
		;;
	*)
		# DFN3 mutes its first second and gates what it takes for no voice by
		# default, both meant for a live microphone.
		nr_plugin=/usr/lib/ladspa/libdfn3_ladspa.so nr_label=deep_filter_net3_rs_mono
		nr_delay=1919 nr_extra="|c6=0" nr_full_db=24 nr_gate=(17 18 19)
		;;
	esac
	# The voice gate: depth, detector and sensitivity controls. On, it mutes
	# what FireRedVAD does not hear as a voice and holds either plugin's
	# output to 3168 samples.
	if [[ ${voice_only:-0} == "1" ]]; then
		nr_extra+="|c${nr_gate[0]}=60|c${nr_gate[1]}=1|c${nr_gate[2]}=${voice_only_sensitivity:-3}"
		nr_delay=3168
	elif [[ ${noise_model:-0} != "1" ]]; then
		nr_extra+="|c${nr_gate[0]}=0"
	fi
	if [[ -e $nr_plugin ]]; then
		# Strength 0..1 is linear in dB up to where the model stops removing
		# more (see constants.NOISE_MODELS).
		nr_attenuation=$(awk -v s="${noise_strength:-1.0}" -v full="$nr_full_db" 'BEGIN { if (s > 1) s = 1; printf "%.2f", s * full }')
		local_filters+=("aresample=48000,asplit[bvcnr_main][bvcnr_head];[bvcnr_head]atrim=end_sample=24000,apad=whole_len=24000,areverse,asetpts=PTS-STARTPTS-24000/SR/TB[bvcnr_pre];[bvcnr_pre][bvcnr_main]concat=n=2:v=0:a=1,apad=pad_len=${nr_delay},ladspa=file=${nr_plugin}:plugin=${nr_label}:controls=c0=${nr_attenuation}${nr_extra},atrim=start_sample=$((24000 + nr_delay)),asetpts=PTS-${nr_delay}/SR/TB")
		echo "Noise reduction enabled: ${nr_label} attenuation=${nr_attenuation}dB"
	else
		# The other audio filters still apply; refusing the job over a
		# missing optional plugin would block every conversion instead.
		echo "WARNING: Noise reduction requested but its LADSPA plugin is not installed: ${nr_plugin}"
	fi
fi

if [[ $noise_gate == "1" ]]; then
	: "${gate_intensity:=0.5}"
	gate_threshold_db=$(calc "-50 + sqrt(${gate_intensity}) * 35")
	gate_range_db=$(calc "-40 - sqrt(${gate_intensity}) * 50")
	gate_threshold_linear=$(calc "exp(${gate_threshold_db}/20 * log(10))")
	gate_range_linear=$(calc "exp(${gate_range_db}/20 * log(10))")
	local_filters+=("agate=threshold=${gate_threshold_linear}:range=${gate_range_linear}:attack=10:release=250:ratio=4:detection=rms")
	echo "Noise gate enabled: intensity=${gate_intensity} threshold=${gate_threshold_db}dB range=${gate_range_db}dB"
fi

# 10-band parametric equalizer
if [[ $eq_enabled == "1" ]]; then
	: "${eq_bands:=0,0,0,0,0,0,0,0,0,0}"
	IFS=',' read -ra bands_arr <<<"$eq_bands"
	eq_freqs=(31 63 125 250 500 1000 2000 4000 8000 16000)
	eq_widths=(1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5 1.5)
	eq_parts=()
	for idx in "${!eq_freqs[@]}"; do
		gain="${bands_arr[$idx]:-0}"
		if [[ ! $gain =~ ^-?0*([.]0*)?$ ]]; then
			eq_parts+=("equalizer=f=${eq_freqs[$idx]}:width_type=o:w=${eq_widths[$idx]}:g=${gain}")
		fi
	done
	if [[ ${#eq_parts[@]} -gt 0 ]]; then
		eq_filter=$(
			IFS=','
			echo "${eq_parts[*]}"
		)
		local_filters+=("${eq_filter}")
		echo "Equalizer enabled: ${#eq_parts[@]} active bands"
	fi
fi

if [[ ${#local_filters[@]} -gt 0 ]]; then
	noise_audio_filter=$(
		IFS=','
		echo "${local_filters[*]}"
	)
	echo "Audio filter chain: ${noise_audio_filter}"

	if [[ $audio_handling == "copy" ]]; then
		echo "Audio processing requires audio re-encoding, switching from copy to reencode"
		audio_handling="reencode"
	fi
fi

# Audio stream queries, honoring the ffprobe fallback.
# probe_audio_info emits "index,language"; the channel helper takes the
# relative audio stream number and its absolute index in the container.
probe_audio_info() {
	if [[ $ffprobe_usable == true ]]; then
		probe_rows a stream=index:stream_tags=language
	else
		fb_audio_info
	fi
}

probe_audio_channels() {
	local rel="$1" abs="$2"
	if [[ $ffprobe_usable == true ]]; then
		probe_value "a:$rel" stream=channels
	else
		fb_audio_channels "$abs"
	fi
}

# Build argv arrays; empty audio metadata must produce no audio maps.
build_audio_cmd() {
	audio_cmd=()
	segment_audio=()
	[[ $audio_handling != none ]] || {
		audio_cmd=(-an)
		return
	}
	local i=0 index_audio channels brate
	local audio_info
	audio_info=$(probe_audio_info)
	while IFS=, read -r index_audio _; do
		[[ $index_audio =~ ^[0-9]+$ ]] || continue
		if [[ $audio_handling == copy ]]; then
			audio_cmd+=(-map "0:$index_audio" "-c:a:$i" copy)
		else
			channels=${audio_channels:-$(probe_audio_channels "$i" "$index_audio")}
			[[ $channels =~ ^[1-9][0-9]*$ ]] || channels=2
			brate=${audio_bitrate:-$((channels * 32))k}
			if [[ ${#segment_inputs[@]} -gt 0 ]]; then
				# Joined cuts: the stream comes out of the concat filtergraph,
				# which also runs the audio chain (see set_video_filters).
				segment_audio+=("$index_audio")
				audio_cmd+=(-map "[a$i]" "-map_metadata:s:a:$i" "0:s:$index_audio")
			else
				audio_cmd+=(-map "0:$index_audio")
				[[ -z $noise_audio_filter ]] || audio_cmd+=("-filter:a:$i" "$noise_audio_filter")
			fi
			append_audio_codec audio_cmd "$i" "$brate" "$channels"
		fi
		((i += 1))
	done <<<"$audio_info"
}

append_audio_codec() {
	local -n target=$1
	local i=$2 brate=$3 channels=$4
	case $audio_codec in
	opus) target+=("-c:a:$i" libopus "-b:a:$i" "$brate" "-ac:a:$i" "$channels") ;;
	ac3) target+=("-c:a:$i" ac3 "-b:a:$i" "$brate" "-ac:a:$i" "$channels") ;;
	*) target+=("-c:a:$i" aac -aac_coder fast -profile:a aac_low "-b:a:$i" "$brate" "-ac:a:$i" "$channels") ;;
	esac
}

# Video
video_quality=${video_quality/default/}

: "${video_quality:=medium}"

case $video_quality in
veryhigh) cq_value=19 qp_value=18 qp_hevc=22 ;;
high) cq_value=24 qp_value=21 qp_hevc=25 ;;
medium) cq_value=28 qp_value=24 qp_hevc=28 ;;
low) cq_value=31 qp_value=27 qp_hevc=31 ;;
verylow) cq_value=34 qp_value=30 qp_hevc=34 ;;
superlow) cq_value=38 qp_value=33 qp_hevc=37 ;;
*)
	echo "ERROR: Unknown video quality: $video_quality" >&2
	exit 2
	;;
esac

# QSV HEVC needs a lower global_quality than the others' QP for the same picture.
qp_hevc_qsv=$((qp_hevc - 4))

# AV1 and VP9 hardware encoders take a q-index (0-255), not a 0-51 QP. The
# CRF of libsvtav1/libvpx (0-63) maps onto it as q-index = 4 x CRF, the
# quantizer_to_qindex table both codecs use (63 -> 255).
q_index=$((cq_value * 4 > 255 ? 255 : cq_value * 4))

if [[ $preset == "default" ]]; then
	unset preset
fi

# Unset, the GPU encoders get medium and the software ones faster.
if [[ -z $preset ]]; then
	preset=medium
	software_preset=faster
else
	software_preset=$preset
fi

# NVENC presets p1 (fastest) to p7.
case $preset in
ultrafast) nvidia_preset=1 ;;
veryfast) nvidia_preset=2 ;;
faster) nvidia_preset=3 ;;
slow) nvidia_preset=5 ;;
veryslow) nvidia_preset=6 ;;
*) nvidia_preset=4 ;; # Default to medium
esac

# Convert preset for SVT-AV1 (6=best quality, 12=fastest)
# Presets below 6 add minimal quality gain with extreme slowdown
case $software_preset in
ultrafast) svtav1_preset=12 ;;
veryfast) svtav1_preset=11 ;;
faster) svtav1_preset=10 ;;
medium) svtav1_preset=9 ;;
slow) svtav1_preset=7 ;;
veryslow) svtav1_preset=6 ;;
*) svtav1_preset=10 ;; # Default to faster
esac

# Convert preset for VP9 cpu-used (0=slowest/best, 8=fastest/worst)
case $software_preset in
ultrafast) vpx_cpu_used=6 ;;
veryfast) vpx_cpu_used=5 ;;
faster) vpx_cpu_used=4 ;;
medium) vpx_cpu_used=3 ;;
slow) vpx_cpu_used=2 ;;
veryslow) vpx_cpu_used=1 ;;
*) vpx_cpu_used=3 ;; # Default to medium
esac

# Builds the argument arrays of every encoder for the current settings.
build_encoder_commands() {
	if [[ ${#segment_inputs[@]} -gt 0 ]]; then
		# Chapters of the whole source do not describe a join of cuts.
		general_params=("${segment_inputs[@]}" -map '[v]' -map_chapters -1 "${audio_cmd[@]}" "${output_color_tags[@]}")
	else
		general_params=(-i "$input_file" -map 0:v:0 "${audio_cmd[@]}" "${output_color_tags[@]}")
	fi
	# QSV knows no "ultrafast" and rejects the whole option, which silently
	# sent every QSV job to the software fallback.
	local qsv_preset=${preset/ultrafast/veryfast}
	# HEVC in MP4/MOV gets the 'hev1' tag by default, which Apple players and
	# some TVs reject; 'hvc1' is the widely supported one.
	local -a hevc_tag=()
	case ${output_file##*.} in
	mp4 | mov | m4v | MP4 | MOV | M4V) hevc_tag=(-tag:v hvc1) ;;
	esac
	# A source with a timecode tag (camera MOV files) makes the MP4 muxer add
	# a tmcd data track, which players list as an unknown stream. MP4 has no
	# use for it; MOV keeps it for editing workflows. keep_timecode=1 opts out.
	case ${output_file##*.} in
	mp4 | m4v | MP4 | M4V) [[ ${keep_timecode:-0} == 1 ]] || general_params+=(-metadata:s:v:0 timecode=) ;;
	esac
	if [[ $subtitle_extract == embedded && $defer_subtitles == false ]]; then
		general_params+=("${subtitle_cmd[@]}")
	fi
	copy_video=("${general_params[@]}" -c:v copy)
	# After an input seek, stream-copied audio and subtitles keep every packet
	# from the keyframe FFmpeg landed on, seconds before the cut, with negative
	# timestamps (-copypriorss defaults to on). Matroska cannot store those, so
	# libavformat shifts every stream forward and the re-encoded video, which
	# starts exactly at the cut, begins that much later. A copied video starts
	# on that same keyframe and needs those packets to stay in sync.
	local opt
	for opt in "${input_options[@]}"; do
		[[ $opt != -ss && $opt != -sseof ]] || {
			general_params+=(-copypriorss:a 0 -copypriorss:s 0)
			break
		}
	done

	case $video_encoder in
	h265)
		encoder_nvenc=("${general_params[@]}" -c:v hevc_nvenc -rc:v vbr -cq "$cq_value" -tune:v hq -preset:v "p$nvidia_preset" -temporal-aq 1 -spatial-aq 1 -b_ref_mode middle -rc-lookahead 32 "${hevc_tag[@]}")
		encoder_vulkan=("${general_params[@]}" -c:v hevc_vulkan -qp "$qp_hevc" -tune hq "${hevc_tag[@]}")
		# AMD's VAAPI driver has no ICQ: constant QP there. Chosen here, not
		# in attempt_conversion, so the bitrate, GOP and tag below still apply.
		local hevc_vaapi_rc=ICQ
		[[ $gpu_vendor != amd ]] || hevc_vaapi_rc=CQP
		encoder_vaapi=("${general_params[@]}" -c:v hevc_vaapi -global_quality "$qp_hevc" -rc_mode "$hevc_vaapi_rc" "${hevc_tag[@]}")
		encoder_qsv=("${general_params[@]}" -c:v hevc_qsv -preset "$qsv_preset" -low_delay_brc 1 -global_quality "$qp_hevc_qsv" -mbbrc 1 -adaptive_i 1 -adaptive_b 1 "${hevc_tag[@]}")
		encoder_software=("${general_params[@]}" -c:v libx265 -preset "$software_preset" -crf "$qp_hevc" -x265-params log-level=warning "${hevc_tag[@]}")
		;;
	av1)
		encoder_nvenc=("${general_params[@]}" -c:v av1_nvenc -rc:v vbr -cq "$cq_value" -tune:v hq -preset:v "p$nvidia_preset" -temporal-aq 1 -spatial-aq 1 -b_ref_mode middle -rc-lookahead 32)
		encoder_vulkan=("${general_params[@]}" -c:v av1_vulkan -qp "$q_index" -tune hq)
		encoder_vaapi=("${general_params[@]}" -c:v av1_vaapi -global_quality "$q_index" -rc_mode CQP)
		encoder_qsv=("${general_params[@]}" -c:v av1_qsv -preset "$qsv_preset" -global_quality "$qp_value")
		encoder_software=("${general_params[@]}" -c:v libsvtav1 -preset "$svtav1_preset" -crf "$cq_value" -pix_fmt yuv420p10le -svtav1-params tune=0)
		;;
	vp9)
		encoder_nvenc=()
		encoder_vulkan=()
		encoder_vaapi=("${general_params[@]}" -c:v vp9_vaapi -global_quality "$q_index" -rc_mode CQP)
		encoder_qsv=("${general_params[@]}" -c:v vp9_qsv -preset "$qsv_preset" -global_quality "$qp_value")
		encoder_software=("${general_params[@]}" -c:v libvpx-vp9 -crf "$cq_value" -b:v 0 -quality good -cpu-used "$vpx_cpu_used" -row-mt 1)
		;;
	prores)
		# ProRes is a software-only codec, map quality to profile
		# profile 0=Proxy, 1=LT, 2=Standard, 3=HQ, 4=4444, 5=4444XQ
		local prores_profile
		case $video_quality in
		superlow | verylow) prores_profile=0 ;; # Proxy
		low) prores_profile=1 ;;                # LT
		medium) prores_profile=2 ;;             # Standard
		high) prores_profile=3 ;;               # HQ
		veryhigh) prores_profile=4 ;;           # 4444
		esac
		encoder_nvenc=()
		encoder_vulkan=()
		encoder_vaapi=()
		encoder_qsv=()
		# Proxy, LT, Standard and HQ are 4:2:2 profiles; only 4444 and XQ carry
		# 4:4:4 and alpha.
		local prores_pix_fmt=yuv422p10le
		[[ $prores_profile -lt 4 ]] || prores_pix_fmt=yuva444p10le
		encoder_software=("${general_params[@]}" -c:v prores_ks -profile:v "$prores_profile" -vendor apl0 -pix_fmt "$prores_pix_fmt")
		;;
	*)
		encoder_nvenc=("${general_params[@]}" -c:v h264_nvenc -rc:v vbr -cq "$cq_value" -tune:v hq -preset:v "p$nvidia_preset" -temporal-aq 1 -rc-lookahead 32)
		encoder_vulkan=("${general_params[@]}" -c:v h264_vulkan -qp "$qp_value")
		encoder_vaapi=("${general_params[@]}" -c:v h264_vaapi -profile:v high -level:v "$h264_hw_level" -rc_mode CQP -global_quality "$qp_value")
		encoder_qsv=("${general_params[@]}" -c:v h264_qsv -preset "$qsv_preset" -profile:v high -level:v "$h264_hw_level" -low_delay_brc 1 -global_quality "$qp_value" -mbbrc 1 -adaptive_i 1 -adaptive_b 1)
		# -pix_fmt yuv420p is required: H.264 "high" profile has no 4:4:4/4:2:2
		# support, so 444/422 sources (ProRes, screen captures) fail to encode
		# without an explicit downsample.
		encoder_software=("${general_params[@]}" -c:v libx264 -preset "$software_preset" -profile:v high -level:v "$h264_level" -crf "$qp_value" -pix_fmt yuv420p)
		;;
	esac
	local encoder_var gop=""
	[[ -z ${keyframe_interval:-} ]] || gop=$(awk -v s="$keyframe_interval" -v n="$output_fps_num" -v d="$output_fps_den" \
		'BEGIN { g = int(s * n / d + 0.5); printf "%d", (g < 1 ? 1 : g) }')
	for encoder_var in encoder_nvenc encoder_vulkan encoder_vaapi encoder_qsv encoder_software; do
		[[ -z ${video_bitrate:-} ]] || use_video_bitrate "$encoder_var"
		# The GOP bound is what lets a size split copy parts out later.
		if [[ -n $gop ]]; then
			local -n with_gop=$encoder_var
			[[ ${#with_gop[@]} -eq 0 ]] || with_gop+=(-g "$gop")
			unset -n with_gop
		fi
		append_preset_encoder_args "$encoder_var"
	done
}

# A size-targeted job asks for an average bitrate: each encoder's constant
# quality knob goes and an average with a 1.5x ceiling takes its place.
use_video_bitrate() { # <encoder array name>
	local -n sized_argv=$1
	[[ ${#sized_argv[@]} -gt 0 ]] || return 0
	local -a kept=()
	local i name=""
	for ((i = 0; i < ${#sized_argv[@]}; i++)); do
		case ${sized_argv[$i]} in
		-crf | -qp | -cq | -global_quality | -rc_mode | -rc:v | -b:v)
			((i += 1))
			continue
			;;
		-c:v) name=${sized_argv[$((i + 1))]} ;;
		esac
		kept+=("${sized_argv[$i]}")
	done
	local rate=$video_bitrate ceiling=$((video_bitrate * 3 / 2)) buffer=$((video_bitrate * 2))
	case $name in
	*_nvenc) kept+=(-rc:v vbr -multipass fullres -b:v "$rate" -maxrate "$ceiling" -bufsize "$buffer") ;;
	*_vaapi) kept+=(-rc_mode VBR -b:v "$rate" -maxrate "$ceiling") ;;
	*_vulkan) kept+=(-rc_mode vbr -b:v "$rate" -maxrate "$ceiling") ;;
	*) kept+=(-b:v "$rate" -maxrate "$ceiling" -bufsize "$buffer") ;;
	esac
	sized_argv=("${kept[@]}")
}

gpu=${gpu,,}
gpu=${gpu/auto/}
# The GPU to encode on: the vendor asked for, the vendor of gpu_device, else
# the one the GUI's rule picks (engine/gpu_selector.py): the card that alone
# encodes AV1, else the one that alone encodes VP9, else NVIDIA, AMD, Intel
# among those that encode the chosen codec.
gpu_vendor=""
cuda_index=""
gpu_selector="$script_dir/../share/big-video-converter/engine/gpu_selector.py"
case $gpu in
nvidia | amd | intel)
	gpu_vendor=$gpu
	# A named NVIDIA device is initialized by its CUDA index.
	if [[ $gpu == nvidia && -n $gpu_device ]]; then
		read -r _ _ cuda_index < <(
			ffmpeg_executable=$ffmpeg_executable python3 "$gpu_selector" h264 "$gpu_device"
		) || true
	fi
	;;
"")
	if [[ $force_copy_video != 1 && ${video_encoder:-h264} != prores ]]; then
		chosen_device=""
		read -r gpu_vendor chosen_device cuda_index < <(
			ffmpeg_executable=$ffmpeg_executable python3 \
				"$gpu_selector" "${video_encoder:-h264}" ${gpu_device:+"$gpu_device"}
		) || true
		gpu_device=$chosen_device
		[[ -n $gpu_vendor ]] || echo "No GPU encodes ${video_encoder:-h264} here - using software encoding"
	fi
	;;
esac
# CUDA's own device order is fastest first; the selector counts in PCI order.
export CUDA_DEVICE_ORDER=PCI_BUS_ID

# Encode to a staging file this job owns.
ffmpeg_generic_options=(-y "$staged_output")
case ${output_file##*.} in
mp4 | mov | m4v | MP4 | MOV | M4V) ffmpeg_generic_options=(-movflags +faststart -y "$staged_output") ;;
esac

# A GPU driver that hangs (a Mesa regression, a VCN ring timeout) leaves
# ffmpeg alive and silent forever: no fallback stage ever runs and the user
# only sees a conversion that "stopped". ffmpeg therefore writes its progress
# report to a file, and when the frame counter, output time and size in that
# report stop changing for stall_timeout seconds the process is killed and the
# attempt fails with 124, which sends the job down the usual GPU → partial →
# software chain. The default is long on purpose: the MP4 faststart pass
# rewrites the whole file without reporting progress. stall_timeout=0 disables.
: "${stall_timeout:=600}"
[[ $stall_timeout =~ ^[0-9]+$ ]] || stall_timeout=600

progress_fingerprint() { # last frame/out_time/size values of a -progress file
	tail -c 4096 -- "$1" 2>/dev/null | awk -F= '
		/^(frame|out_time_us|total_size|progress)=/ { v[$1] = $2 }
		END { print v["frame"], v["out_time_us"], v["total_size"], v["progress"] }'
}

# What FFmpeg printed during the current conversion attempt.
ffmpeg_log="$output_stage_dir/ffmpeg.log"

# FFmpeg 8 gave libx265 -pass and -x265-stats. Earlier releases (Debian 13
# ships 7.1) refuse -x265-stats and ignore -pass there, so x265 is handed the
# pass through its own parameters. Asked once, of the binary in use.
x265_takes_stats() {
	if [[ -z ${x265_stats_option:-} ]]; then
		if "$ffmpeg_executable" -hide_banner -h encoder=libx265 2>/dev/null | grep -q -- '-x265-stats'; then
			x265_stats_option=yes
		else
			x265_stats_option=no
		fi
	fi
	[[ $x265_stats_option == yes ]]
}

# Keep human-readable diagnostics; only argv is executed.
run_ffmpeg() {
	local -a progress_opt=()
	local progress_file="$output_stage_dir/progress"
	if [[ $stall_timeout -gt 0 && -d $output_stage_dir ]]; then
		: >"$progress_file"
		progress_opt=(-progress "$progress_file")
	fi
	printf 'Running command:'
	printf ' %q' "$ffmpeg_executable" -nostdin "${progress_opt[@]}" "$@"
	printf '\n'
	# FFmpeg's messages still reach stderr; the copy decides the retries.
	# The copier is waited for below, so the log is whole when it is read.
	local log_fd tee_pid
	exec {log_fd}> >(tee -a -- "$ffmpeg_log" >&2)
	tee_pid=$!
	"$ffmpeg_executable" -nostdin "${progress_opt[@]}" "$@" 2>&"$log_fd" &
	ffmpeg_pid=$!
	local stalled=false status
	# Poll for exit every quarter second (a short job must not wait on the
	# watchdog). The job runs in its own session, so nothing else ends it when
	# the application that started it (BVC_PARENT_PID) dies; exiting runs the
	# cleanup trap, which stops FFmpeg and drops the staged output.
	local ticks=0 idle=0 seen="" now i
	while kill -0 "$ffmpeg_pid" 2>/dev/null; do
		sleep 0.25
		if [[ -n ${BVC_PARENT_PID:-} ]] && ! kill -0 "$BVC_PARENT_PID" 2>/dev/null; then
			echo "The application that started this job has quit" >&2
			exit 143
		fi
		[[ ${#progress_opt[@]} -gt 0 ]] || continue
		((++ticks % 4 == 0)) || continue
		now=$(progress_fingerprint "$progress_file")
		if [[ $now != "$seen" ]]; then
			seen=$now
			idle=0
			continue
		fi
		((idle += 1))
		if [[ $idle -ge $stall_timeout ]]; then
			echo "Stall watchdog: no progress for ${idle}s, terminating ffmpeg" >&2
			kill -TERM "$ffmpeg_pid" 2>/dev/null
			for ((i = 0; i < 50; i++)); do
				kill -0 "$ffmpeg_pid" 2>/dev/null || break
				sleep 0.1
			done
			kill -KILL "$ffmpeg_pid" 2>/dev/null
			stalled=true
			break
		fi
	done
	wait "$ffmpeg_pid"
	status=$?
	ffmpeg_pid=""
	exec {log_fd}>&-
	wait "$tee_pid"
	[[ $stalled == false ]] || status=124
	return "$status"
}

if [[ $only_extract_subtitles == 1 ]]; then
	if [[ ${#subtitle_cmd[@]} -eq 0 ]]; then
		echo "ERROR: No extractable subtitles found" >&2
		exit 2
	fi
	run_ffmpeg "${subtitle_input_options[@]}" -i "$input_file" "${subtitle_cmd[@]}" || exit $?
	publish_subtitles "$output_file"
	exit $?
fi

# A GPU check that hangs must not hang the job. --foreground keeps timeout in
# this job's process group, where the GUI's cancel reaches it; without it
# timeout makes a group of its own. Waiting on a background job lets the TERM
# trap run at once, and cleanup ends the check instead of leaving it running.
run_gpu_check() { # <command...>
	local status
	timeout --foreground -k 5 "${gpu_probe_timeout:-30}" "$@" </dev/null &
	ffmpeg_pid=$!
	wait "$ffmpeg_pid"
	status=$?
	ffmpeg_pid=""
	return "$status"
}

# A hardware encoder whose driver is installed but broken (a Mesa regression,
# missing firmware, a device the user cannot open) would otherwise be found
# only by pushing the real file through two failing GPU stages, or never when
# it hangs. Two synthetic frames are encoded first, with the device setup and
# upload filters the real stages use; failure or a hang means software
# encoding, announced in the log. gpu_smoke_test=0 skips the check.
hw_encoder_name=""
hw_encoder_smoke_test() {
	local i encoder=""
	local -a upload=()
	for ((i = 0; i < ${#encoder_format[@]} - 1; i++)); do
		[[ ${encoder_format[$i]} == -c:v ]] && encoder=${encoder_format[$((i + 1))]}
	done
	[[ -n $encoder && $encoder != copy ]] || return 0
	hw_encoder_name=$encoder
	case $detected_encoder_var in
	encoder_vaapi) upload=(-vf "format=nv12,hwupload=derive_device=vaapi") ;;
	encoder_vulkan) upload=(-vf "format=nv12,hwupload=derive_device=vulkan") ;;
	encoder_qsv) upload=(-vf "format=nv12,hwupload=extra_hw_frames=64,format=qsv") ;;
	esac
	echo "Checking GPU encoder $encoder..."
	run_gpu_check "$ffmpeg_executable" -nostdin -v error "${init_hardware[@]}" \
		-f lavfi -i "color=c=gray:size=256x256:rate=25:duration=0.2" "${upload[@]}" \
		-c:v "$encoder" -frames:v 2 -f null -
}

# A GPU decodes only the codecs its hardware has (no AV1 before NVIDIA's RTX
# 30 series): FFmpeg then fails every packet, one error per frame, and the
# GPU stage would run through the whole file before falling back. One frame
# of the source is decoded first; -xerror ends the check at the first error.
hw_decoder_smoke_test() {
	run_gpu_check "$ffmpeg_executable" -nostdin -v error -xerror "${init_hardware[@]}" \
		"${decoder_format[@]}" -i "$input_file" -map 0:v:0 -frames:v 1 -f null -
}

# Crop, turn and flip without leaving GPU memory when they are the only
# edits, which is faster and gives the same pixels. Each API keeps only what
# it gets right:
# CUDA has no crop in FFmpeg 9.0 (scale_cuda ignores the crop filter's frame
# cropping), and transpose_vaapi on AMD turned a 1920x802 picture along with
# the decoder's padding rows, shifting it. Prints the GPU chain, or fails.
gpu_geometry_filters() { # <user filter chain>
	local part out="" parts=()
	IFS=, read -ra parts <<<"$1"
	for part in "${parts[@]}"; do
		case $detected_encoder_var:$part in
		encoder_nvenc:transpose=1) out+="transpose_cuda=dir=clock," ;;
		encoder_nvenc:transpose=2) out+="transpose_cuda=dir=cclock," ;;
		encoder_nvenc:hflip | encoder_nvenc:vflip) out+="transpose_cuda=dir=$part," ;;
		encoder_vaapi:crop=*)
			[[ $part =~ ^crop=([0-9]+):([0-9]+):[0-9]+:[0-9]+$ ]] || return 1
			# scale_vaapi applies the crop; its output keeps the link's size
			# unless told the cropped one.
			out+="$part,scale_vaapi=w=${BASH_REMATCH[1]}:h=${BASH_REMATCH[2]},"
			;;
		*) return 1 ;;
		esac
	done
	printf '%s' "${out%,}"
}

# Sets the caller's filters array: -vf for a single input; for joined cuts, one
# filtergraph that splits, trims and joins the video, and one for the audio.
# In one graph the audio output keeps asking for input while the slower video
# encoder lags, so decoded frames pile up; apart, each graph waits for its own
# encoder. Without audio the concat filter
# guesses a cut's last frame from the ones before it, so a cut of one frame
# lasted nothing: each cut brings a silent track of its length to time it.
# Apart, nothing pads a cut whose audio ends early (audio starting late, or a
# track shorter than the picture): apad fills each to its length, or every
# later cut would play ahead of its picture.
set_video_filters() { # <video filter chain>
	filters=()
	if [[ ${#segment_inputs[@]} -eq 0 ]]; then
		[[ -z $1 ]] || filters=(-vf "$1")
		return
	fi
	local n i j k b blocks start end last length video="" audio="" video_in="" count=0
	local -a cuts block audio_in=()
	for n in "${!segment_cuts[@]}"; do
		read -ra cuts <<<"${segment_cuts[$n]}"
		if [[ ${#cuts[@]} -eq 1 ]]; then
			# The input is the cut. Its video starts at 0 like its audio, or
			# the concat filter reckons the cut longer by the first frame's offset.
			video+="[$n:v:0]setpts=PTS-STARTPTS[p$count];"
			video+="anullsrc=r=8000:cl=mono,atrim=duration=${cuts[0]#*+}[z$count];"
			video_in+="[p$count][z$count]"
			for j in "${!segment_audio[@]}"; do
				i=${segment_audio[$j]}
				audio+="[$n:$i]asetpts=PTS-STARTPTS,apad=whole_dur=${cuts[0]#*+}[q${count}_$i];"
				audio_in[j]+="[q${count}_$i]"
			done
			((count += 1))
			continue
		fi
		# Every frame passes each trim it is split to: blocks of 32 cuts keep
		# that near 50 per frame rather than one per cut.
		blocks=$(((${#cuts[@]} + 31) / 32))
		video+="[$n:v:0]split=$blocks"
		for ((b = 0; b < blocks; b++)); do video+="[v${n}_$b]"; done
		video+=";"
		for i in "${segment_audio[@]}"; do
			audio+="[$n:$i]asplit=$blocks"
			for ((b = 0; b < blocks; b++)); do audio+="[a${n}_${i}_$b]"; done
			audio+=";"
		done
		for ((b = 0; b < blocks; b++)); do
			block=("${cuts[@]:b*32:32}")
			start=${block[0]%+*}
			last=${block[-1]}
			end=$(awk -v s="${last%+*}" -v d="${last#*+}" 'BEGIN { printf "%.6f", s + d }')
			# The block keeps the input's clock, so its cuts trim by their own times.
			video+="[v${n}_$b]trim=start=$start:end=$end,split=${#block[@]}"
			for k in "${!block[@]}"; do video+="[s${n}_${b}_$k]"; done
			video+=";"
			for i in "${segment_audio[@]}"; do
				audio+="[a${n}_${i}_$b]atrim=start=$start:end=$end,asplit=${#block[@]}"
				for k in "${!block[@]}"; do audio+="[t${n}_${i}_${b}_$k]"; done
				audio+=";"
			done
			for k in "${!block[@]}"; do
				start=${block[$k]%+*} length=${block[$k]#*+}
				video+="[s${n}_${b}_$k]trim=start=$start:duration=$length,setpts=PTS-STARTPTS[p$count];"
				video+="anullsrc=r=8000:cl=mono,atrim=duration=${length}[z$count];"
				video_in+="[p$count][z$count]"
				for j in "${!segment_audio[@]}"; do
					i=${segment_audio[$j]}
					audio+="[t${n}_${i}_${b}_$k]atrim=start=$start:duration=$length,asetpts=PTS-STARTPTS,apad=whole_dur=${length}[q${count}_$i];"
					audio_in[j]+="[q${count}_$i]"
				done
				((count += 1))
			done
		done
	done
	video+="${video_in}concat=n=$count:v=1:a=1[cv][cz];[cz]anullsink;[cv]${1:-null}[v]"
	# Each stream's chain has its own noise labels.
	local chain=${noise_audio_filter:-anull}
	for j in "${!segment_audio[@]}"; do
		audio+="${audio_in[j]}concat=n=$count:v=0:a=1,${chain//bvcnr/bvcnr$j}[a$j];"
	done
	add_filtergraph video "$video"
	[[ -z $audio ]] || add_filtergraph audio "${audio%;}"
}

# Appends a -filter_complex to the caller's filters. An argument holds at most
# 128 KiB in bytes (MAX_ARG_STRLEN) and ${#2} counts characters, so a graph over
# 64 KiB, which multibyte file names could take past the limit, is read from a
# file. That needs FFmpeg 7; the usual joins stay arguments for older ones.
add_filtergraph() { # <name> <graph>
	if ((${#2} > 65536)); then
		printf '%s' "$2" >"$output_stage_dir/$1.graph"
		filters+=(-/filter_complex "$output_stage_dir/$1.graph")
	else
		filters+=(-filter_complex "$2")
	fi
}

# A full disk or an unreadable source fails every stage the same way; the
# next stage would only repeat a long encode.
encode_cannot_succeed() {
	grep -qE 'No space left on device|Disk quota exceeded|Input/output error|moov atom not found' "$ffmpeg_log"
}

# Encodes with the 3-stage fallback: GPU, then software decoding, then software.
attempt_conversion() {
	: >"$ffmpeg_log"
	# Reset all hardware state before a retry.
	encoder_format=()
	decoder_format=()
	init_hardware=()
	force_8bit_color=""

	# Never hardcode kernel_driver=i915: Arrow Lake and newer Intel GPUs are
	# driven by "xe", and asking for i915 there fails with "Device creation
	# failed", which silently pushes every Intel conversion down to software
	# encoding. Use the driver the kernel reports for the chosen node, else for
	# the first Intel GPU, and omit the filter when it cannot be determined.
	intel_kernel_driver=""
	for _device_dir in ${gpu_device:+"/sys/class/drm/${gpu_device##*/}/device"} /sys/class/drm/card[0-9]*/device; do
		[[ -r $_device_dir/vendor && $(<"$_device_dir/vendor") == 0x8086 ]] || continue
		intel_kernel_driver=$(basename "$(readlink -f "$_device_dir/driver" 2>/dev/null)")
		[[ -n $intel_kernel_driver ]] && break
	done
	init_qsv=(-init_hw_device "vaapi=va:$gpu_device,driver=iHD${intel_kernel_driver:+,kernel_driver=$intel_kernel_driver}" -init_hw_device qsv=qs@va)
	init_vaapi=(-init_hw_device vaapi)
	init_vulkan=(-init_hw_device vulkan)
	init_nvidia=(-init_hw_device cuda)

	if [[ -n "$gpu_device" ]]; then
		init_vaapi=(-init_hw_device "vaapi=va:$gpu_device")
		# Vulkan names a device by index or name; a render node goes through DRM.
		init_vulkan=(-init_hw_device "drm=dr:$gpu_device" -init_hw_device vulkan=vk@dr)
	fi
	# FFmpeg names a CUDA device by its index, never by a /dev/dri node.
	[[ -z $cuda_index ]] || init_nvidia=(-init_hw_device "cuda=cu:$cuda_index")
	decoder_qsv=(-filter_hw_device qs -hwaccel qsv -hwaccel_output_format qsv)
	decoder_vaapi=(-hwaccel vaapi -hwaccel_output_format vaapi)
	decoder_vulkan=(-hwaccel vulkan -hwaccel_output_format vulkan)
	decoder_cuda=(-hwaccel cuda -hwaccel_output_format cuda)

	local scale_filter_name="scale"
	local detected_encoder_var="" # Track which encoder var was selected

	if [[ "$gpu" == "software" ]]; then
		encoder_format=("${encoder_software[@]}")
		detected_encoder_var="encoder_software"
		force_software=1
	# ProRes has no hardware encoder.
	elif [[ "$video_encoder" == "prores" ]]; then
		encoder_format=("${encoder_software[@]}")
		detected_encoder_var="encoder_software"
		force_software=1
	elif [[ "$force_encoder" == "vulkan" || "$gpu" == "vulkan" ]]; then
		decoder_format=("${decoder_vulkan[@]}")
		encoder_format=("${encoder_vulkan[@]}")
		detected_encoder_var="encoder_vulkan"
		init_hardware=("${init_vulkan[@]}")
	elif [[ $gpu_vendor == nvidia ]]; then
		decoder_format=("${decoder_cuda[@]}")
		encoder_format=("${encoder_nvenc[@]}")
		detected_encoder_var="encoder_nvenc"
		init_hardware=("${init_nvidia[@]}")
		scale_filter_name="scale_cuda"
	elif [[ $gpu_vendor == amd ]]; then
		decoder_format=("${decoder_vaapi[@]}")
		encoder_format=("${encoder_vaapi[@]}")
		detected_encoder_var="encoder_vaapi"
		init_hardware=("${init_vaapi[@]}")
		scale_filter_name="scale_vaapi"
	elif [[ $gpu_vendor == intel ]]; then
		decoder_format=("${decoder_qsv[@]}")
		encoder_format=("${encoder_qsv[@]}")
		detected_encoder_var="encoder_qsv"
		init_hardware=("${init_qsv[@]}")
		scale_filter_name="scale_qsv"
	else
		encoder_format=("${encoder_software[@]}")
		detected_encoder_var="encoder_software"
	fi

	if [[ -n $force_encoder ]]; then
		case $force_encoder in
		nvenc)
			detected_encoder_var=encoder_nvenc
			init_hardware=("${init_nvidia[@]}")
			scale_filter_name=scale_cuda
			;;
		vaapi)
			detected_encoder_var=encoder_vaapi
			init_hardware=("${init_vaapi[@]}")
			scale_filter_name=scale_vaapi
			;;
		qsv)
			detected_encoder_var=encoder_qsv
			init_hardware=("${init_qsv[@]}")
			scale_filter_name=scale_qsv
			;;
		vulkan)
			detected_encoder_var=encoder_vulkan
			init_hardware=("${init_vulkan[@]}")
			;;
		software) detected_encoder_var=encoder_software ;;
		*)
			echo "ERROR: Unsupported encoder backend: $force_encoder" >&2
			exit_code=2
			return
			;;
		esac
		local -n requested_encoder=$detected_encoder_var
		encoder_format=("${requested_encoder[@]}")
	fi
	if [[ -n $force_decoder ]]; then
		case $force_decoder in
		cuda)
			decoder_format=("${decoder_cuda[@]}")
			init_hardware=("${init_nvidia[@]}")
			;;
		vaapi)
			decoder_format=("${decoder_vaapi[@]}")
			init_hardware=("${init_vaapi[@]}")
			;;
		qsv)
			decoder_format=("${decoder_qsv[@]}")
			init_hardware=("${init_qsv[@]}")
			;;
		vulkan)
			decoder_format=("${decoder_vulkan[@]}")
			init_hardware=("${init_vulkan[@]}")
			;;
		software)
			decoder_format=()
			gpu_partial=1
			;;
		*)
			echo "ERROR: Unsupported decoder backend: $force_decoder" >&2
			exit_code=2
			return
			;;
		esac
	fi
	if [[ $force_software == 1 || ${#encoder_format[@]} -eq 0 ]]; then
		encoder_format=("${encoder_software[@]}")
		detected_encoder_var=encoder_software
		init_hardware=()
		decoder_format=()
	fi
	if [[ $force_copy_video == 1 ]]; then
		encoder_format=("${copy_video[@]}")
		detected_encoder_var=copy_video
		init_hardware=()
		decoder_format=()
	fi

	local video_filter_chain=""
	local filter_parts=()

	# hwdownload hands out only the surface's own format, and a 10-bit source
	# decodes to p010: reduce it to 8 bits on the GPU, before any download,
	# and download a kept 10-bit picture as p010.
	if [[ $needs_10bit_to_8bit_conversion == true ]]; then
		force_8bit_color="${scale_filter_name}=format=nv12,"
	fi
	local download_format=nv12
	[[ $is_10bit == false || $needs_10bit_to_8bit_conversion == true ]] || download_format=p010le

	# HDR to SDR on the GPU where one maps it: OpenCL beside CUDA, libplacebo
	# (Vulkan) beside VAAPI, both much faster than software. OpenCL's hable
	# comes out far darker than the software chain and mpv; mobius matches
	# both. Both hand over 8-bit frames in system memory; a failure lands in
	# Stage 2.
	# setparams: tonemap_opencl's frames reach format negotiation untagged,
	# and with bt709 requested for the stream a later scale_cuda then failed
	# ("Impossible to convert between the formats").
	local gpu_tonemap=""
	if [[ $needs_tonemap == true && $detected_encoder_var == encoder_nvenc ]] &&
		run_gpu_check "$ffmpeg_executable" -nostdin -v error -init_hw_device "opencl=ocl:,platform_vendor=NVIDIA" \
			-f lavfi -i nullsrc=s=16x16 -frames:v 1 -f null - 2>/dev/null; then
		init_hardware+=(-init_hw_device "opencl=ocl:,platform_vendor=NVIDIA" -filter_hw_device ocl)
		gpu_tonemap="hwdownload,format=p010le,$hdr_tags,hwupload,tonemap_opencl=tonemap=mobius:desat=0:t=bt709:m=bt709:p=bt709:r=tv:format=nv12,hwdownload,format=nv12,setparams=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv"
	elif [[ $needs_tonemap == true && $detected_encoder_var == encoder_vaapi &&
		$("$ffmpeg_executable" -hide_banner -filters 2>/dev/null) == *" libplacebo "* ]]; then
		gpu_tonemap="$hdr_tags,libplacebo=tonemapping=hable:colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv:format=nv12"
	fi
	# The upload after system-memory filters: with OpenCL as the filter
	# device, the generic one would pick OpenCL.
	local uploader=hwupload
	[[ $gpu_tonemap != *opencl* ]] || uploader="hwupload_cuda${cuda_index:+=device=$cuda_index}"

	# What must run in system memory: CUDA deinterlaces on the GPU, and crop,
	# turn and flip alone can stay there too (gpu_geometry_filters).
	local gpu_first="" gpu_geometry=""
	if [[ -n $deinterlace_filter && $detected_encoder_var == encoder_nvenc ]]; then
		gpu_first="bwdif_cuda=mode=send_frame:deint=interlaced,"
	elif [[ -n $deinterlace_filter ]]; then
		filter_parts+=("$deinterlace_filter")
	fi
	[[ -z $stabilize_filter ]] || filter_parts+=("$stabilize_filter")
	if [[ -n $video_filter ]]; then
		if [[ ${#filter_parts[@]} -gt 0 || $fps_filter == framerate=* || -n $gpu_tonemap ]] ||
			! gpu_geometry=$(gpu_geometry_filters "$video_filter"); then
			filter_parts+=("$video_filter")
		fi
	fi
	[[ $fps_filter != framerate=* ]] || filter_parts+=("$fps_filter")

	# vflip flips a frame by pointing at its last row with a negative stride,
	# and CUDA copies take no negative stride: an upload of such a frame, or a
	# download into the flipped buffer vflip offers upstream (which format
	# passes on), failed and left a green picture. null keeps that offer from
	# reaching hwdownload, and copy hands the upload an ordinary frame.
	# The format after the filters is there because eq and hue work in 8 bits
	# and would hand a 10-bit encode 8-bit frames.
	gpu_first="${source_tag_filter:+$source_tag_filter,}$gpu_first"
	if [[ ${#filter_parts[@]} -gt 0 || -n $gpu_tonemap ]]; then
		if [[ -n $gpu_tonemap ]]; then
			video_filter_chain="${gpu_first}$gpu_tonemap,null"
		else
			video_filter_chain="${gpu_first}${force_8bit_color}hwdownload,format=$download_format,null"
		fi
		[[ ${#filter_parts[@]} -eq 0 ]] || video_filter_chain+=",$(
			IFS=,
			echo "${filter_parts[*]}"
		)"
		video_filter_chain+=",format=$download_format"
		# VAAPI and Vulkan encoders only take frames in GPU memory; NVENC and
		# QSV also accept these.
		if [[ -n $video_resolution ]]; then
			video_filter_chain+=",copy,$uploader,$scale_filter_name=$fit_size"
		elif [[ $detected_encoder_var == encoder_vaapi || $detected_encoder_var == encoder_vulkan ]]; then
			video_filter_chain+=",copy,$uploader"
		fi
	else
		video_filter_chain="${gpu_first}${force_8bit_color}${gpu_geometry:+$gpu_geometry,}"
		[[ -z $video_resolution ]] || video_filter_chain+="$scale_filter_name=$fit_size,"
		video_filter_chain=${video_filter_chain%,}
	fi
	# Dropping frames works on GPU frames as well, so it closes every chain.
	[[ $fps_filter != fps=* ]] || video_filter_chain+="${video_filter_chain:+,}$fps_filter"

	# Otherwise tone mapping runs in software on full-depth frames.
	if [[ $needs_tonemap == true && -z $gpu_tonemap && "$detected_encoder_var" != "encoder_software" ]]; then
		gpu_partial=1
		echo "HDR to SDR tone mapping: forcing software decode (Stage 2)"
	fi
	# FFmpeg turns a picture by its display matrix only in system memory:
	# decoded into GPU memory, a phone video came out sideways with the
	# matrix dropped (CUDA, VAAPI, QSV and Vulkan alike). Decode it in
	# software; the rotation then happens before the filters and the upload.
	if [[ $source_rotation =~ ^-?(90|180|270)([.]0+)?$ && "$detected_encoder_var" != "encoder_software" ]]; then
		gpu_partial=1
		echo "Rotated video (${source_rotation}°): forcing software decode (Stage 2)"
	fi
	# scale_vulkan garbles the picture unless both input dimensions are
	# multiples of 32 (FFmpeg 9.0 on NVIDIA): 1280x720 and 1920x1080
	# lost their chroma alignment even at the same size. Scale and reduce the
	# bit depth in software, before the upload.
	if [[ $detected_encoder_var == encoder_vulkan && (-n "$video_resolution" || $needs_10bit_to_8bit_conversion == true) ]]; then
		gpu_partial=1
		echo "Vulkan scaling: forcing software decode (Stage 2)"
	fi

	if [[ $force_copy_video != 1 && $detected_encoder_var != encoder_software && ${gpu_smoke_test:-1} == 1 ]]; then
		if ! hw_encoder_smoke_test; then
			echo "GPU encoder check failed: $hw_encoder_name did not encode a test frame (driver problem?). Using software encoding."
			detected_encoder_var=encoder_software
			encoder_format=("${encoder_software[@]}")
			init_hardware=()
			decoder_format=()
			# Later passes (audio fallbacks) must not probe the GPU again.
			force_software=1
		fi
	fi
	if [[ $force_copy_video != 1 && $detected_encoder_var != encoder_software && $gpu_partial != 1 &&
		${#decoder_format[@]} -gt 0 && ${gpu_smoke_test:-1} == 1 ]] && ! hw_decoder_smoke_test; then
		gpu_partial=1
		echo "GPU cannot decode this video: forcing software decode (Stage 2)"
	fi

	# Sidecar subtitles are written once, by a command of their own.
	if [[ ${#subtitle_cmd[@]} -gt 0 && "$subtitle_extract" == "extract" && "$subtitles_already_extracted" != "true" ]]; then
		echo "Extracting subtitles..."
		if run_ffmpeg -y "${subtitle_input_options[@]}" -i "$input_file" "${subtitle_cmd[@]}"; then
			subtitles_already_extracted=true
		else
			# The video is the job; a missing sidecar is not worth discarding it.
			echo "Warning: Subtitle extraction failed (non-critical, continuing with conversion)"
			subtitle_outputs=()
			subtitles_already_extracted=true
		fi
	fi

	if [[ $force_copy_video == 1 ]]; then
		echo "Generating file without re-encoding"
		run_ffmpeg "${input_options[@]}" "${decoder_format[@]}" "${encoder_format[@]}" "${output_options[@]}" "${ffmpeg_generic_options[@]}"
		exit_code=$?
		return
	fi

	# Stage 1: decode and encode on the GPU.
	if [[ "$force_copy_video" != "1" && "$detected_encoder_var" != "encoder_software" && "$gpu_partial" != "1" ]]; then
		# The GUI parses "Encode mode:" and "Running command:"; keep one line
		# each and let the interface handle wording and translation.
		echo "Encode mode: Decode GPU, encode GPU"
		# -hwaccel reaches only the next -i, so each joined cut, which opens
		# the encoder command, gets its own copy of the decoder options.
		local -a decoded_inputs=("${decoder_format[@]}") encoder_args=("${encoder_format[@]}")
		if [[ ${#segment_inputs[@]} -gt 0 ]]; then
			local n option segment_decoder=("${decoder_format[@]}")
			# Each cut has its own decoder and so its own pool of GPU frames,
			# and hwdownload takes one pool ("Input frame is not the in the
			# configured hwframe context"). CUDA copies the frames into one
			# pool; the other decoders hand over system memory themselves
			# (no VAAPI filter makes new frames of an unchanged picture).
			if [[ $video_filter_chain == hwdownload* && $detected_encoder_var == encoder_nvenc ]]; then
				video_filter_chain="scale_cuda=passthrough=0,$video_filter_chain"
			elif [[ $video_filter_chain == *hwdownload,* && $detected_encoder_var != encoder_nvenc ]]; then
				video_filter_chain=${video_filter_chain#*hwdownload,}
				segment_decoder=()
				for ((n = 0; n < ${#decoder_format[@]}; n++)); do
					option=${decoder_format[$n]}
					if [[ $option == -hwaccel_output_format ]]; then
						((n += 1))
					else
						segment_decoder+=("$option")
					fi
				done
			fi
			decoded_inputs=()
			for ((n = 0; n < ${#segment_inputs[@]}; n += 6)); do
				decoded_inputs+=("${segment_decoder[@]}" "${segment_inputs[@]:n:6}")
			done
			encoder_args=("${encoder_format[@]:${#segment_inputs[@]}}")
		fi
		local -a filters=()
		set_video_filters "$video_filter_chain"
		run_ffmpeg "${init_hardware[@]}" "${input_options[@]}" "${decoded_inputs[@]}" "${encoder_args[@]}" "${output_options[@]}" "${filters[@]}" "${ffmpeg_generic_options[@]}"
		exit_code=$?
		if [[ $exit_code != 0 ]]; then
			echo "Conversion failed!"
			! encode_cannot_succeed || return
		fi
	elif [[ "$force_copy_video" != "1" ]]; then
		exit_code=1
	fi

	# Stage 2: Partial GPU (decode CPU, encode GPU)
	if [[ (($exit_code != 0 && $force_software != 1 && $force_copy_video != 1) || "$gpu_partial" == "1") && "$detected_encoder_var" != "encoder_software" && "$force_copy_video" != "1" ]]; then
		echo "Encode mode: Decode Software, Encode GPU"

		local partial_video_filter_chain=""
		local partial_filter_parts=()

		[[ -z $source_tag_filter ]] || partial_filter_parts+=("$source_tag_filter")
		[[ -z $deinterlace_filter ]] || partial_filter_parts+=("$deinterlace_filter")

		if [[ $needs_tonemap == true ]]; then
			partial_filter_parts+=("$tonemap_filters")
		fi

		if [[ $needs_gbr_matrix_fix == true ]]; then
			partial_filter_parts+=("zscale=min=709:m=709:tin=709:t=709:pin=709:p=709:r=tv")
		fi

		# User video filters (crop, rotation, flip, etc.) must run in software space
		[[ -z $stabilize_filter ]] || partial_filter_parts+=("$stabilize_filter")
		if [[ -n "$video_filter" ]]; then
			partial_filter_parts+=("$video_filter")
		fi
		[[ $fps_filter != framerate=* ]] || partial_filter_parts+=("$fps_filter")

		local hw_pix_fmt="nv12"
		if [[ $video_encoder == "av1" ]]; then
			hw_pix_fmt="p010le"
		elif [[ $is_10bit == true && $needs_10bit_to_8bit_conversion == false ]]; then
			hw_pix_fmt="p010le"
		fi

		if [[ $detected_encoder_var == encoder_nvenc ]]; then
			partial_filter_parts+=("format=$hw_pix_fmt" "hwupload_cuda")
		elif [[ $detected_encoder_var == encoder_vulkan ]]; then
			if [[ -n "$video_resolution" ]]; then
				partial_filter_parts+=("scale=$fit_size")
			fi
			partial_filter_parts+=("format=$hw_pix_fmt" "hwupload=derive_device=vulkan")
		elif [[ $detected_encoder_var == encoder_vaapi ]]; then
			partial_filter_parts+=("format=$hw_pix_fmt" "hwupload=derive_device=vaapi")
		elif [[ $detected_encoder_var == encoder_qsv ]]; then
			partial_filter_parts+=("format=$hw_pix_fmt" "hwupload=extra_hw_frames=64,format=qsv")
		fi

		if [[ -n "$video_resolution" && $detected_encoder_var != encoder_vulkan ]]; then
			partial_filter_parts+=("$scale_filter_name=$fit_size")
		fi
		[[ $fps_filter != fps=* ]] || partial_filter_parts+=("$fps_filter")

		if [[ ${#partial_filter_parts[@]} -gt 0 ]]; then
			partial_video_filter_chain=$(
				IFS=,
				echo "${partial_filter_parts[*]}"
			)
		fi

		local -a filters=()
		set_video_filters "$partial_video_filter_chain"
		run_ffmpeg "${init_hardware[@]}" "${input_options[@]}" "${encoder_format[@]}" "${output_options[@]}" "${filters[@]}" "${ffmpeg_generic_options[@]}"
		exit_code=$?
		if [[ $exit_code != 0 ]]; then
			echo "Conversion failed!"
			! encode_cannot_succeed || return
		fi
	fi

	# Stage 3: Full software (decode + encode on CPU)
	if [[ ($exit_code != 0 && $force_software != 1 && $force_copy_video != 1) || $force_software == 1 || ("$detected_encoder_var" == "encoder_software") ]]; then
		echo "Encode mode: Decode Software, Encode Software"
		local software_filter_chain=""
		local sw_filter_parts=()

		[[ -z $source_tag_filter ]] || sw_filter_parts+=("$source_tag_filter")
		[[ -z $deinterlace_filter ]] || sw_filter_parts+=("$deinterlace_filter")

		if [[ $needs_tonemap == true ]]; then
			sw_filter_parts+=("$tonemap_filters")
		fi

		if [[ $needs_gbr_matrix_fix == true ]]; then
			sw_filter_parts+=("zscale=min=709:m=709:tin=709:t=709:pin=709:p=709:r=tv")
			if [[ $video_encoder == "av1" ]]; then
				sw_filter_parts+=("format=yuv420p10le")
			elif [[ $is_10bit == true && $needs_10bit_to_8bit_conversion == false ]]; then
				sw_filter_parts+=("format=yuv420p10le")
			else
				sw_filter_parts+=("format=yuv420p")
			fi
		fi

		[[ -z $stabilize_filter ]] || sw_filter_parts+=("$stabilize_filter")
		if [[ -n "$video_filter" ]]; then
			sw_filter_parts+=("$video_filter")
		fi
		[[ $fps_filter != framerate=* ]] || sw_filter_parts+=("$fps_filter")

		if [[ -n "$video_resolution" ]]; then
			sw_filter_parts+=("scale=$fit_size")
		fi
		[[ $fps_filter != fps=* ]] || sw_filter_parts+=("$fps_filter")

		if [[ ${#sw_filter_parts[@]} -gt 0 ]]; then
			software_filter_chain=$(
				IFS=,
				echo "${sw_filter_parts[*]}"
			)
		fi

		local -a filters=() second_pass=()
		set_video_filters "$software_filter_chain"
		if [[ -n ${video_bitrate:-} && $video_encoder != prores ]]; then
			# On the processor a bitrate job is two-pass: the first pass
			# learns where the picture needs bits, so the size lands on
			# target. Audio stays in: joined cuts feed it through the same
			# filtergraph, which would be left with an unconnected output.
			local -a pass_log=(-passlogfile "$output_stage_dir/pass") first_pass=()
			if [[ $video_encoder != h265 ]]; then
				first_pass=(-pass 1 "${pass_log[@]}")
				second_pass=(-pass 2 "${pass_log[@]}")
			elif x265_takes_stats; then
				first_pass=(-pass 1 "${pass_log[@]}" -x265-stats "$output_stage_dir/pass.x265")
				second_pass=(-pass 2 "${pass_log[@]}" -x265-stats "$output_stage_dir/pass.x265")
			else
				# The later -x265-params replaces the encoder's log-level one,
				# so it is repeated. ':' and '\' in the path are escaped for
				# FFmpeg's key=value list.
				local stats=$output_stage_dir/pass.x265
				stats=${stats//\\/\\\\}
				stats=${stats//:/\\:}
				first_pass=(-x265-params "log-level=warning:pass=1:stats=$stats")
				second_pass=(-x265-params "log-level=warning:pass=2:stats=$stats")
			fi
			echo "Analysis pass: measuring the video before the sized encode"
			run_ffmpeg "${input_options[@]}" "${encoder_software[@]}" "${output_options[@]}" "${filters[@]}" \
				"${first_pass[@]}" -sn -dn -f null - || {
				exit_code=$?
				return
			}
		fi
		run_ffmpeg "${input_options[@]}" "${encoder_software[@]}" "${output_options[@]}" "${filters[@]}" "${second_pass[@]}" "${ffmpeg_generic_options[@]}"
		exit_code=$?
	fi
}

# A path inside a filter description is parsed twice, as an option value and
# then as part of the graph. Escaped for both, a folder named "a,b" or "x:y"
# stays a path instead of ending the option or starting another filter.
filter_path() {
	local s=$1
	s=${s//\\/\\\\} s=${s//\'/\\\'} s=${s//:/\\:}
	s=${s//\\/\\\\} s=${s//\'/\\\'} s=${s//\[/\\[} s=${s//]/\\]} s=${s//,/\\,} s=${s//;/\\;}
	printf '%s' "$s"
}

# Stabilizing takes two passes: vidstabdetect measures the camera motion in
# the frames the encode will see (deinterlaced, cuts joined), and
# vidstabtransform then moves each frame against it. unsharp gives back the
# detail the resampling softens, as the vid.stab documentation advises.
stabilize_filter=""
analyze_stabilization() {
	local -a segment_audio=() filters=() inputs=(-i "$input_file") maps=(-map 0:v:0) limits=()
	local i transforms
	transforms=$(filter_path "$output_stage_dir/stabilize.trf")
	[[ ${#segment_inputs[@]} -eq 0 ]] || inputs=("${segment_inputs[@]}") maps=(-map '[v]')
	# The analysis covers what the encode covers: its trim, not the whole file.
	for ((i = 0; i < ${#output_options[@]}; i++)); do
		case ${output_options[$i]} in
		-t | -to | -frames:v) limits+=("${output_options[$i]}" "${output_options[$((i + 1))]}") ;;
		esac
	done
	set_video_filters "${deinterlace_filter:+$deinterlace_filter,}vidstabdetect=result=$transforms:shakiness=6"
	echo "Stabilization analysis: measuring camera motion"
	run_ffmpeg "${input_options[@]}" "${inputs[@]}" "${limits[@]}" "${maps[@]}" "${filters[@]}" -f null - || return
	stabilize_filter="vidstabtransform=input=$transforms:smoothing=15,unsharp=5:5:0.8:3:3:0.4"
}

subtitles_already_extracted=false
build_audio_cmd
if [[ ${video_stabilize:-0} == 1 && $force_copy_video != 1 ]]; then
	if ! "$ffmpeg_executable" -hide_banner -filters 2>/dev/null | grep -qw vidstabdetect; then
		echo "Warning: this FFmpeg has no vid.stab filters, so the video is not stabilized"
	elif ! analyze_stabilization; then
		echo "ERROR: Stabilization analysis failed" >&2
		exit 1
	fi
fi
build_encoder_commands
attempt_conversion

# Another audio codec only helps when FFmpeg blamed the audio: the audio
# encoder, an audio codec the container has no tag for, or the WebM codec
# rule. Any other failure (a full disk, a broken source) is final.
audio_failure_re='Only VP8 or VP9 or AV1 video and Vorbis or Opus audio|Could not find tag for codec (pcm_|adpcm_|aac|mp[123]|opus|vorbis|e?ac3|dts|truehd|mlp|flac|alac|wma|amr|speex|gsm|cook|atrac|dsd_|wavpack|tta|ape|qdm2|nellymoser|g72)|(pcm_[a-z0-9_]+|aac|mp[123]|opus|vorbis|e?ac3|dts|truehd|mlp|flac|alac) in (MP4|MOV) support is experimental|\[aost#[^]]*\].*(Error|[Ii]nvalid|not supported)'
audio_failed() {
	[[ $exit_code != 0 && $exit_code != 2 && $exit_code != 124 ]] && grep -qE -- "$audio_failure_re" "$ffmpeg_log"
}
# Copying the audio failed because of the audio: encode it instead.
if [[ "$original_audio_handling" == "copy" ]] && audio_failed; then
	echo "Audio stream copy may have failed. Retrying with audio re-encoding..."
	audio_handling="reencode"
	build_audio_cmd
	build_encoder_commands
	attempt_conversion
fi

# A codec the container refused (e.g. Opus in MP4) is replaced by AAC.
# WebM already has its only legal codec, and cannot hold AAC.
if [[ "$audio_handling" == "reencode" && "$audio_codec" != "aac" && ${output_file,,} != *.webm ]] && audio_failed; then
	echo "Audio re-encoding with $audio_codec may have failed. Retrying with AAC codec..."
	audio_codec="aac"
	build_audio_cmd
	build_encoder_commands
	attempt_conversion
fi

# A cancel never gets here: the INT/TERM traps end the script themselves, so
# an ffmpeg exit of 255 is a real error (AVERROR(EPERM) truncated to a byte).
if [[ $exit_code != 0 ]]; then
	echo "Conversion failed with an unexpected error. Exit code: $exit_code."
	exit "$exit_code"
fi

# Subtitle packets spanning a cut must be clipped, not omitted or allowed to
# extend the A/V output, so the encode left them out and they are added now,
# from the original, before anything measures the size.
encoded_output=$staged_output
add_deferred_subtitles() {
	staged_output=$encoded_output
	[[ $defer_subtitles == true ]] || return 0
	echo "Adding subtitles..."
	local with_subtitles="$output_stage_dir/subtitled.${output_file##*.}"
	# The same selection as the encode, aimed at the original as input 1.
	# "-c copy" comes first so the per-stream subtitle encoder still wins.
	run_ffmpeg -i "$encoded_output" "${subtitle_input_options[@]}" -i "$input_file" \
		-map 0:v:0 -map '0:a?' -map_metadata 0 -map_chapters 0 -c copy \
		"${subtitle_cmd[@]/#0:/1:}" "${subtitle_packet_options[@]}" -y "$with_subtitles" || return
	staged_output=$with_subtitles
}
add_deferred_subtitles || exit $?

# A size-limited job that came out too big gets one more encode, at the
# bitrate that fits: scaled by how far the encoder missed when it already had
# one, computed from the size budget when it was a quality encode.
stream_bytes() { # <stream selector> <file> → total packet bytes
	"$ffprobe_executable" -v error -select_streams "$1" -show_entries packet=size -of csv=p=0 "$2" |
		awk '{ total += $1 } END { printf "%d", total }'
}
if [[ -n ${size_limit:-} ]]; then
	size=$(stat -c %s -- "$staged_output")
	if ((size > size_limit)); then
		if [[ $force_copy_video == 1 ]]; then
			echo "ERROR: The copied video is $size bytes, over the $size_limit-byte limit" >&2
			exit 3
		fi
		video_bytes=$(stream_bytes v:0 "$staged_output")
		other_bytes=$((size - video_bytes))
		budget=$((size_limit * 92 / 100 - other_bytes))
		seconds=$("$ffprobe_executable" -v error -show_entries format=duration -of csv=p=0 "$staged_output")
		if ((budget <= 0 || video_bytes <= 0)) || [[ ! $seconds =~ ^[0-9]+([.][0-9]+)?$ ]]; then
			echo "ERROR: The output is $size bytes and cannot be brought under $size_limit" >&2
			exit 3
		fi
		if [[ -n ${video_bitrate:-} ]]; then
			video_bitrate=$((video_bitrate * budget / video_bytes))
		else
			video_bitrate=$(awk -v b="$budget" -v s="$seconds" 'BEGIN { printf "%d", b * 8 / s }')
		fi
		((video_bitrate >= 50000)) || {
			echo "ERROR: The output cannot fit $size_limit bytes" >&2
			exit 3
		}
		echo "Output is $size bytes, over the $size_limit-byte limit: encoding again at $video_bitrate bit/s"
		build_audio_cmd
		build_encoder_commands
		attempt_conversion
		[[ $exit_code == 0 ]] || exit "$exit_code"
		add_deferred_subtitles || exit $?
		size=$(stat -c %s -- "$staged_output")
		if ((size > size_limit)); then
			echo "ERROR: The output is still $size bytes, over the $size_limit-byte limit" >&2
			exit 3
		fi
	fi
fi

if [[ ! -s $staged_output ]]; then
	echo "ERROR: Conversion produced no output" >&2
	exit 1
fi
# The video first: sidecars belong to a file that exists.
publish "$staged_output" "$output_file" || exit 1
# The GUI takes the published name from this line, so it must stay one line:
# a backslash, carriage return or newline in the path is written escaped.
escaped=${published_file//\\/\\\\} escaped=${escaped//$'\n'/\\n} escaped=${escaped//$'\r'/\\r}
printf 'Output file: %s\n' "$escaped"
publish_subtitles "$published_file" || exit 1
