# ffmpeg Check a 9:16 Freestyle Clip Before Upload

Short-video platforms re-encode whatever you give them. If the file you upload is already off (wrong frame size, odd frame rate, mono audio, a mastering level that clips), the platform's transcode makes it worse, and you usually find out only after the post is live. This post shows a small ffmpeg check for a 9:16 clip before upload: four `ffprobe` and `ffmpeg` commands, the numbers to look for, and a script that turns them into pass/fail lines.

The example file is a 15-second 9:16 street freestyle clip generated from a single AI-generated portrait, plus a 17.5-second export of it with a title card and an end card added for Shorts.

![Before and after: an AI-generated portrait on the left, and the same character as the lead of a red-lit street freestyle video on the right](https://cdn.hashnode.com/uploads/covers/6ac7155f8cadc38d9e483af7/1e324f95-bbce-44b9-b363-a47519576712.jpg align="center")

*The clip under test. AI-generated example; the lead is an AI-generated portrait, not a real person.*

## What we want to confirm

For a vertical short going to YouTube Shorts, TikTok or Dailymotion, these are the checks that catch most problems:

*   **Frame size and ratio:** 1080 × 1920 for the upload master (9:16). A 720 × 1280 source is also 9:16 but will look soft if the platform upscales it.
    
*   **Frame rate:** a constant 30 fps.
    
*   **Pixel format:** `yuv420p`, which every player decodes.
    
*   **Audio:** AAC, 48 kHz, stereo.
    
*   **Loudness:** integrated around -14 LUFS, true peak at or below -1 dBTP.
    
*   **Duration:** matches what you expect, so no trailing silence or cut ending.
    

## Step 1: video stream

```bash
ffprobe -v error -select_streams v:0 \
  -show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt \
  -show_entries format=duration -of default=nw=1 short.mp4
```

Output for the 1080 × 1920 export:

```text
codec_name=h264
width=1080
height=1920
pix_fmt=yuv420p
r_frame_rate=30/1
duration=17.533333
```

Output for the raw 15-second generation:

```text
codec_name=h264
width=720
height=1280
pix_fmt=yuv420p
r_frame_rate=30/1
duration=15.000000
```

Both are 9:16 and 30 fps. The raw file is 720 × 1280, which is why the upload master was scaled to 1080 × 1920 with a Lanczos filter instead of letting each platform do it.

## Step 2: audio stream

```bash
ffprobe -v error -select_streams a:0 \
  -show_entries stream=codec_name,sample_rate,channels \
  -of default=nw=1 short.mp4
```

```text
codec_name=aac
sample_rate=48000
channels=2
```

AAC, 48 kHz, stereo: nothing to fix. A 44.1 kHz or mono track would be worth re-exporting, because some platforms resample with audible artefacts.

## Step 3: loudness and true peak

`ebur128` measures integrated loudness (LUFS) and, with `peak=true`, the true peak:

```bash
ffmpeg -hide_banner -nostats -i short.mp4 \
  -af ebur128=peak=true -f null - 2>&1 | tail -n 12
```

Raw generation:

```text
Integrated loudness:
  I:         -15.4 LUFS
Loudness range:
  LRA:         3.7 LU
True peak:
  Peak:       -4.5 dBFS
```

Upload master:

```text
Integrated loudness:
  I:         -14.8 LUFS
Loudness range:
  LRA:         3.3 LU
True peak:
  Peak:       -3.4 dBFS
```

The master sits within 1 LU of -14 LUFS and leaves 3.4 dB of headroom, so the platforms' own normalisation won't push it into clipping.

## Step 4: a pass/fail script

Reading numbers by eye works once. For a batch, wrap the checks in a short Python script:

```python
import json, re, subprocess, sys

def probe(path):
    cmd = ["ffprobe", "-v", "error", "-show_streams",
           "-show_format", "-of", "json", path]
    return json.loads(subprocess.check_output(cmd))

def loudness(path):
    cmd = ["ffmpeg", "-hide_banner", "-nostats", "-i", path,
           "-af", "ebur128=peak=true", "-f", "null", "-"]
    err = subprocess.run(cmd, capture_output=True, text=True).stderr
    i = float(re.findall(r"I:\s+(-?[\d.]+) LUFS", err)[-1])
    tp = float(re.findall(r"Peak:\s+(-?[\d.]+) dBFS", err)[-1])
    return i, tp

def check(path):
    info = probe(path)
    v = next(s for s in info["streams"] if s["codec_type"] == "video")
    a = next(s for s in info["streams"] if s["codec_type"] == "audio")
    i, tp = loudness(path)
    rows = [
        ("1080x1920", (v["width"], v["height"]) == (1080, 1920)),
        ("30 fps", v["r_frame_rate"] == "30/1"),
        ("yuv420p", v["pix_fmt"] == "yuv420p"),
        ("AAC 48k", a["codec_name"] == "aac"
            and a["sample_rate"] == "48000"),
        ("stereo", a["channels"] == 2),
        ("-14 LUFS +/-1", abs(i + 14) <= 1),
        ("peak <= -1", tp <= -1),
    ]
    for name, ok in rows:
        print(("PASS " if ok else "FAIL ") + name)
    return all(ok for _, ok in rows)

if __name__ == "__main__":
    sys.exit(0 if all(check(p) for p in sys.argv[1:]) else 1)
```

Run against the upload master, every line passes; the raw 720 × 1280 file fails the frame-size line (and is 0.6 LU quieter), which is exactly the reason to upload the master instead.

## Fixing a failed line

Most failures have a one-line fix. Keep the original and write a new file each time.

*   **Frame size:** `-vf scale=1080:1920:flags=lanczos` upscales a 720 × 1280 clip. It won't add detail, but it gives you control over the filter instead of each platform's.
    
*   **Frame rate:** `-r 30` forces a constant 30 fps; variable frame rate phone footage is a common cause of audio drift after upload.
    
*   **Audio:** `-ar 48000 -ac 2` converts to 48 kHz stereo.
    
*   **Loudness:** `-af loudnorm=I=-14:TP=-1.5:LRA=11` brings a quiet or hot clip close to the target. Run it in two passes for accuracy if the first pass lands more than 1 LU away.
    

## Things this check does not catch

*   **Text in the platform's UI zones.** Captions and buttons cover roughly the bottom 20%, the right 15% and the top 8-10% of a vertical frame. ffprobe can't see that; export a frame and look.
    
*   **Music rights.** A clip can pass every technical check and still get a copyright claim. Our example keeps the source clip's original song, so expect a claim on platforms with content matching.
    
*   **Lip sync.** Generated clips can drift; watch the whole thing.
    
*   **The first frame.** Some platforms pick the first frame as the cover. Check what frame 0 shows with `ffmpeg -i short.mp4 -frames:v 1 cover.png`.
    

## Summary

*   Probe the video stream: 1080 × 1920, 30 fps, `yuv420p`.
    
*   Probe the audio stream: AAC, 48 kHz, stereo.
    
*   Measure loudness with `ebur128`: about -14 LUFS, true peak at or below -1.
    
*   Script it, and keep the raw generation separate from the upload master.
    

The example clip in this post was generated with [Bulin 47 AI on MixVio](https://mixvio.ai/ai-effects/bulin-47-ai?utm_source=hashnode&utm_medium=article&utm_campaign=bulin-47-ai&utm_content=bulin-47-ai-hn-cta#effect-workspace). This is an independent MixVio AI effect and is not affiliated with any third-party artist or music rights holder.

*Disclosure: written by the MixVio team with AI assistance; the example video was made with MixVio's Bulin 47 AI effect from an AI-generated portrait.*
