Sunday, November 29, 2020

Handy FFmpeg + bash scripts

I created these scripts to perform some common tasks with FFmpeg and figured I'd post them here in case anyone else needs to do the same jobs.

Convert to mp3

I keep my music collection in lossless FLAC format, but not everything supports this format, including the stereo in my car. It'll play music files from a USB thumb drive and is compatible with a variety of lossy formats, just not FLAC, so if I want to put a new album on my drive to get it into my regular rotation, I have to convert from my lossless archival copy to a compatible lossy format. I made this basic script to simplify the process:

#!/bin/bash

if [ "$1" == "" ] || [ "$1" == "--help" ]; then
echo "Convert audio files to mp3 using LAME with high-quality VB0 settings."
echo "Single files or wildcards are valid arguments."
exit
fi

for i in "$@"
do

[ ! -d "mp3s" ] && mkdir "mp3s"

name=`echo "$i" | sed 's/\.[^.]*$//' | sed 's/.*\///'`

ffmpeg -i "$i" -acodec libmp3lame -q:a 0 mp3s/"$name".mp3

done
To explain a bit what's going on: the first 'if' statement echos out a helper statement if the script is run either without an argument or with the standard '--help' switch. You'll notice I use the special built-in "$1", which means "first argument". More info on the built-in argument variables here. It also exits here instead of going on to the rest of the script, which wouldn't really make sense without the proper arguments anyway.

Next, we start the 'for' loop. Here, I've used another built-in alias, "$@", which is important because it will allow the loop to expand to accept wildcards, such as asterisk/*. When I was first working on this script, I used the same "$1" as before and it would only hit the first match from the wildcard and then stop. More info on that problem in this stackexchange post.

So, for each match to the pattern (be it a single file or a series), it will first check whether the directory named 'mp3s' exists and, if not, it will make it (I find it's easier to deal with the files if they're all in one place instead of mixed in with the FLACs that have exactly the same name other than file extension...).

Next, we declare a variable, "name", which uses some sed magic to remove the file extension. Unfortunately, I looked this up a long time ago and no longer have a link to where I found the answer, and sed syntax is pure black magic to me, so I have zero clue what it's actually doing, but it works. The reason we need to do this is because the resulting mp3s would all be named foo.flac.mp3 otherwise, which is ugly.

Alright, the last bit is where we use FFmpeg to actually do the work of reencoding. The only real gotcha here is knowing that the LAME encoder is called libmp3lame, and knowing about the qscale:a setting, which determines the variable quality/bitrate. Since my car supports it, I use VB0, which is variable bitrate that goes up to 320 kbps when necessary but doesn't waste bits encoding silence, for example. If your device doesn't support variable-bitrate mp3s, you can do -b:a 128k (or whatever bitrate you want) instead.

Trim From The End

Turns out it's really hard to get FFmpeg to trim an arbitrary number of seconds from the end of a file without reencoding, which is exactly what I needed to do to remove a very loud and very useless sequence at the end of every episode of my Duckman DVD rips (my wife likes to fall asleep to reruns, and this sequence would startle her awake every time).

#!/bin/bash

if [ "$1" == "--help" ] || [ "$1" == "" ]; then
echo "Enter a video's filename followed by the number of seconds to trim from it."
echo "For example (to trim 25 seconds from a video named 'foo.mp4'):"
echo "foo.mp4 25"
echo "The trimmed file will be placed in the newly created 'trunc' directory."
exit
fi

DURATION=`ffprobe -v 0 -show_entries format=duration -of compact=p=0:nk=1 "$1"`

echo "$DURATION"

if [ "$2" == "" ]; then
echo "Your video is '$DURATION' seconds long. You need to specify how many seconds to trim."
exit
fi

echo "Total duration is: '$DURATION'"
DUR_INT=`awk 'BEGIN { print int('$DURATION'+0.5) }'`
TRIM=`expr "$DUR_INT" - "$2"`
echo "Duration after trimming is: $TRIM"

if [ ! -d trunc ]; then
  mkdir trunc;
fi

ffmpeg -i "$1" -c copy -t "$TRIM" trunc/"$1"
echo "Success"
exit

Again, I started out with some helpful explanation, just in case I forget how to use the script.

Since we're using two arguments, we use the built-in "$1" and "$2". This command will not run with wildcards.

Next up, we'll use ffprobe (a utility that comes with FFmpeg) to determine the duration of the file in seconds. This gives us a floating point value, which means it usually includes a bunch of fractional stuff after the decimal point. This becomes important later. We'll store this value in the DURATION variable.

So, we now know how long our video is, and we know how many seconds we want to trim, so we should be able to subtract the trim amount from the total value using bash's built-in expr arithmetic, but life is never that easy. No, expr won't subtract an integer from a float, so we need to convert the DURATION float value to an integer using some awk magic (I know as little about awk as sed, sadly). This will take our float value, add 0.5 to it, "floor" it (that is, round it down) to the nearest integer and then cast the value to an "int" (i.e., store it as an actual integer rather than a float; like "2" instead of "2.0"). We'll store that int value in DUR_INT.

Now we can subtract our seconds from the total, and we'll store the resulting total value as TRIM.

After that, it's just like before, where we check for (and if necessary, create) our storage directory (even more important this time because we don't want to accidentally overwrite our original files with the trimmed version), then loop through our pattern matches and use FFmpeg's "-t" switch to stop the new file at our newly calculated "trimmed" duration, which will cut off the end (and we're using the -c copy switch to avoid reencoding, which takes a long time and reduces the quality of the video each time).

Analytics Tracking Footer