At month end you have thirty product videos that need resizing, compressing for the web, and a vertical cut for social media. You open the editing software, drag them in one by one, hit export, and wait. Fine the first time. By the fifth time it's tedious.
A free tool does all of that from the command line. It's called FFmpeg. Type one command, go make coffee, come back to a finished folder. If your computer has an NVIDIA graphics card it gets faster still. This post is the set of recipes I use, including the limits I ran into. I tested every command on self-generated sample video, with FFmpeg 6.1.1 and an RTX 3060 Ti, without touching any real video.
What FFmpeg and NVENC are
FFmpeg is a command-line program that reads, transforms and writes video and audio. NVENC is a hardware block built into NVIDIA cards that does nothing but compress video. When you choose the h264_nvenc or hevc_nvenc encoder, the compression runs on that block instead of the CPU, which leaves the CPU free for other work.
You can picture the CPU as a versatile chef, and NVENC as a machine that makes exactly one dish, very fast. The machine is quick, but the dish usually comes out a bit bigger. I come back to that in the numbers.
To test without using anyone's data, create a 30-second sample video with sound:
ffmpeg -f lavfi -i "testsrc2=size=1920x1080:rate=30" \
-f lavfi -i "sine=frequency=440:sample_rate=48000" \
-t 30 -c:v libx264 -preset veryfast -crf 20 -pix_fmt yuv420p -c:a aac -b:a 128k sample.mp4
Compressing video on the GPU
# H.264, quality-targeted (constant quality), audio copied
ffmpeg -nostdin -i sample.mp4 -c:v h264_nvenc -preset p5 -tune hq -rc vbr -cq 23 -b:v 0 -c:a copy out-h264.mp4
# H.265/HEVC: smaller files; -tag:v hvc1 makes Apple players accept it
ffmpeg -nostdin -i sample.mp4 -c:v hevc_nvenc -preset p5 -tune hq -rc vbr -cq 28 -b:v 0 -tag:v hvc1 -c:a copy out-hevc.mp4
Two things to understand. -cq is the target quality level: a smaller number looks better and weighs more, and -b:v 0 lets the encoder pick the file size. -preset runs from p1 to p7; a higher number is slower but compresses tighter. I usually use p5.
I measured on the 30-second sample (900 frames), on a machine with 24 CPU threads:
| Encoder | Time | Size |
|---|---|---|
h264_nvenc p5, cq 23 |
3.6 s | 38.7 MB |
hevc_nvenc p5, cq 28 |
3.4 s | 23.1 MB |
libx264 medium, crf 23 (CPU) |
8.7 s | 21.3 MB |
libx265 medium, crf 28 (CPU) |
28.0 s | 15.1 MB |
Read this table carefully. The sample is synthetic, so the absolute numbers don't represent real video. NVENC's cq and x264's crf also aren't the same scale, so the sizes are for reference only. What you can take away: the GPU is clearly faster (about 2.4 times x264 and 8 times x265 here), but the CPU usually compresses tighter at the same perceived quality. Use the GPU when you need speed and volume. Use the CPU when you need the smallest file.

One limit I hit: trying av1_nvenc on the RTX 3060 Ti gave the real error No capable devices found and produced no usable file (same result when I re-ran it on 21 Oct 2026). According to NVIDIA's support table, only the RTX 40 generation and later can encode AV1; the RTX 30 series can only play it. If you need AV1 on this machine, use libsvtav1 on the CPU and accept the slower speed.
Cutting, joining, thumbnails, subtitles
There are two kinds of cut. The accurate kind re-encodes the video so it cuts at the exact frame. The fast kind cuts straight through without re-encoding, but it has to start at the nearest "keyframe", so it can be off by a few frames.
# Accurate cut: -ss BEFORE -i is fast, and re-encoding makes it frame-accurate
ffmpeg -nostdin -ss 5.5 -i in.mp4 -t 4 -c:v h264_nvenc -preset p5 -c:a aac cut-accurate.mp4
# Instant cut with no re-encoding: only cuts on keyframes
ffmpeg -nostdin -ss 5.5 -i in.mp4 -t 4 -c copy cut-copy.mp4
I measured again on 21 Oct 2026: the accurate cut came out at exactly 4.000 s, the fast cut at 4.067 s. To join clips of the same format, use concat, which needs no re-encoding:
printf "file '%s'\n" "$PWD/a.mp4" "$PWD/b.mp4" > list.txt
ffmpeg -nostdin -f concat -safe 0 -i list.txt -c copy joined.mp4
If the clips differ in format or resolution, you must re-encode with the concat filter instead of using this method. Thumbnails and a contact sheet of several frames look like this:
# one frame at 3 s, 640 px wide (-2 keeps the height even)
ffmpeg -nostdin -ss 3 -i in.mp4 -frames:v 1 -vf scale=640:-2 -q:v 3 thumb.jpg
# contact sheet: one frame every 3 s, tiled 2x2
ffmpeg -nostdin -i in.mp4 -vf "fps=1/3,scale=320:-2,tile=2x2" -frames:v 1 sheet.jpg
Subtitles come in two kinds: burned into the picture (always visible) or attached so the viewer can toggle them.
# burn-in (always visible); uses libass on CPU, then encode on GPU
ffmpeg -nostdin -i in.mp4 -vf "subtitles=sub.srt:force_style='FontSize=22,Outline=2'" \
-c:v h264_nvenc -preset p5 -cq 23 -b:v 0 -c:a copy burned.mp4
# soft subtitles (viewer can toggle), no re-encode
ffmpeg -nostdin -i in.mp4 -i sub.srt -c copy -c:s mov_text -metadata:s:s:0 language=eng soft.mp4
A 9:16 vertical version for social media, cropped from the centre (if the subject is off-centre, add an x position to crop):
ffmpeg -nostdin -i in.mp4 -vf "crop=ih*9/16:ih,scale=1080:1920" \
-c:v h264_nvenc -preset p5 -cq 23 -b:v 0 -c:a copy vertical.mp4
Checked with ffprobe on 21 Oct 2026: the vertical version came out at 1080x1920. For large videos you can keep the whole path in GPU memory with -hwaccel cuda -hwaccel_output_format cuda and the scale_cuda filter; I tested that to HEVC 1280x720. Note that with this approach CPU-based filters such as subtitles can't be used.
A batch script that won't cause accidents
Running a whole folder is when things most often go wrong: overwriting the originals, leaving half-written files when interrupted, or one bad file stopping the whole batch. My script avoids all three.

