ffmpegvideo-processingautomation

FFmpeg cropdetect isn't one-shot. Detect black bars per file

·Javid Jamae·10 min read
FFmpeg cropdetect isn't one-shot. Detect black bars per file

You run cropdetect on one letterboxed MP4, get a clean crop=1920:800:0:140, and the bars disappear. Then you point the same command at a folder of 200 clips and half the outputs come back with bars still attached, a slice of picture missing, or no output at all because libx264 refused an odd height. The command isn't wrong. The assumption that one detection result applies to more than one file is.

Quick answer: FFmpeg cropdetect measures the black borders in a video and prints a crop=w:h:x:y string you pass to the crop filter, as in ffmpeg -ss 120 -t 30 -i in.mp4 -vf cropdetect=limit=24:round=2 -f null - followed by ffmpeg -i in.mp4 -vf crop=1920:800:0:140 -c:a copy out.mp4. Seek past the intro before sampling, use the last crop= line rather than the first, and set round=2 so the dimensions stay divisible by 2. In a batch, run that detect-then-crop pair per file, because bar size differs per source.

Cropdetect reports a running union, not a per-frame measurement

The filter keeps one bounding box across every frame it sees and only ever grows it. Each crop= line is the largest non-black area detected since the last reset, which means the first line printed is based on a handful of early frames and the final line is the one that has seen your whole sample. That's why head -1 scripts produce crops that clip the picture: they lock in a measurement taken while the video was still fading up from black.

Two settings control this. reset_count (default 0, meaning never reset) makes the filter throw away its accumulated box every N frames, which is useful when a source changes aspect partway through and useless when you want one crop for the file. skip (default 2) ignores the first frames entirely. Leave both alone for normalization work and just take the last line:

ffmpeg -hide_banner -ss 120 -t 30 -i input.mp4 \
  -vf cropdetect=limit=24:round=2:reset_count=0 \
  -f null - 2>&1 | grep -o 'crop=[0-9:]*' | tail -1

-f null - decodes without writing a file, and cropdetect prints to stderr, so the 2>&1 is required. Thirty seconds of sample is plenty; there's no accuracy gain from scanning a 40-minute source end to end, just decode time you pay on every file in the batch.

Seek past the intro or you'll measure a black frame

Placing -ss 120 before -i tells FFmpeg to seek before decoding, so the sample starts two minutes in, past the fade-in, the logo sting, and the black title card that make the opening seconds unrepresentative. On an all-black frame, cropdetect has nothing to bound and the numbers it prints are garbage. Sample the middle of the video instead of the start and that entire failure mode disappears.

Hardcoding -ss 120 breaks on anything shorter than two minutes, and a 45-second UGC clip will return an empty crop string. Derive the offset from the duration instead:

dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
off=$(awk -v d="$dur" 'BEGIN{printf "%d", d*0.1}')

Ten percent in lands past the intro on a 20-minute podcast recording and at the 4-second mark on a 45-second clip. Seeking before -i is fast because FFmpeg jumps to the nearest keyframe rather than decoding everything it skips, which is the same behavior that makes timestamp-accurate trims land on the wrong frame.

Odd dimensions are what actually kills the encode

libx264 requires even width and height for 4:2:0 chroma and fails the job outright with height not divisible by 2 (1920x803). Cropdetect's round option exists to prevent exactly this: it forces the reported width and height to be divisible by the value you give it. The default is 16, which is aggressive and can leave a few pixels of bar behind on non-standard sources. Set round=2 for the tightest crop that still encodes.

The trap is scripts that parse x1:, x2:, y1:, y2: out of the cropdetect line and compute the geometry themselves. A height of y2 - y1 + 1 is odd roughly half the time, and that number sails through your script and dies in the encoder. Use the crop= string the filter already formatted for you.

One detail that surprises people: the crop filter rounds x and y offsets down to the chroma grid by default, so an odd y of 141 becomes 140 in practice. That's exact=0, the default, and it's the behavior you want. Setting exact=1 on subsampled video gives you exact offsets and shifted color.

Skip the files that don't have bars

Half a folder of stock or AI-generated footage usually arrives clean, and re-encoding a clean file costs quality and time for nothing. Compare the detected geometry against the source geometry from ffprobe, and copy the file through untouched when they match. That single check typically cuts batch runtime by a third on mixed footage, because the skipped files never hit the encoder at all.

Here's the whole per-file loop:

#!/usr/bin/env bash
set -euo pipefail
mkdir -p output

