To speed up a video with FFmpeg, change the video timestamps with setpts and the audio tempo with atempo in the same command, then tell FFmpeg what frame rate you want out. For 2x:
ffmpeg -i in.mp4 \ -filter_complex "[0:v]setpts=PTS/2,fps=48[v];[0:a]atempo=2[a]" \ -map "[v]" -map "[a]" -c:v libx264 -crf 18 -c:a aac out.mp4 That last part, the frame rate, is the one most snippets skip, and it decides whether you keep your frames or silently throw half of them away. Below I measure every variant on a real clip, then wrap it in a small Python helper with pytest checks for frame count, A/V sync and pitch.
The test clip
A 5.04 s, 1280×720, 24 fps clip with AAC audio (a ramen cook lifting noodles, steam, street noise). It is the public text-to-video example on Voor's Seedance 2.0 Mini page, so you can grab the same file and reproduce the numbers. ffprobe with -count_frames reports 121 video frames.
What the naive one-liner actually does
The common answer is -filter:v "setpts=PTS/2". It works, in the sense that the video is half as long. But the MP4 muxer keeps the input's 24 fps, so FFmpeg drops every second frame to fit:
| Command (2x) | Frames out | fps | Video / audio |
|---|---|---|---|
| source | 121 | 24 | 5.04 s / 5.09 s |
setpts=PTS/2 only | 62 | 24 | 2.58 s / 2.54 s |
setpts=PTS/2,fps=48 | 121 | 48 | 2.52 s / 2.54 s |
setpts=PTS/2,fps=24 | 61 | 24 | 2.54 s / 2.54 s |
So there are really two different jobs:
- Keep every frame (smooth, larger file, 48 fps): set
fpsto source fps × factor. - Keep 24 fps (smaller, platform-friendly): drop frames on purpose with
fps=24, and know that motion gets choppier.
Both are fine. Doing the second one by accident is the bug.
Audio: use atempo, never asetrate
atempo changes tempo without changing pitch. asetrate changes the sample rate, which speeds the audio up and raises the pitch, so voices go chipmunk. I checked it on a 440 Hz test tone:
On older FFmpeg builds one atempo stage only accepted 0.5–2.0, so the usual trick is to chain stages (atempo=2,atempo=2 for 4x). FFmpeg 9.0.1 accepted atempo=4 in one stage, but the chain works everywhere, so the helper below always builds one.
Slowing down has the opposite problem
setpts=PTS*2 (0.5x) with no rate keeps all 121 frames but stretches them over 10 s, so the file ends up at about 12 fps and stutters. Your options:
| Command (0.5x) | Frames out | fps | Encode time |
|---|---|---|---|
setpts=PTS/0.5 only | 121 | 12 | 0.71 s |
...,fps=24 (duplicate frames) | 242 | 24 | 1.04 s |
...,minterpolate=fps=24:mi_mode=mci | 239 | 24 | 7.71 s |
Duplicated frames look like a step-motion slow-mo; minterpolate synthesizes in-between frames and is about 7x slower here. Check it for warping around steam, hair and edges before you ship it.
Stream copy cannot do this
You might hope to skip re-encoding with -c copy. FFmpeg refuses:
Filtergraph 'setpts=PTS/2' was specified, but codec copy was selected. Filtering and streamcopy cannot be used together. Changing speed means new timestamps for every frame, so it is always a re-encode.
A small helper
import json, subprocess def atempo_chain(factor: float) -> str: """Split a tempo factor into atempo stages within 0.5..2.0.""" if factor <= 0: raise ValueError("factor must be > 0") stages, f = [], factor while f > 2.0: stages.append(2.0); f /= 2.0 while f < 0.5: stages.append(0.5); f /= 0.5 stages.append(f) return ",".join(f"atempo={s:.6g}" for s in stages) def source_fps(path): out = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", path], capture_output=True, text=True, check=True).stdout.strip() num, den = out.split("/") return float(num) / float(den) def speed_cmd(src, dst, factor, fps="keep", interpolate=False): """fps="keep": source fps * factor; fps=N: resample; fps=None: naive.""" v = f"setpts=PTS/{factor}" if fps == "keep": v += f",fps={source_fps(src) * factor:g}" elif fps and interpolate: v += f",minterpolate=fps={fps}:mi_mode=mci" elif fps: v += f",fps={fps}" return ["ffmpeg", "-v", "error", "-y", "-i", src, "-filter_complex", f"[0:v]{v}[v];[0:a]{atempo_chain(factor)}[a]", "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-crf", "18", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", dst] def speed_up(src, dst, factor, fps="keep", interpolate=False): subprocess.run(speed_cmd(src, dst, factor, fps, interpolate), check=True) return dst def probe(path): out = subprocess.run(["ffprobe", "-v", "error", "-count_frames", "-show_entries", "stream=codec_type,duration,nb_read_frames,avg_frame_rate", "-of", "json", path], capture_output=True, text=True, check=True).stdout s = {x["codec_type"]: x for x in json.loads(out)["streams"]} num, den = s["video"]["avg_frame_rate"].split("/") return {"v_dur": float(s["video"]["duration"]), "a_dur": float(s["audio"]["duration"]) if "audio" in s else None, "frames": int(s["video"]["nb_read_frames"]), "fps": float(num) / float(den)} The clip must have an audio stream; for silent clips drop the [0:a] branch and the second -map.
Tests that catch the silent failures
import subprocess import numpy as np import pytest from speed import atempo_chain, speed_up, probe SRC = "inputs/mini-t2v-720p.mp4" FRAME = 1 / 24 @pytest.fixture(scope="module") def out(tmp_path_factory): return tmp_path_factory.mktemp("speed") def test_atempo_chain_splits_large_and_small_factors(): assert atempo_chain(4) == "atempo=2,atempo=2" assert atempo_chain(0.25) == "atempo=0.5,atempo=0.5" assert atempo_chain(3) == "atempo=2,atempo=1.5" def test_2x_keep_every_frame(out): src = probe(SRC) r = probe(speed_up(SRC, str(out / "keep.mp4"), 2)) assert r["frames"] == src["frames"] assert r["fps"] == pytest.approx(48) assert abs(r["v_dur"] - r["a_dur"]) < 2 * FRAME def test_naive_2x_silently_drops_half_the_frames(out): src = probe(SRC) r = probe(speed_up(SRC, str(out / "naive.mp4"), 2, fps=None)) assert r["frames"] <= src["frames"] // 2 + 2 def dominant_hz(path): pcm = subprocess.run(["ffmpeg", "-v", "error", "-i", str(path), "-ac", "1", "-ar", "44100", "-f", "s16le", "-"], capture_output=True, check=True).stdout x = np.frombuffer(pcm, dtype=np.int16).astype(float) spec = np.abs(np.fft.rfft(x * np.hanning(len(x)))) return np.fft.rfftfreq(len(x), 1 / 44100)[spec.argmax()] def test_atempo_keeps_pitch(out): tone, fast = out / "tone.wav", out / "fast.wav" subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=2", str(tone)], check=True) subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", str(tone), "-af", atempo_chain(2), str(fast)], check=True) assert dominant_hz(fast) == pytest.approx(440, abs=5) The full suite (8 tests, also covering 4x sync, slow-motion frame rate, the asetrate pitch shift and the stream-copy refusal) passes in about 4 s on FFmpeg 9.0.1 with Python 3.13:
uvx --python 3.13 --with numpy pytest -q test_speed.py ........ [100%] 8 passed Checklist
- Decide first: keep every frame (
fps= source × factor) or a fixed delivery rate (fps=24/30). - Always pair
setptswithatempoin one-filter_complex, neverasetrate. - Chain
atempostages for portability beyond 2x. - Verify with
ffprobe -count_frames: frame count, fps, and video vs audio duration within a frame or two. - For slow motion, choose between duplicated frames (fast, steppy) and
minterpolate(slow, check for warping).
When not to speed up at all
Speeding up shortens the clip: the 5 s example becomes 2.5 s. If what you actually need is a longer or differently paced shot, it is often cleaner to generate it at that length. On Voor, Seedance 2.0 Mini takes 4–15 s durations at 480p or 720p with generated audio; the form quotes 126 credits for 480p at 5 s and 270 credits for 720p at 5 s before you press Generate. You can try a brief in the Seedance 2.0 Mini generator and then run the clip through the helper above if you still want a time-lapse feel.




