Ffmpeg Speed Time
Context: $ARGUMENTS
Quick start
- Constant 2x speed (V+A): → Step 1 →
speedmode with factor 2.0 - Smooth 0.25x slow-motion (motion-interpolated): → Step 1 →
speed --smooth minterpolate --fps 120 - Reverse entire clip: → Step 1 →
reversemode - Freeze last frame for 3s: → Step 1 →
freeze --end 3 - Loop clip 5x: → Step 1 →
loop --count 5 - Timelapse (keep every Nth frame): → Step 1 →
timelapse --every 10 - Speed ramp 1x → 0.25x: → Step 1 →
ramp(splits + concats)
When to use
- Slow a clip to 0.5x / 0.25x with either simple PTS scaling or smooth motion interpolation.
- Speed up a lecture/recording 2x–4x without pitching the voice up.
- Play a clip in reverse (visually and/or audio).
- Hold on the first or last frame for N seconds (title cards, outros).
- Ramp speed mid-clip (cinematic slow-down into a highlight).
- Loop a short clip, build a time-lapse, or freeze a mid-clip frame.
Step 1 — Pick an operation
| Goal | Operation | Key filters |
|---|---|---|
| Constant speed, both V+A | speed |
setpts=K*PTS + atempo=1/K |
| Smooth slow-motion | speed --smooth minterpolate |
minterpolate,setpts=... |
| Play backwards | reverse |
reverse, areverse |
| Hold first / last / mid frame | freeze |
tpad, apad, loop |
| Repeat N times | loop |
loop, aloop or concat demuxer |
| Timelapse (drop frames) | timelapse |
setpts=PTS/N,fps=X |
| Variable-speed ramp | ramp |
split → speed each → concat |
Decision shortcuts:
- Pitch-preserving speed change → always pair
setptswithatempo(neverasetrate). - Pitch-shift for effect (chipmunk / deep voice) →
asetrate=48000*K,aresample=48000(changes speed AND pitch). - Slow-mo below 0.5x and smooth motion matters →
minterpolate(slow render, but no juddery frame repeats). - Slow-mo below 0.5x and render speed matters → plain
setpts(frames simply held longer). - Reverse > ~60s clip → split into chunks, reverse each, concat in reverse order (otherwise RAM explodes).
Step 2 — Build the V+A filter graph
Speed change must apply to BOTH streams or video and audio drift. Core identity:
video: setpts = K * PTS (K < 1 → faster, K > 1 → slower)
audio: atempo = 1 / K (must be inverse of K)
atempo per-instance range is 0.5 – 100.0. For factors outside that range, chain:
0.25x (K=4) → atempo=0.5,atempo=0.5
0.1x (K=10) → atempo=0.5,atempo=0.5,atempo=0.4
4x (K=0.25)→ atempo=2.0,atempo=2.0
8x (K=0.125)→ atempo=2.0,atempo=2.0,atempo=2.0
2a. Constant speed (2x, pitch preserved)
ffmpeg -i in.mp4 \
-filter_complex "[0:v]setpts=0.5*PTS[v];[0:a]atempo=2.0[a]" \
-map "[v]" -map "[a]" out.mp4
2b. Constant speed (0.5x, pitch preserved)
ffmpeg -i in.mp4 \
-filter_complex "[0:v]setpts=2.0*PTS[v];[0:a]atempo=0.5[a]" \
-map "[v]" -map "[a]" out.mp4
2c. Smooth slow-motion 0.25x at 120 fps output
ffmpeg -i in.mp4 -filter_complex \
"[0:v]minterpolate='mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:fps=120',setpts=4.0*PTS[v];\
[0:a]atempo=0.5,atempo=0.5[a]" \
-map "[v]" -map "[a]" out.mp4
minterpolate fps= is the OUTPUT fps the filter generates synthesized frames at — independent of the setpts time-scaling that follows. Apply setpts AFTER minterpolate.
2d. Reverse (short clips only — buffers whole stream in RAM)
ffmpeg -i in.mp4 -vf reverse -af areverse out.mp4
2e. Freeze LAST frame 3s (V+A)
ffmpeg -i in.mp4 \
-vf "tpad=stop_mode=clone:stop_duration=3" \
-af "apad=pad_dur=3" \
-shortest out.mp4
2f. Freeze FIRST frame 2s
ffmpeg -i in.mp4 \
-vf "tpad=start_mode=clone:start_duration=2" \
-af "adelay=2s:all=1" \
out.mp4
2g. Mid-clip freeze (hold frame at t=5s for 2s, source is 30 fps)
Frame index = 5s * 30fps = 150. Hold 2s = 2 * 30 = 60 extra frames:
ffmpeg -i in.mp4 -vf "loop=loop=60:size=1:start=150" -c:a copy out.mp4
2h. Loop entire clip 5x
ffmpeg -stream_loop 4 -i in.mp4 -c copy out.mp4
-stream_loop 4 = play input 5 times total (0-based). Faster than loop filter when stream-copy works. Otherwise:
ffmpeg -i in.mp4 -filter_complex "[0:v]loop=loop=4:size=9999:start=0[v];[0:a]aloop=loop=4:size=2e9:start=0[a]" \
-map "[v]" -map "[a]" out.mp4
2i. Timelapse — keep every 10th frame, output 30 fps
ffmpeg -i in.mp4 -vf "setpts=PTS/10,fps=30" -an out.mp4
or explicit frame selection:
ffmpeg -i in.mp4 -vf "select='not(mod(n,10))',setpts=N/FRAME_RATE/TB" -an out.mp4
2j. Pitch-shifted speed (chipmunk effect)
ffmpeg -i in.mp4 \
-filter_complex "[0:v]setpts=(2/3)*PTS[v];[0:a]asetrate=48000*1.5,aresample=48000[a]" \
-map "[v]" -map "[a]" out.mp4
asetrate multiplies both playback rate and pitch (like tape speedup). Follow with aresample to restore the container's expected sample rate.
Step 3 — Run and verify duration
Expected output duration:
new_duration = source_duration * K (K from setpts=K*PTS)
Verify:
ffprobe -v error -show_entries format=duration -of default=nw=1:nk=1 out.mp4
For speed 2x on a 60s source you should see ~30. For 0.5x, ~120. If the number disagrees, you probably forgot to match setpts with atempo or one of the audio chains exceeded per-instance range.
Available scripts
scripts/time.py— subcommand-based wrapper around the recipes above. Stdlib only.--dry-runprints the exact ffmpeg command.
# 2x speed
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py speed --input in.mp4 --output out.mp4 --factor 2.0
# 0.25x smooth slow-mo at 120 fps
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py speed --input in.mp4 --output out.mp4 --factor 0.25 --smooth minterpolate --fps 120
# Reverse
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py reverse --input in.mp4 --output out.mp4
# Freeze last 3s
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py freeze --input in.mp4 --output out.mp4 --end 3
# Freeze mid-clip at t=5s for 2s
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py freeze --input in.mp4 --output out.mp4 --at 5 --duration 2
# Loop 5x
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py loop --input in.mp4 --output out.mp4 --count 5
# Timelapse
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py timelapse --input in.mp4 --output out.mp4 --every 10 --fps 30
# Ramp 1.0x → 0.25x starting at t=10s over 2s
uv run ${CLAUDE_SKILL_DIR}/scripts/time.py ramp --input in.mp4 --output out.mp4 --from 1.0 --to 0.25 --start-time 10 --duration 2
Reference docs
- Read
references/filters.mdfor full option tables (setpts expressions, minterpolate modes, atempo vs asetrate, recipe gallery).
Gotchas
setptsandatempoare independent. Change video PTS without changing audio tempo and the two streams drift — 0.5x video over a 60s clip + untouched audio = 60s of video playing at half speed with only 60s of audio, so audio ends at the 50% mark (or the mux silently truncates one stream).atemporange is 0.5–100.0 per instance. For 0.25x chainatempo=0.5,atempo=0.5. For 4x chainatempo=2.0,atempo=2.0.setptsscales ALL timestamps uniformly. It cannot express "fast then slow" on its own. For variable speed, split the source into segments, speed-change each, then concat.reverseandareversebuffer the entire stream in RAM. A 10-minute 4K clip will OOM. Split first, reverse each chunk, concat chunks in reverse order.minterpolateis expensive. 1 fps render throughput is common at 1080p. Use on short clips (<30s) or pre-trim.minterpolate fps=Xis the OUTPUT fps of the synthesis stage and is independent of the subsequentsetptstime scaling. Settingfps=120does not itself slow the video —setpts=4.0*PTSafter it does.tpadcan only pad, never trim. To freeze the FIRST frame,tpad=start_mode=clone:start_duration=Non video PLUS matching audio delay (adelay=Ns:all=1) or silence pad (apad=pad_dur=Ncombined with a pre-rolled silent source).- Freezing the LAST frame needs matching audio pad. Use
apad=pad_dur=Nalongside the videotpad. Without it, audio ends early and-shortesttruncates the frozen video back. loopfilter is VIDEO only. Usealoopfor audio, or prefer-stream_loop Nat the input level when stream copy is viable.select+setpts=N/FRAME_RATE/TBis the portable idiom for dropping frames. TheN/FRAME_RATE/TBrewrites the N-th surviving frame's PTS to the correct integer tick; without it, output timestamps will be garbage.asetpts=N/SR/TBis the audio equivalent (SR = sample rate), used afteraselect.asetratechanges pitch AND speed. Useatempofor pitch-preserving speed. Useasetrateonly when you WANT the pitch shift (chipmunk, demonic slowdown).- Dropping frames via
fps=10is lossy — you throw away the intervening frames entirely. For smooth slow-down from existing frames, useminterpolateinstead. - Pixel format after
minterpolatemay need forcing. Append,format=yuv420pbefore encoding for broad MP4 compatibility. loop=size=is a MAX frame count to buffer for the loop. Set it to a value larger than your clip's frame count (9999for short clips; use2e9cap onaloopsince it counts samples).- Output duration can drift with variable-frame-rate sources. Force
-vsync cfror afps=filter early if downstream tools require constant frame rate.
Examples
Example 1: 2x lecture speed with natural-sounding audio
ffmpeg -i lecture.mp4 \
-filter_complex "[0:v]setpts=0.5*PTS[v];[0:a]atempo=2.0[a]" \
-map "[v]" -map "[a]" -c:v libx264 -crf 20 -c:a aac -b:a 128k \
lecture_2x.mp4
Duration of a 60-minute lecture → ~30 minutes. Audio remains at original pitch because atempo preserves it.
Example 2: Cinematic 0.25x slow-mo of a 5-second action shot
ffmpeg -i hit.mp4 -filter_complex \
"[0:v]minterpolate='mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1:fps=120',setpts=4.0*PTS,format=yuv420p[v];\
[0:a]atempo=0.5,atempo=0.5[a]" \
-map "[v]" -map "[a]" -c:v libx264 -crf 18 -preset slow hit_slowmo.mp4
5s input → 20s output at 120 fps with synthesized intermediate frames.
Example 3: Freeze last frame 3s for an outro
ffmpeg -i clip.mp4 \
-vf "tpad=stop_mode=clone:stop_duration=3" \
-af "apad=pad_dur=3" \
-shortest -c:v libx264 -crf 20 -c:a aac \
clip_outro.mp4
Example 4: Speed ramp 1x → 0.25x
# Split at t=10 and t=12, speed-change middle chunk, concat
ffmpeg -i in.mp4 -t 10 -c copy seg1.mp4
ffmpeg -i in.mp4 -ss 10 -to 12 -filter_complex \
"[0:v]setpts=4.0*PTS[v];[0:a]atempo=0.5,atempo=0.5[a]" \
-map "[v]" -map "[a]" seg2.mp4
ffmpeg -i in.mp4 -ss 12 -c copy seg3.mp4
printf "file 'seg1.mp4'\nfile 'seg2.mp4'\nfile 'seg3.mp4'\n" > list.txt
ffmpeg -f concat -safe 0 -i list.txt -c copy ramped.mp4
(Concat-copy requires same codec params in all three; re-encode the concat step if needed. scripts/time.py ramp automates this.)
Example 5: Timelapse, 1 frame every 10 → 30 fps output
ffmpeg -i timelapse_src.mp4 -vf "setpts=PTS/10,fps=30" -an timelapse.mp4
Troubleshooting
Error: Value X for parameter 'tempo' out of range [0.5 - 100]
Cause: atempo instance fed a factor outside its single-stage range.
Solution: Chain stages: atempo=0.5,atempo=0.5 for 0.25x; atempo=2.0,atempo=2.0 for 4x.
Output has audio but no video after setpts change
Cause: You forgot to -map "[v]" — ffmpeg picked a default video stream from the input, not the filtered one.
Solution: Explicit -map "[v]" -map "[a]" when using -filter_complex with named outputs.
minterpolate runs at 0.5 fps and never finishes
Cause: Expected — minterpolate is CPU-heavy at high resolutions.
Solution: Downscale first (scale=1280:-2) or trim the clip before interpolating. No GPU path for minterpolate; only CPU.
Reverse command killed / OOM
Cause: reverse/areverse buffer the entire stream in RAM (uncompressed frames).
Solution: Split into 5–10 second segments, reverse each, concat in reverse order of segments.
Freeze-frame on last frame produces a frozen video with early-cutting audio
Cause: tpad extends video but -shortest or missing apad caps duration at the audio end.
Solution: Add -af "apad=pad_dur=N" matching the stop_duration, and keep -shortest (or drop it).
Audio and video drift after speed change
Cause: Only one of setpts/atempo was applied, or atempo factor is not 1/K.
Solution: Verify: setpts=K*PTS pairs with atempo=1/K. Check ffprobe duration of output vs source_duration * K.
Loop filter output is only one iteration
Cause: loop=size= was smaller than the clip's frame count, so only the first size frames got looped.
Solution: Set size greater than total frame count (9999 for short clips), or prefer -stream_loop N at the input level.