The shape of the pipeline
- Probe — read duration, resolution, frame rate, and audio layout so nothing downstream guesses.
- Detect — find candidate boundaries with scene-change detection and candidate highlights with audio energy.
- Score and select — rank the candidate segments and pick the ones that fit the target runtime.
- Cut — extract the selected ranges, ideally without re-encoding.
- Assemble — concatenate, convert aspect ratio, normalise loudness, encode once at the end.
The discipline that keeps this fast is doing all the analysis on cheap passes and encoding exactly once, at the end. Every intermediate re-encode costs time and a generation of quality.
Probe first
ffprobe -v error -show_entries \
format=duration:stream=width,height,r_frame_rate,codec_type \
-of json input.mp4
Hardcoding 1920x1080 at 30fps is how a pipeline breaks on the first phone clip someone hands you. Read it, branch on it.
Scene-change detection
ffmpeg's select filter exposes a per-frame scene score between 0 and 1, measuring how different a frame is from its predecessor. Thresholding that score gives you cut candidates.
ffmpeg -i input.mp4 \
-filter:v "select='gt(scene,0.4)',showinfo" \
-f null - 2>&1 | grep showinfo
# print timestamps only, machine-readable
ffprobe -show_frames -of csv=p=0 \
-f lavfi "movie=input.mp4,select=gt(scene\,0.4)" \
-show_entries frame=pkt_pts_time
- 0.3 — sensitive. Fires on camera movement and lighting changes as well as real cuts.
- 0.4 — a reasonable default for mixed footage.
- 0.5 and above — hard cuts only. Misses dissolves and slow transitions entirely.
- Single continuous shots — a locked-off camera at an event produces almost no scene changes, so this detector contributes nothing and audio has to carry the selection.
Calibrating the threshold by footage type
0.4 is a starting point, not a constant. The right threshold depends on how much of the frame moves on a normal cut versus a normal pan, and that varies enormously by source.
| Footage type | Reasonable threshold | Why |
| Multi-camera edited source | 0.5 – 0.6 | Hard cuts are frequent and unambiguous; a low threshold fires on every pan and zoom instead |
| Talking head, single camera | 0.35 – 0.45 | Few genuine scene changes; this range still catches cutaways and reframes |
| Live event, locked-off wide shot | 0.5+, or skip it | Almost nothing changes visually — audio energy has to carry the whole selection |
| Sports, fast handheld | 0.25 – 0.35 | Motion and camera shake trigger false positives at higher thresholds, so tolerate more noise here and filter it downstream |
| Screen recording, tutorial | 0.6 – 0.7 | Long static frames punctuated by real transitions; a high threshold avoids firing on cursor movement |
Finding highlights with audio
For unedited footage — an event, a talk, a match — the visual track is often uniform and the interesting moments are audible: a cheer, a laugh, a sudden loudness jump. Audio energy is a crude but effective highlight detector, and it is cheap to compute.
# per-window loudness, one line per measurement
ffmpeg -i input.mp4 -af ebur128=framelog=verbose -f null - 2>&1
# volume statistics across the whole file
ffmpeg -i input.mp4 -af volumedetect -f null - 2>&1
# detect silence, then treat the gaps between silences as segments
ffmpeg -i input.mp4 -af silencedetect=noise=-30dB:d=0.6 -f null - 2>&1
Two approaches that work in practice. Peak-relative: compute short-term loudness over one-second windows, then keep windows more than roughly 6 dB above the file's median — those are your spikes. Silence-inverted: use silencedetect to find quiet gaps, treat the audible stretches between them as natural segments, and rank those segments by mean loudness.
Then pad each pick: a highlight starts before the spike. Two to three seconds of lead-in and one to two of tail turns a jarring fragment into a watchable clip.
Scoring a candidate segment: audio energy plus scene density
Once both detectors have run, every candidate window gets one number so they can be ranked against each other and against the target runtime. A simple weighted sum works well in practice, because it lets you decide up front whether cuts or loudness should dominate the ranking for this footage.
score = w_audio × normalised_audio_energy + w_scene × normalised_scene_density
Worked, two 10-second candidates, w_audio = 0.6, w_scene = 0.4:
Segment A: peak loudness 6 dB over median → normalised_audio = 0.80
1 scene change in the window → density 0.10/s → normalised_scene = 0.25
score = 0.6(0.80) + 0.4(0.25) = 0.48 + 0.10 = 0.58
Segment B: peak loudness 2 dB over median → normalised_audio = 0.30
3 scene changes in the window → density 0.30/s → normalised_scene = 0.75
score = 0.6(0.30) + 0.4(0.75) = 0.18 + 0.30 = 0.48
Segment A wins despite fewer cuts, because w_audio was set high on purpose:
for event footage, loudness is signal and rapid scene changes are usually camera shake, not content.
The weights are the actual editorial decision, made explicit instead of buried in code. Event and talk footage should weight audio heavily, as above. A multi-camera edited source, where cuts were placed deliberately by a human, should weight scene density higher instead — the cut points themselves are already the signal.
Cutting without re-encoding
Stream copy is enormously faster than re-encoding, but it can only cut at keyframes. Put -ss before -i and ffmpeg seeks quickly to the nearest keyframe; put it after, and it decodes up to the exact frame, which is accurate and slow.
# fast, keyframe-aligned, no quality loss
ffmpeg -ss 00:01:12 -i input.mp4 -t 8 -c copy clip01.mp4
# frame-accurate, re-encodes
ffmpeg -i input.mp4 -ss 00:01:12.400 -t 8 \
-c:v libx264 -crf 18 -preset veryfast -c:a aac clip01.mp4
The pragmatic compromise: cut with stream copy when the keyframe interval is small enough that a fraction of a second of drift does not matter, and re-encode only the clips where the cut point must be exact. Or force a short keyframe interval when you control the source encode.
Keyframe interval, GOP, and a worked drift example
A GOP (group of pictures) is the span between one keyframe and the next. Frames inside it are stored as differences from nearby frames, so a decoder — and a stream-copy cut — can only start cleanly at a keyframe. A long GOP means fewer keyframes, a smaller file, and worse worst-case drift when you cut with -c copy.
keyframe interval = 250 frames @ 30fps = 8.33s between keyframes
desired cut point = 12.40s
nearest keyframe = 8.33s (the one before it — copy cannot cut after it)
stream-copy drift = 12.40s − 8.33s = 4.07s too early
# force a 1-second GOP so the worst case shrinks to well under a second
ffmpeg -i input.mp4 -c:v libx264 -g 30 -keyint_min 30 -sc_threshold 0 \
-c:a aac source_tight_gop.mp4
# after -g 30 (1s GOP at 30fps):
nearest keyframe = 12.00s
stream-copy drift = 12.40s − 12.00s = 0.40s, worst case ≈ 1.0s
The trade is file size: a 1-second GOP writes roughly 8 times as many full I-frames as the 250-frame default, which grows the source file noticeably. Force a tight GOP only on footage you know will be cut heavily and stream-copied, not as a universal default.
Concatenating the picks
The concat demuxer joins files without re-encoding, but only when every input shares the same codec, resolution, frame rate, and audio parameters. Mixed sources need the concat filter, which re-encodes.
# clips.txt
file 'clip01.mp4'
file 'clip02.mp4'
file 'clip03.mp4'
ffmpeg -f concat -safe 0 -i clips.txt -c copy reel.mp4
If concatenated output plays the first clip and then freezes or desyncs, the inputs did not actually match. Normalise every clip to identical codec, resolution, frame rate, sample rate, and channel layout first, or switch to the concat filter and accept one re-encode.
Converting to 9:16 vertical
Vertical delivery from horizontal footage is a framing decision, not a resize. Three options, in ascending order of effort:
| Approach | How it looks | When to use it |
| Blurred-background pad | Original centred, blurred enlarged copy behind it | Fast, safe default for talking-head and wide shots |
| Centre crop | Fills the frame, cuts off both sides | Subject reliably centred; loses anything at the edges |
| Tracked crop | Crop window follows the subject | Best result, needs detection per frame and real work |
# blurred-background 1080x1920
ffmpeg -i reel.mp4 -filter_complex \
"[0:v]scale=1080:-2,boxblur=20:2,crop=1080:1920[bg];\
[0:v]scale=1080:-2[fg];\
[bg][fg]overlay=(W-w)/2:(H-h)/2" \
-c:a copy vertical.mp4
# centre crop to 9:16
ffmpeg -i reel.mp4 -vf \
"crop=ih*9/16:ih,scale=1080:1920" -c:a copy vertical.mp4
Finish with loudness normalisation so clips cut from different parts of a recording do not jump in volume. The two-pass loudnorm filter targeting around −14 LUFS is the usual choice for social platforms.
ffmpeg -i vertical.mp4 -af loudnorm=I=-14:TP=-1.5:LRA=11 \
-c:v copy final.mp4
Hardware acceleration, compared
The final encode is the one step in the pipeline that cannot be avoided, and it is the slowest one. Hardware encoders trade some quality-per-bit for a large speed win, which is usually the right trade while iterating and sometimes the wrong one for final delivery.
| Option | Platform | Speed vs software x264 | Quality per bit | Notes |
| VideoToolbox | macOS, including Apple Silicon | Roughly 3–8x faster | Slightly behind x264 at equal bitrate | No setup on a Mac; the sensible default while iterating |
| NVENC | NVIDIA GPU | Roughly 5–10x faster | Close to x264's medium preset on recent generations | Needs an NVIDIA GPU and current drivers |
| Quick Sync (QSV) | Intel CPU with integrated graphics | Roughly 3–6x faster | Historically behind x264, the gap has closed | Free hardware most Intel laptops already have |
| VA-API | Linux, various GPUs | Varies | Varies by driver | Least consistent of the options, driver-dependent |
| Software libx264 | Any CPU | Baseline | Best quality per bit — the reference everything else is measured against | Slowest; use it for the final export when quality matters most |
Encode settings: CRF, bitrate, and presets
Three settings do almost all the work of an x264 or x265 encode, and confusing them is the most common way to waste render time or ship a worse-looking file than necessary.
| Setting | What it controls | Trade-off |
| CRF (constant rate factor) | Targets a visual quality directly; the encoder spends whatever bitrate that quality needs, scene by scene | The right default for most delivery. Lower CRF is higher quality and a bigger file — 18 is close to lossless, 23 is a reasonable default, 28 is visibly compressed |
| Target bitrate, typically 2-pass | Targets a file size or bandwidth ceiling directly | Use only when a platform enforces a hard cap. Quality varies scene to scene to hit the number instead of the number varying to hit quality |
| Preset (ultrafast … veryslow) | How much effort the encoder spends finding efficient encodings, at a fixed CRF | Slower presets produce a smaller file at the same visual quality, for more CPU time. veryfast is fine for iterating; use slow or veryslow only on the final export |
# iterating on cuts — fast, good enough to judge the edit
ffmpeg -i joined.mp4 -c:v libx264 -crf 23 -preset veryfast -c:a aac out_draft.mp4
# final delivery — slower, smaller file at equal visual quality
ffmpeg -i joined.mp4 -c:v libx264 -crf 20 -preset slow -c:a aac -b:a 192k out_final.mp4
Putting it together: a worked end-to-end script
Every piece above is one stage. Chained, with the analysis kept cheap and the encode done exactly once at the end, the pipeline looks like this.
#!/usr/bin/env bash
set -euo pipefail
SRC="$1" # input.mp4
OUT="reel.mp4"
WORKDIR=$(mktemp -d)
# 1. probe
DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$SRC")
# 2. detect scene cuts (timestamps only)
ffprobe -show_frames -of csv=p=0 \
-f lavfi "movie=$SRC,select=gt(scene\,0.4)" \
-show_entries frame=pkt_pts_time > "$WORKDIR/scenes.txt"
# 3. detect audio spikes (silencedetect, inverted downstream)
ffmpeg -i "$SRC" -af silencedetect=noise=-30dB:d=0.6 -f null - 2> "$WORKDIR/silence.log"
# 4. score and select candidate segments against a target runtime
python3 score_segments.py "$WORKDIR/scenes.txt" "$WORKDIR/silence.log" \
--target-duration 90 > "$WORKDIR/picks.txt"
# 5. cut each pick, stream copy where possible
i=0
while read -r start end; do
i=$((i + 1))
ffmpeg -ss "$start" -i "$SRC" -t "$(echo "$end - $start" | bc)" \
-c copy "$WORKDIR/clip$i.mp4"
echo "file '$WORKDIR/clip$i.mp4'" >> "$WORKDIR/clips.txt"
done < "$WORKDIR/picks.txt"
# 6. assemble: concat, then one encode at the end
ffmpeg -f concat -safe 0 -i "$WORKDIR/clips.txt" -c copy "$WORKDIR/joined.mp4"
ffmpeg -i "$WORKDIR/joined.mp4" \
-af loudnorm=I=-14:TP=-1.5:LRA=11 \
-c:v libx264 -crf 20 -preset slow -c:a aac "$OUT"
The only genuinely custom piece is score_segments.py — the weighted-sum ranking from earlier, reading the two detector outputs and writing start and end timestamps for whichever candidates best fit the target runtime. Everything else is ffmpeg and ffprobe, called the same way a human would from a terminal.
Troubleshooting
| Symptom | Likely cause | Fix |
| Output plays the first clip then freezes or goes black | concat demuxer given inputs with mismatched codec, resolution, or audio parameters | Normalise every clip to identical parameters first, or switch to the concat filter and accept one re-encode |
| A -c copy cut lands noticeably off target | Stream copy snapped to the nearest keyframe | Re-encode just that clip, or shorten the source's GOP so the worst-case drift is smaller |
| Scene detection fires constantly on a locked-off shot | Threshold too low for low-motion footage; it is picking up compression noise | Raise the threshold per the calibration table above, or drop scene detection and rely on audio energy instead |
| Vertical crop cuts off the subject | Centre crop assumes the subject is centred in the original frame | Switch to the blurred-background pad, or implement a tracked crop |
| Loudness jumps between clips in the final reel | No normalisation pass, or loudnorm applied before concatenation instead of after | Apply two-pass loudnorm as the last step, on the assembled file, not on individual clips |
| Hardware-encoded output looks visibly worse than software at the same bitrate | Hardware encoders trade quality for speed at equal bitrate | Raise the target quality or bitrate for hardware encodes, or fall back to libx264 for the final delivery pass |
| ffmpeg reports an unsafe file name on concat | -safe 0 omitted, or clips.txt uses paths outside its own directory | Add -safe 0, or use absolute paths consistent with where clips.txt lives |
Why local beats an API for this
- No upload. A 4 GB event recording takes longer to upload on a home connection than to process locally. The transfer is the bottleneck, not the compute.
- No per-minute cost. Video APIs price per minute processed. Iterating on a threshold means reprocessing the same footage twenty times — free locally, expensive per call.
- No rights question. Footage of identifiable people at an event does not go to a third party at all, which removes both a consent conversation and a terms-of-service question about training use.
- Deterministic and debuggable. The same command produces the same output, and every intermediate is a file you can open.
- No dependency on someone else's uptime or pricing page. The pipeline works the same in three years.
The honest counterpoint: an API gives you transcription, face tracking, and semantic highlight selection that a local ffmpeg pipeline does not have without extra models. If your selection logic genuinely needs to understand speech, run a local transcription model — the same local-over-API case, made generally, is in the local LLM guide — and keep the rest of the pipeline where it is.
This is the architecture behind EventReels — probe, detect, score, cut, assemble, one encode at the end, entirely on the machine, on the same stack the rest of this site runs on. It is the same no-third-party argument made more generally in the client-side tools guide, applied here to gigabytes of footage instead of a few kilobytes of JSON, and it slots into a broader shop workflow the way the automation guide describes.
Tools referenced in this guide
- EventReels — the working auto-editing pipeline this guide describes.
- My stack — the rest of the tooling around it.
- Local LLM guide — the same local-over-API argument applied to language models.
- AI automation guide — where a pipeline like this fits into an actual workflow.