The idea is that the result is written to a temporary file first, and only renamed to the real name after it passes a check, so a half-finished file never carries the real name. Originals are read-only and results go to a different folder. Files already done are skipped, so rerunning after a failure doesn't start over. And before accepting a result, the script compares the output duration with the input.
#!/usr/bin/env bash
set -euo pipefail
IN_DIR=${1:?input dir}; OUT_DIR=${2:?output dir}
LOCK=${GPU_LOCK:-/tmp/gpu.lock} # one GPU job at a time (shared GPU)
mkdir -p "$OUT_DIR"
failed=0
duration() { ffprobe -v error -show_entries format=duration -of csv=p=0 "$1"; }
while IFS= read -r -d '' src; do
name=$(basename "${src%.*}"); dst="$OUT_DIR/$name.mp4"; tmp="$OUT_DIR/.$name.part"
[[ -e "$dst" ]] && { echo "skip $name"; continue; } # idempotent
flock "$LOCK" ffmpeg -nostdin -hide_banner -loglevel error -y -i "$src" \
-map 0:v:0 -map '0:a?' -c:v h264_nvenc -preset p5 -tune hq -rc vbr -cq 23 -b:v 0 \
-c:a aac -b:a 160k -movflags +faststart -f mp4 "$tmp" \
|| { rm -f "$tmp"; echo "FAIL $name"; failed=$((failed+1)); continue; }
din=$(duration "$src"); dout=$(duration "$tmp" || echo 0) # verify before publishing
if awk -v a="$din" -v b="$dout" 'BEGIN{d=a-b; if(d<0)d=-d; exit !(d<=0.5)}'; then
mv -f "$tmp" "$dst"; echo "ok $name"
else
rm -f "$tmp"; echo "BADDUR $name"; failed=$((failed+1))
fi
done < <(find "$IN_DIR" -maxdepth 1 -type f \( -iname '*.mp4' -o -iname '*.mov' -o -iname '*.mkv' \) -print0 | sort -z)
[[ $failed -eq 0 ]] # non-zero exit status if anything failed
A few details worth explaining. -nostdin stops FFmpeg from swallowing the rest of the loop's input. -print0 with read -d '' keeps file names containing spaces from breaking. flock makes jobs sharing the GPU run one after another instead of fighting; consumer cards also limit how many encoding sessions can run at once.
I re-ran it on 21 Oct 2026. A folder with clip2.mp4 and sample one.mp4 (a name with a space) gave ok for both, with 12.00 s becoming 12.01 s. A second run printed skip for both. A folder with a bad.mp4 that was really just text gave FAIL bad, while the neighbouring good.mp4 was still processed, the exit code was 1, and no .part file was left behind.
Common mistakes
The easiest one to hit is forgetting -nostdin inside the loop: FFmpeg swallows the rest of the file list and you only get the first few videos. Another is putting -ss after -i: the command gets much slower because it decodes from the start, while before -i it jumps straight to the spot. Joining files of different formats with concat gives broken picture or sound. For web or phone output, don't forget -pix_fmt yuv420p and -movflags +faststart. And don't assume every card can encode AV1.
What to do next
Try it on a folder of sample videos first, not straight on real data. Create a few videos with the command at the top, run the script, and check the outputs by eye and with ffprobe. Once you trust it, point it at the real folder. If you're building a whole automated process with several steps like this, the principles of locking resources, independent checks and a way back are in Handing a Big Job to Several AIs Without Breaking Things.