for f in input/*.mp4; do
  base=$(basename "$f")

  dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
  off=$(awk -v d="$dur" 'BEGIN{printf "%d", d*0.1}')

  crop=$(ffmpeg -hide_banner -ss "$off" -t 30 -i "$f" \
    -vf cropdetect=limit=24:round=2:reset_count=0 \
    -f null - 2>&1 | grep -o 'crop=[0-9:]*' | tail -1)

  src=$(ffprobe -v error -select_streams v:0 \
    -show_entries stream=width,height -of csv=p=0:s=x "$f")
  want=$(echo "${crop#crop=}" | cut -d: -f1,2 | tr ':' 'x')

  if [ -z "$crop" ] || [ "$src" = "$want" ]; then
    echo "$base: no crop needed ($src)"
    cp "$f" "output/$base"
    continue
  fi

  echo "$base: $src -> $want"
  ffmpeg -hide_banner -loglevel error -i "$f" \
    -vf "$crop" -c:v libx264 -crf 20 -preset medium \
    -c:a copy "output/$base"
done

Every file gets its own detection pass. That's the part the single-file tutorials leave out, and it's the only version that survives a folder where one clip is 2.39:1 letterboxed, one is pillarboxed vertical, and one is already clean.

Common cropdetect pitfalls

Cropdetect breaks in specific ways once real footage hits the script rather than a test file:

  • Dark scenes get over-cropped. limit is the threshold below which a pixel counts as black, default 24 on the 0-255 scale. A night scene with crushed blacks reads as border. Drop limit to 16 for moody footage, raise it toward 40 for noisy or heavily compressed sources where the bars aren't quite pure black.
  • Compression noise in the bars. Re-encoded YouTube rips often have mosquito noise bleeding into the letterbox, so the bars never read as fully black and cropdetect reports the full frame. A higher limit fixes it.
  • Content that's never bright. FFmpeg 5.1 added mode=mvedges, which detects borders from motion vectors instead of luminance and handles sources where the black threshold is useless.
  • Baked-in bars in the middle of the timeline. If one file changes aspect halfway through, a single crop is the wrong output shape for part of it. Detect on both halves and split the file, or leave it alone.
  • Audio silently re-encoded. Cropping always re-encodes video, but -c:a copy keeps the audio stream bit-identical and saves real time on long files.
  • Empty output with no error. A crop that resolves to zero height produces no frames. That's one of the sources of FFmpeg's "Output file is empty, nothing was encoded" message.

Two decode passes per file, or one job

Detect-then-crop means every file gets decoded twice: once for the sample, once for the real encode. On a laptop that's fine for 20 clips and painful for 2,000, and it's worse when the sources live in S3 and each pass pulls the file down again.

StepLocal scriptFFmpeg Micro
Get the filedownload from S3, twicepass the presigned URL
Detectdecode 30s samplehandled in the job
Croplibx264 re-encode on your boxruns on managed workers
Batch of 200serial, hours of CPUjobs run in parallel
Failure handlingparse stderr yourselfjob status plus webhook

That second column is what FFmpeg Micro is: you post a job with the source URL and the normalization you want, get a webhook when the output is ready, and never install an encoder. It reads URLs directly, so the same pattern that lets you hand FFmpeg a presigned URL instead of downloading from S3 applies, and it works the same from n8n, Make, Zapier, or an AI agent over MCP. The docs have the exact request shape, and the playground will run one against your own file before you write any code.

When the job is genuinely one-off, keep it local. The two-line CLI version above is faster than opening a browser.

FAQ

What does ffmpeg cropdetect actually output?

FFmpeg cropdetect writes a line per analyzed frame to stderr containing x1, x2, y1, y2, the computed w and h, and a ready-to-use crop=w:h:x:y string. Cropdetect never modifies the video itself. You take that string and pass it to the crop filter in a second command.

Why does cropdetect give a different crop every time I run it?

Cropdetect results change because the filter measures whatever frames it happens to see, and a different -ss offset means different frames. A fade, a black title card, or a dark scene inside the sample window all shift the reported bounding box. Fix the offset and the sample length in your script and the results become repeatable.

How do I remove black bars from a video in FFmpeg without detecting them?

You can crop fixed bars directly if you already know their size: ffmpeg -i in.mp4 -vf "crop=1920:800:0:140" -c:a copy out.mp4 removes 140 pixels from the top and bottom of a 1080p file. Skipping detection only works when every source in the batch has identical bars, which is rare outside footage from a single camera or a single render preset.

Can I auto crop letterbox in batch without re-encoding?

No. Cropping changes the picture geometry, so the video stream has to be decoded and re-encoded, and -c:v copy will silently ignore the crop filter. Audio can pass through with -c:a copy, and -crf 20 with libx264 keeps the visible quality loss negligible on one generation.

What limit value should I use for cropdetect?

Use the default limit=24 for normally exposed footage, drop to 16 for dark or night footage that's being over-cropped, and raise toward 40 for noisy re-encodes where the bars aren't reading as black. Test the value on the darkest file in your batch, since that's the one that breaks.

If you're normalizing a whole folder of stock, UGC, or AI-generated footage that keeps arriving letterboxed at different sizes, send each file as a job instead of babysitting a bash loop. Sign up free and run the first batch on the free tier.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free