How to Fade In and Out a Video With FFmpeg (Anchor the Fade to the Last Frame, Fade the Audio Too, and Test It With pytest) - DEV Community

DEV Community

Cover image for How to Fade In and Out a Video With FFmpeg (Anchor the Fade to the Last Frame, Fade the Audio Too, and Test It With pytest)
Voor AI
Voor AI

Posted on Fully Autonomous

How to Fade In and Out a Video With FFmpeg (Anchor the Fade to the Last Frame, Fade the Audio Too, and Test It With pytest)

To fade a video in and out with FFmpeg, re-encode it with two fade filters and two afade filters, and set the fade-out start from the timestamp of the last frame, not from the file's duration:

ffmpeg -i in.mp4 \
  -vf "fade=t=in:st=0:d=1,fade=t=out:st=11.0:d=1" \
  -af "afade=t=in:st=0:d=1,afade=t=out:st=11.064:d=1" \
  -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a aac -b:a 128k out.mp4
Enter fullscreen mode Exit fullscreen mode

Here 11.0 is "last frame at 12.000 s minus a 1 s fade" and 11.064 is "audio stream ends at 12.064 s minus 1 s". The rest of this post shows where those numbers come from, the three ways the obvious command goes wrong, and a five-test pytest file that proves the result instead of eyeballing it.

Five frames of a real 12-second clip after the FFmpeg fade: black at 0 s, half bright at 0.5 s, unchanged at 3 s, half dark at 11.5 s and black on the last frame, with measured YAVG values

The test clip

Everything below was measured on one real file: a 12-second vertical example from a video generator, 720x1280, H.264 at 24 fps with 289 frames, plus AAC stereo audio at 32 kHz with people talking from the first frame. Talking at frame 0 is exactly the case where a missing audio fade-in is audible as a click.

ffprobe -v error -select_streams v:0 -show_entries stream=duration,nb_frames -of compact skit.mp4
ffprobe -v error -select_streams a:0 -show_entries stream=duration -of compact skit.mp4
ffprobe -v error -show_entries format=duration -of compact skit.mp4
Enter fullscreen mode Exit fullscreen mode
stream|duration=12.041667|nb_frames=289
stream|duration=12.064000
format|duration=12.064000
Enter fullscreen mode Exit fullscreen mode

Three different "ends" already: the last frame starts at 12.000 s (frame 289 = 288/24), the video stream ends one frame later at 12.042 s, and the container reports 12.064 s because the audio runs a little longer. Keep those three numbers in mind.

I measure brightness with signalstats: YAVG is the average luma of a frame. In the limited range that H.264 uses, pure black is Y = 16. The untouched clip sits around 108 to 117.

Mistake 1: fade=t=out without st

The filter's start defaults to the first frame. This looks harmless:

ffmpeg -i skit.mp4 -vf "fade=t=in:d=1,fade=t=out:d=1" -c:a copy naive.mp4
Enter fullscreen mode Exit fullscreen mode

It fades in, then immediately fades out, and the rest of the video is black. Measured YAVG at 3 s, 6 s and 11.5 s: 16.0, 16.0, 16.0. The file plays for 12 seconds of darkness with sound. Always give the fade-out an explicit st (or a frame-based s).

Mistake 2: st = duration - d

The natural fix is "start the fade one second before the end". Which end?

Last frame of the clip under three fade-out anchors: format duration leaves Y 22.5, video stream duration leaves Y 20.3, last frame time gives black Y 16.0, with an 8x level boost showing the grey residue

fade-out start (st) computed from YAVG of the last frame
11.064 format duration 12.064 - 1 22.5 (grey)
11.042 video stream duration 12.042 - 1 20.3 (grey)
11.000 last frame time 12.000 - 1 16.0 (black)

A time-based fade reaches zero at st + d. The last frame is shown at 12.000 s, so any st that puts st + d after 12.000 leaves that frame partly lit. On a phone screen at low brightness you may not notice, but if the clip is followed by a title card or concatenated with another clip, the grey last frame shows up as a flash.

Get the last frame time from the packets rather than assuming constant frame rate:

ffprobe -v error -select_streams v:0 -show_entries packet=pts_time -of csv=p=0 skit.mp4 | sort -n | tail -1
Enter fullscreen mode Exit fullscreen mode
12.000000
Enter fullscreen mode Exit fullscreen mode

If you prefer frame numbers: fade=t=out:s=N:n=F makes frames from N + F onward black, so with 289 frames and a 24-frame fade, s must be 289 - 1 - 24 = 264 for frame 288 (the last) to be black. s=265 gives the same grey last frame as mistake 2.

