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
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.
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
stream|duration=12.041667|nb_frames=289
stream|duration=12.064000
format|duration=12.064000
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
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?
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
12.000000
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.
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
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'}
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
uvx --python 3.13 pytest -q test_fade.py
..... [100%]
5 passed in 3.11s
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=outan explicitstors. - Anchor the video fade-out to the last frame time (or
nb_frames - 1 - n), not toformat=duration. - Anchor
afade=t=outto the audio stream duration. - Re-encode: no
-c:v copywith-vf, no-c:a copywith-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)