Imported from ahmedhassan456/manim-skills (
skills/video-post/SKILL.md). Install upstream withnpx skills add ahmedhassan456/manim-skills --skill video-post. Copyright stays with the author.
Video post-production with ffmpeg
Manim (and most render tools) produce one file per scene. Turning those into a deliverable is a separate job: join, trim, add audio, compress, export. That is what this skill covers.
This matters for Manim specifically because the recommended practice is to split a long video into
several Scene classes rendered separately — which leaves you holding a directory of clips and no
way to combine them.
Golden rule: stream copy when you can
-c copy # remux only: no quality loss, near-instant
Every re-encode loses quality and takes real time. Only re-encode when you actually change pixels
(scaling, crossfades, overlays, filters) or when the inputs genuinely differ. Joining ten clips
that all came out of the same manim -qh run needs no re-encode.
Workflow to follow
- Inspect before you act. Run
ffprobeon every input (see below). Mismatched codec, resolution, frame rate, pixel format or audio layout is the cause of almost every concat failure, and you cannot see it by looking at the files. - Choose the join method from what you learned: concat demuxer if the inputs match, concat filter if they do not. Guessing here wastes a long encode.
- Work at low resolution while iterating if you are building a filter chain. Get the command
right on a 10-second slice (
-t 10) before running it on the full film. - Verify the output, don't assume it. Check duration, stream count and frame count with
ffprobeafterwards — a command can exit 0 and still produce a 0-frame file or drop the audio. Report the real numbers. - Never overwrite your only render. Write to a new filename.
-ysilently clobbers.
Inspecting
# One-line summary
ffprobe -hide_banner input.mp4
# The fields that decide whether concat will work, machine-readable
ffprobe -v error -select_streams v:0 \
-show_entries stream=codec_name,width,height,r_frame_rate,pix_fmt,time_base \
-of default=noprint_wrappers=1 input.mp4
# Duration alone
ffprobe -v error -show_entries format=duration -of csv=p=0 input.mp4
# Real frame count (decodes; slow but exact)
ffprobe -v error -count_frames -select_streams v:0 \
-show_entries stream=nb_read_frames -of csv=p=0 input.mp4
# Does it even have an audio stream?
ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 input.mp4
An empty result from that last command means no audio — important, because concatenating a clip with audio and one without will desync or drop sound.
Joining clips
Matching inputs → concat demuxer (lossless, fast)
Use when every input has the same codec, resolution, frame rate and pixel format — which is the normal case for scenes rendered by one tool at one quality setting.
# Build the list file. Paths are relative to the LIST FILE, not the shell.
printf "file '%s'\n" media/videos/scene/1080p60/*.mp4 > clips.txt
ffmpeg -f concat -safe 0 -i clips.txt -c copy out.mp4
-safe 0is required for absolute paths or paths containing unusual characters.- Single quotes in a filename must be escaped as
'\''inside the list file. - Order is the order of lines. Shell globs sort lexically, so
scene10sorts beforescene2— zero-pad your scene names (part01,part02) or write the list by hand.
Mismatched inputs → concat filter (re-encodes)
ffmpeg -i a.mp4 -i b.mp4 -i c.mp4 \
-filter_complex "[0:v][1:v][2:v]concat=n=3:v=1:a=0[v]" \
-map "[v]" -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p out.mp4
n= must equal the number of inputs. Set a=1 and include [0:a][1:a]… only if every input
has audio. If resolutions differ, scale first:
-filter_complex "[0:v]scale=1920:1080,setsar=1[v0];[1:v]scale=1920:1080,setsar=1[v1];[v0][v1]concat=n=2:v=1:a=0[v]"
setsar=1 prevents a stretched picture when inputs have different sample aspect ratios.
Crossfading between scenes
# 0.5s crossfade; offset = (duration of first clip) - (fade duration)
ffmpeg -i a.mp4 -i b.mp4 \
-filter_complex "[0:v][1:v]xfade=transition=fade:duration=0.5:offset=9.5[v]" \
-map "[v]" -c:v libx264 -crf 18 -pix_fmt yuv420p out.mp4
offset is measured from the start of the whole output, and getting it wrong is the usual bug:
too large and the fade never happens, too small and you cut the first clip short. Compute it from
the real duration rather than assuming. Chaining more than two clips means chaining xfade nodes
and accumulating offsets — for many clips, prefer fading to black at the scene boundaries inside
Manim instead.
Other useful transition= values: fadeblack, wipeleft, slideup, dissolve, circleopen.
Trimming
# Fast, lossless — but snaps to the nearest keyframe before the cut
ffmpeg -ss 00:00:05 -to 00:00:20 -i in.mp4 -c copy out.mp4
# Frame-accurate; re-encodes
ffmpeg -i in.mp4 -ss 00:00:05 -to 00:00:20 -c:v libx264 -crf 18 out.mp4
-ss before -i seeks fast by jumping in the container; after -i decodes from the start
and is exact but slow. With -c copy you always get keyframe snapping, so if the cut must land on
an exact frame, you must re-encode.
Audio
# Replace audio entirely, end at whichever stream is shorter
ffmpeg -i video.mp4 -i narration.wav -map 0:v -map 1:a -c:v copy -c:a aac -b:a 192k -shortest out.mp4
# Mix narration over music, ducking the music to 25%
ffmpeg -i video.mp4 -i narration.wav -i music.mp3 \
-filter_complex "[2:a]volume=0.25[m];[1:a][m]amix=inputs=2:duration=first:dropout_transition=0[a]" \
-map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k out.mp4
# Broadcast-standard loudness (EBU R128). -14 LUFS is the YouTube target.
ffmpeg -i in.mp4 -af loudnorm=I=-14:TP=-1.5:LRA=11 -c:v copy -c:a aac -b:a 192k out.mp4
# Silent audio track (some platforms reject video-only files)
ffmpeg -i in.mp4 -f lavfi -i anullsrc=channel_layout=stereo:sample_rate=48000 \
-map 0:v -map 1:a -c:v copy -c:a aac -shortest out.mp4
For accurate loudness, loudnorm should be run twice — once with print_format=json to measure,
then again passing the measured values back in. The single-pass form above is fine for most work.
Export presets
# YouTube 1080p — the safe, universally playable settings
ffmpeg -i in.mp4 -c:v libx264 -crf 18 -preset slow -pix_fmt yuv420p \
-c:a aac -b:a 192k -movflags +faststart out.mp4
# Vertical 1080x1920 (Shorts / Reels / TikTok): fit, don't crop, pad with the background colour
ffmpeg -i in.mp4 -vf "scale=1080:1920:force_original_aspect_ratio=decrease,\
pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=#0f0f17,setsar=1" \
-c:v libx264 -crf 20 -preset slow -pix_fmt yuv420p out_vertical.mp4
# Square 1080x1080
ffmpeg -i in.mp4 -vf "scale=1080:1080:force_original_aspect_ratio=decrease,\
pad=1080:1080:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1" -c:v libx264 -crf 20 out_square.mp4
# WebM / VP9 with alpha (transparent overlays)
ffmpeg -i in.mov -c:v libvpx-vp9 -pix_fmt yuva420p -crf 30 -b:v 0 out.webm
# High-quality GIF (two-pass palette — never skip this, the default palette looks terrible)
ffmpeg -i in.mp4 -vf "fps=15,scale=720:-1:flags=lanczos,palettegen=stats_mode=diff" -y palette.png
ffmpeg -i in.mp4 -i palette.png \
-lavfi "fps=15,scale=720:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=3" out.gif
Two flags that matter more than they look:
-pix_fmt yuv420p— without it,libx264may pickyuv444p, which QuickTime, Safari and most social platforms refuse to play. Always set it for anything you will share.-movflags +faststart— moves the index to the front so the file streams before it finishes downloading. Costs one extra pass over the file; always worth it for web.
-crf is the quality dial for x264: 0 is lossless, 18 is visually lossless, 23 is the default, 28
is visibly soft. Lower = bigger file. -preset (ultrafast…veryslow) trades encode time for
compression efficiency at the same quality; it does not change quality.
Stills
# Frame at 12.5s as a thumbnail
ffmpeg -ss 00:00:12.5 -i in.mp4 -frames:v 1 -q:v 2 thumb.jpg
# Let ffmpeg pick a representative frame from the first 500
ffmpeg -i in.mp4 -vf "thumbnail=500" -frames:v 1 thumb.png
# Every 5 seconds, numbered
ffmpeg -i in.mp4 -vf fps=1/5 frames/%03d.png
Speed, loops, stills-to-video
ffmpeg -i in.mp4 -vf "setpts=0.5*PTS" -an fast.mp4 # 2x faster (video only)
ffmpeg -i in.mp4 -vf "setpts=2.0*PTS" -an slow.mp4 # 2x slower
ffmpeg -stream_loop 3 -i in.mp4 -c copy looped.mp4 # play 4 times total
ffmpeg -loop 1 -i still.png -t 5 -c:v libx264 -pix_fmt yuv420p -vf fps=60 still5s.mp4
To change audio speed as well, use atempo (valid range 0.5–2.0 per instance; chain them for
more): -filter:a "atempo=2.0".
Troubleshooting
| Symptom | Cause |
|---|---|
| Concat output plays only the first clip | Inputs differ; the demuxer can't switch parameters. Use the concat filter. |
Unsafe file name |
Add -safe 0. |
| Audio drifts out of sync after concat | Variable frame rate input, or one clip lacks audio. Re-encode with -r fixed and give every clip an audio track. |
| Output won't play in QuickTime/Safari | Missing -pix_fmt yuv420p. |
| Video is stretched after scaling | Add setsar=1. |
height not divisible by 2 |
x264 needs even dimensions: scale=1280:-2 (not -1). |
| Trim starts early / late | -c copy snapped to a keyframe. Re-encode for exact cuts. |
| GIF looks banded and muddy | You skipped palettegen/paletteuse. |
| Output is 0 bytes or 0 frames | Filter graph produced no output — check the -map labels match the filter's output names. |
loudnorm → Input contains (near) NaN/+-Inf |
The audio track is pure digital silence (e.g. from anullsrc). loudnorm cannot measure it. Skip normalisation on silent tracks. |
Verifying your work
Always finish by confirming the result rather than trusting exit code 0:
ffprobe -v error -show_entries format=duration,size -show_entries stream=codec_type \
-of default=noprint_wrappers=1 out.mp4
Expect the duration to be the sum of the inputs (minus overlap for crossfades), and expect both a
video and an audio stream if you muxed audio. If either is missing, the command did not do what
you think it did.