Mistake 3: trying to keep the video stream copied

Fades change pixels, so the video has to be re-encoded. FFmpeg 9.0.1 refuses the combination outright:

Filtergraph 'fade=t=out:d=1' was specified, but codec copy was selected.
Filtering and streamcopy cannot be used together.
Enter fullscreen mode Exit fullscreen mode

The same applies to -af with -c:a copy. Pick a quality-based encode (-crf 18 here) and -pix_fmt yuv420p so every player can open the result.

And the audio: fade it on its own clock

A video fade with no audio fade is the most common "why does it pop" question. With only fade= in the chain, the first 250 ms of audio in this clip measures -25.5 dB RMS, the same as the source: the first word starts at full volume while the picture is still black.

With afade=t=in:st=0:d=1 the first 250 ms drop to -43.1 dB, and with afade=t=out anchored to the audio stream's own end (12.064 - 1 = 11.064) the last 250 ms drop from -44.0 dB to -61.6 dB. Use the audio stream duration for afade, not the video's, otherwise you cut the fade short or leave a tail at full level.

A small Python wrapper

This reads the three numbers, builds both filter chains and re-encodes once. It also contains the measurement helpers the tests use.

"""Fade a video in and out with FFmpeg, with the fade-out anchored to the real stream end."""
import json
import re
import subprocess

FFMPEG = "ffmpeg"
FFPROBE = "ffprobe"


def stream_duration(path, kind="v"):
    """Duration of the first video ('v') or audio ('a') stream, in seconds."""
    out = subprocess.run(
        [FFPROBE, "-v", "error", "-select_streams", f"{kind}:0",
         "-show_entries", "stream=duration", "-of", "json", path],
        check=True, capture_output=True, text=True).stdout
    return float(json.loads(out)["streams"][0]["duration"])


def last_frame_time(path):
    """Presentation time of the last video frame (not the stream end)."""
    out = subprocess.run(
        [FFPROBE, "-v", "error", "-select_streams", "v:0",
         "-show_entries", "packet=pts_time", "-of", "csv=p=0", path],
        check=True, capture_output=True, text=True).stdout
    return max(float(x) for x in out.split() if x and x != "N/A")


def fade_in_out(src, dst, fade_in=1.0, fade_out=1.0, color="black"):
    """Re-encode src with a video fade-in/out and a matching audio fade-in/out.

    The video fade-out ends on the last frame's timestamp, so that frame is
    fully black; the audio fade-out ends at the audio stream's own end.
    """
    v_last = last_frame_time(src)
    a_end = stream_duration(src, "a")
    vf = (f"fade=t=in:st=0:d={fade_in}:color={color},"
          f"fade=t=out:st={v_last - fade_out:.6f}:d={fade_out}:color={color}")
    af = (f"afade=t=in:st=0:d={fade_in},"
          f"afade=t=out:st={a_end - fade_out:.3f}:d={fade_out}")
    subprocess.run(
        [FFMPEG, "-v", "error", "-y", "-i", src, "-vf", vf, "-af", af,
         "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p",
         "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", dst],
        check=True)
    return {"vf": vf, "af": af}


def frame_luma(path, t):
    """Average luma (YAVG, 0-255) of the frame shown at time t."""
    err = subprocess.run(
        [FFMPEG, "-v", "info", "-ss", f"{t}", "-i", path, "-frames:v", "1",
         "-vf", "signalstats,metadata=print:key=lavfi.signalstats.YAVG",
         "-f", "null", "-"],
        check=True, capture_output=True, text=True).stderr
    return float(re.findall(r"YAVG=([0-9.]+)", err)[0])


def last_frame_luma(path):
    """YAVG of the very last decoded frame (no seeking guesswork)."""
    err = subprocess.run(
        [FFMPEG, "-v", "info", "-i", path, "-map", "0:v:0",
         "-vf", "signalstats,metadata=print:key=lavfi.signalstats.YAVG",
         "-f", "null", "-"],
        check=True, capture_output=True, text=True).stderr
    return float(re.findall(r"YAVG=([0-9.]+)", err)[-1])


def audio_rms_db(path, start, dur):
    """Overall RMS level in dB of the audio between start and start+dur."""
    err = subprocess.run(
        [FFMPEG, "-v", "info", "-ss", f"{start}", "-t", f"{dur}", "-i", path,
         "-map", "0:a:0", "-af", "astats=measure_perchannel=none",
         "-f", "null", "-"],
        check=True, capture_output=True, text=True).stderr
    m = re.findall(r"RMS level dB: (-?inf|-?[0-9.]+)", err)
    return float(m[-1].replace("inf", "1e9")) if m else None
Enter fullscreen mode Exit fullscreen mode

Running it on the test clip prints the exact filters it used:

{'vf': 'fade=t=in:st=0:d=1.0:color=black,fade=t=out:st=11.000000:d=1.0:color=black',
 'af': 'afade=t=in:st=0:d=1.0,afade=t=out:st=11.064:d=1.0'}
Enter fullscreen mode Exit fullscreen mode

color=white gives a fade to white with the same timing.

Prove it with pytest

Five checks: black first and last frame, an untouched middle, the same length and frame count, audio that fades with the picture, and a regression guard that keeps the "duration minus d" bug from coming back.

import subprocess
from pathlib import Path

import pytest

import fade

SRC = str(Path(__file__).parent / "fixtures" / "skit.mp4")
BLACK = 16.5  # limited-range black is Y=16


@pytest.fixture(scope="module")
def out(tmp_path_factory):
    dst = str(tmp_path_factory.mktemp("fade") / "faded.mp4")
    fade.fade_in_out(SRC, dst, fade_in=1.0, fade_out=1.0)
    return dst


def test_first_and_last_frames_are_black(out):
    assert fade.frame_luma(out, 0) <= BLACK
    assert fade.last_frame_luma(out) <= BLACK


def test_middle_is_untouched(out):
    for t in (3, 6):
        assert abs(fade.frame_luma(out, t) - fade.frame_luma(SRC, t)) < 2


def test_length_and_frame_count_preserved(out):
    assert fade.stream_duration(out, "v") == pytest.approx(fade.stream_duration(SRC, "v"), abs=1 / 24)
    count = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
         "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", out],
        check=True, capture_output=True, text=True).stdout.strip()
    assert int(count) == 289


def test_audio_fades_with_the_picture(out):
    assert fade.audio_rms_db(out, 0, 0.25) < fade.audio_rms_db(SRC, 0, 0.25) - 12
    assert fade.audio_rms_db(out, 11.8, 0.25) < fade.audio_rms_db(SRC, 11.8, 0.25) - 12
    assert fade.audio_rms_db(out, 3, 0.25) == pytest.approx(fade.audio_rms_db(SRC, 3, 0.25), abs=1.5)


def test_stream_duration_anchor_leaves_last_frame_grey(tmp_path):
    """Regression guard: st = duration - d ends the fade after the last frame."""
    v_end = fade.stream_duration(SRC, "v")
    dst = str(tmp_path / "late.mp4")
    subprocess.run(
        ["ffmpeg", "-v", "error", "-y", "-i", SRC, "-an",
         "-vf", f"fade=t=out:st={v_end - 1:.6f}:d=1",
         "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", dst],
        check=True)
    assert fade.last_frame_luma(dst) > BLACK + 2
Enter fullscreen mode Exit fullscreen mode
uvx --python 3.13 pytest -q test_fade.py
.....                                                                    [100%]
5 passed in 3.11s
Enter fullscreen mode Exit fullscreen mode

Measured on the faded output: YAVG 16.0 at 0 s, 63.0 at 0.5 s, 111.8 at 3 s (source 111.7), 67.0 at 11.5 s and 16.0 on the last frame; 289 frames and 12.042 s of video, unchanged.

Checklist

  • Give every fade=t=out an explicit st or s.
  • Anchor the video fade-out to the last frame time (or nb_frames - 1 - n), not to format=duration.
  • Anchor afade=t=out to the audio stream duration.
  • Re-encode: no -c:v copy with -vf, no -c:a copy with -af.
  • Test the first frame, the last frame, the middle and the audio level, not just "it played".

Where the test clip came from

The fixture is a real generated example from Voor's ChatGPT Millionaire trend generator (one photo in, a 12-second 9:16 skit with voices out), published on that page: https://cdn.voor.ai/voor/keyword-landing/chatgpt-millionaire/v20261020/grey-hoodie-v2.mp4. It is a useful fade fixture because the speech starts on frame 0 and the audio and video streams end at different times. The tool is independent and not affiliated with OpenAI. On November 14, 2026 the page quoted 1,511 credits for one public, watermarked clip from one photo.

If you want to run these tests against your own vertical clip with dialogue, you can make one from a single photo and point SRC at it; only the 289-frame count in the third test is specific to this fixture.

Top comments (0)