DEV Community

Super Funicular
Super Funicular

Posted on

ffmpeg's segment_time Is Not a Length: Splitting a Long Recording Into Pieces Without Losing Footage

A single video file that runs all night is awkward to share, slow to scrub and fragile to copy. Cutting it into hour-long pieces without re-encoding looks like a one-line job for ffmpeg's segment muxer, and the line most people reach for is close to this one:

ffmpeg -i night.mp4 -c copy -f segment -segment_time 3600 part%02d.mp4

It runs, exits cleanly and gives you files. Whether they are the files you wanted depends on things the command does not say. I ran a scaled-down version of it, plus four variations, on one synthetic video and checked every output file. In short:

  • segment_time is not a length. With -c copy and default settings, a piece can only start on a keyframe. Asking for 60-second pieces gave 63, 63, 56, 63, 56 and 59.
  • Without -reset_timestamps 1, pieces declare the time before them. The second piece of a six-minute split held 63 seconds of picture and declared itself 126 seconds long.
  • -segment_atclocktime 1 reads the computer's clock. On a finished file it produced one piece: the whole thing.
  • -strftime 1 can silently delete footage. In the run shown, all six pieces got the same name, each overwrote the last, and 301 of 360 seconds were gone. ffmpeg exited with status 0.

Disclosure: I make an Android camera app. None of its code is used here and nothing below describes how it saves video; the scripts generate their own test file.

The test file

The scripts scale the job down: six minutes of ffmpeg's test pattern, split into pieces of a nominal 60 seconds. The keyframe interval is pinned at 7 seconds (210 frames at 30 fps, scene-cut keyframes off) so it does not divide evenly into 60, just as a real recording rarely has a keyframe on each hour mark.

#!/bin/sh
# Six minutes of synthetic video with a keyframe every 7 seconds (210 frames at 30 fps),
# then the same file split into "60-second" pieces five different ways.
set -e
ffmpeg -y -loglevel error -f lavfi -i testsrc2=size=640x360:rate=30 -t 360 \
  -c:v libx264 -preset veryfast -g 210 -keyint_min 210 -sc_threshold 0 \
  -pix_fmt yuv420p long.mp4

split() {  # $1 = folder, $2 = output name, rest = extra segment options
  dir=$1; name=$2; shift 2
  rm -rf "$dir"; mkdir "$dir"
  ffmpeg -y -loglevel error -i long.mp4 -c copy -f segment -segment_time 60 "$@" "$dir/$name"
}
split plain     part%02d.mp4
split reset     part%02d.mp4 -reset_timestamps 1
split clock     part%02d.mp4 -reset_timestamps 1 -segment_atclocktime 1
split named     'cam_%Y%m%d_%H%M%S.mp4' -reset_timestamps 1 -strftime 1
split nonkey    part%02d.mp4 -reset_timestamps 1 -break_non_keyframes 1
Enter fullscreen mode Exit fullscreen mode

Every run uses -c copy, so nothing is re-encoded. The checker reads four numbers per file with ffprobe and compares the summed length with the source.

#!/bin/sh
# For every file in a folder: length, first timestamp, the length the file declares,
# and how many video frames actually decode. Then compare the total with the source.
src=$1; dir=$2
total=0
for f in "$dir"/*.mp4; do
  len=$(ffprobe -v error -select_streams v:0 -show_entries stream=duration -of csv=p=0 "$f")
  start=$(ffprobe -v error -select_streams v:0 -show_entries stream=start_time -of csv=p=0 "$f")
  declared=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$f")
  frames=$(ffprobe -v error -count_frames -select_streams v:0 \
           -show_entries stream=nb_read_frames -of csv=p=0 "$f" 2>/dev/null)
  echo "$(basename "$f")  length=$len  starts_at=$start  declares=$declared  decoded_frames=$frames"
  total=$(echo "$total + $len" | bc)
done
echo "files=$(ls "$dir"/*.mp4 | wc -l)  sum_of_lengths=$total  source=$(ffprobe -v error \
  -select_streams v:0 -show_entries stream=duration -of csv=p=0 "$src")"
Enter fullscreen mode Exit fullscreen mode

Everything here ran on ffmpeg 4.4.2 under Ubuntu 22.04, exactly as shown.

segment_time is where a cut may start, not how long a piece is

== plain
part00.mp4  length=63.000000  starts_at=0.066016  declares=63.067000  decoded_frames=1890
part01.mp4  length=63.000000  starts_at=63.066016  declares=126.067000  decoded_frames=1890
part02.mp4  length=56.000000  starts_at=126.066016  declares=182.067000  decoded_frames=1680
part03.mp4  length=63.000000  starts_at=182.066016  declares=245.067000  decoded_frames=1890
part04.mp4  length=56.000000  starts_at=245.066016  declares=301.067000  decoded_frames=1680
part05.mp4  length=59.000000  starts_at=301.066016  declares=360.067000  decoded_frames=1770
files=6  sum_of_lengths=360.000000  source=360.000000
== reset
part00.mp4  length=63.000000  starts_at=0.066016  declares=63.067000  decoded_frames=1890
part01.mp4  length=63.000000  starts_at=0.000000  declares=63.000000  decoded_frames=1890
part02.mp4  length=56.000000  starts_at=0.000000  declares=56.000000  decoded_frames=1680
part03.mp4  length=63.000000  starts_at=0.000000  declares=63.000000  decoded_frames=1890
part04.mp4  length=56.000000  starts_at=0.000000  declares=56.000000  decoded_frames=1680
part05.mp4  length=59.000000  starts_at=0.000000  declares=59.000000  decoded_frames=1770
files=6  sum_of_lengths=360.000000  source=360.000000
Enter fullscreen mode Exit fullscreen mode

The plain split's lengths add up to exactly 360, so nothing is lost; the cuts just land late. With keyframes at 0, 7, 14 and so on, the first keyframe at or after 60 is 63, after 120 it is 126, then 182, 245 and 301. Those are exactly where the pieces start. The marks count from the start of the file, not from the previous cut, which is why the lengths alternate.

The build's help reads like a length: segment_time is "set segment duration (default 2)". But with a stream copy a piece can only begin on a keyframe, because the frames between keyframes are stored as differences from earlier pictures.

With a keyframe every couple of seconds, hour pieces overrun by a couple of seconds at most. A longer interval means a longer overrun. I do not know what interval any particular phone or app uses, so measure your own file (script below).

Without reset_timestamps, a piece declares the time before it

Look at the declares column in the plain run. The second piece holds 63 seconds of picture (1890 frames at 30 fps) and declares 126.067 seconds. The last one declares 360.067. Each piece keeps the original timestamps, so part01.mp4 starts at 63.07 seconds. Walking its MP4 boxes shows how the gap is filled: the edit list opens with an empty edit 63.066 seconds long, an instruction to present nothing for that stretch.

I have not tested what players do with that, but the file describes itself as two minutes and six seconds long. -reset_timestamps 1, described in the help as "reset timestamps at the beginning of each segment (default false)", removes the gap. Every piece after the first starts at 0 and declares its real length.

atclocktime and strftime read the computer's clock, not the footage

== clock
part00.mp4  length=360.000000  starts_at=0.066016  declares=360.067000  decoded_frames=10800
files=1  sum_of_lengths=360.000000  source=360.000000
== named
cam_20261015_203626.mp4  length=59.000000  starts_at=0.000000  declares=59.000000  decoded_frames=1770
files=1  sum_of_lengths=59.000000  source=360.000000
Enter fullscreen mode Exit fullscreen mode

-segment_atclocktime 1 is described as "set segment to be cut at clocktime (default false)". The clock is the one on the machine running ffmpeg, at the moment it runs. That suits live capture that should start a new file every hour. On a finished file, the whole job completed inside one clock interval, so it produced one file of 360 seconds. Either way the cut points record when you ran ffmpeg, not when anything was filmed.

-strftime 1 is described as "set filename expansion with strftime at segment creation". That yields names like cam_20261015_203626.mp4, which looks like a recording time. It is the moment ffmpeg created the piece, in the local time zone of the machine (here a sandbox set four hours behind UTC). A stream copy is fast, so all six pieces were created within the same second and got the same name; verbose logging shows ffmpeg opening that one name six times. The folder ends up with one file holding the final 59 seconds. At -loglevel error it printed nothing, and its exit status was 0. Rerunning the same line gave one file twice and two files once, when the pieces happened to straddle a second boundary. How much survives depends on timing, which makes it harder to catch, not easier.

That last result is the one worth carrying away: a script that only checks for errors will report success. If you want clock-style names, keep a counter in the name as well, or rename the pieces afterwards from information you trust.

break_non_keyframes buys exact lengths with broken starts

The last line of the script uses -break_non_keyframes 1 ("allow breaking segments on non-keyframes"). Run through the checker, its lengths come out within three frames of 60 seconds, but each piece now starts on frames that depend on pictures in the previous file. The decoder logs missing-reference errors and drops frames until the next keyframe. The second piece decodes 1707 frames from 59.9 seconds, 90 short: the 3 seconds between the mark and the keyframe at 63. Across the six full pieces, 10275 frames decode out of the source's 10800: 525 short, 17.5 seconds of picture. A seventh file one frame long appears too.

Two ways to get pieces of a known length

First, find out where the keyframes actually are.

#!/bin/sh
# Print the time of every keyframe in the first video stream, then the gaps between them.
ffprobe -v error -select_streams v:0 -show_entries packet=pts_time,flags -of csv=p=0 "$1" |
  awk -F, '$2 ~ /K/ { t = $1 + 0; printf "%s ", t; if (n++) gaps[t - prev]++; prev = t }
           END { print ""; for (g in gaps) print "gap " g " s: " gaps[g] " times" }'
Enter fullscreen mode Exit fullscreen mode
0 7 14 21 28 35 42 49 56 63 70 77 84 91 98 105 112 119 126 133 140 147 154 161 168 175 182 189 196 203 210 217 224 231 238 245 252 259 266 273 280 287 294 301 308 315 322 329 336 343 350 357 
gap 7 s: 51 times
Enter fullscreen mode Exit fullscreen mode

Here every gap is 7 seconds, 51 times over. A real recording may show one steady value or a spread. Either way, the longest gap is the most a piece can overrun.

With a steady interval, the free fix is to ask for a multiple of it: with a 7-second interval, 63-second pieces cut exactly on keyframes, and with a 2-second interval, 3600 already is a multiple. The other fix is to re-encode and put a keyframe on every mark with -force_key_frames, which costs a full encode and a generation of quality loss.

#!/bin/sh
# Two ways to get pieces of a known length. 1: ask for a multiple of the keyframe gap.
# 2: re-encode and place a keyframe exactly on every 60-second mark.
set -e
rm -rf multiple forced; mkdir multiple forced
ffmpeg -y -loglevel error -i long.mp4 -c copy -f segment -segment_time 63 \
  -reset_timestamps 1 multiple/part%02d.mp4
ffmpeg -y -loglevel error -i long.mp4 -c:v libx264 -preset veryfast \
  -force_key_frames "expr:gte(t,n_forced*60)" -f segment -segment_time 60 \
  -reset_timestamps 1 forced/part%02d.mp4
Enter fullscreen mode Exit fullscreen mode
== multiple
part00.mp4  length=63.000000  starts_at=0.066016  declares=63.067000  decoded_frames=1890
part01.mp4  length=63.000000  starts_at=0.000000  declares=63.000000  decoded_frames=1890
part02.mp4  length=63.000000  starts_at=0.000000  declares=63.000000  decoded_frames=1890
part03.mp4  length=63.000000  starts_at=0.000000  declares=63.000000  decoded_frames=1890
part04.mp4  length=63.000000  starts_at=0.000000  declares=63.000000  decoded_frames=1890
part05.mp4  length=45.000000  starts_at=0.000000  declares=45.000000  decoded_frames=1350
files=6  sum_of_lengths=360.000000  source=360.000000
== forced
part00.mp4  length=60.000000  starts_at=0.066016  declares=60.067000  decoded_frames=1800
part01.mp4  length=60.000000  starts_at=0.000000  declares=60.000000  decoded_frames=1800
part02.mp4  length=60.000000  starts_at=0.000000  declares=60.000000  decoded_frames=1800
part03.mp4  length=60.000000  starts_at=0.000000  declares=60.000000  decoded_frames=1800
part04.mp4  length=60.000000  starts_at=0.000000  declares=60.000000  decoded_frames=1800
part05.mp4  length=60.000000  starts_at=0.000000  declares=60.000000  decoded_frames=1800
files=6  sum_of_lengths=360.000000  source=360.000000
Enter fullscreen mode Exit fullscreen mode

Both give known lengths that add up to the source and decode every frame. The script took about 21 seconds, nearly all of it the re-encode of a simple test pattern; a real night of video takes far longer, and deserves a better quality setting than the defaults.

Limits of this test

  • Audio. The test file is video only.
  • Players. I report what the files declare, not how any player shows them.
  • Irregular keyframes. An encoder that adds keyframes at scene changes gives uneven pieces even with a multiple of its usual interval.
  • Other builds. Descriptions and defaults come from the 4.4.2 help output; check ffmpeg -h muxer=segment on yours.

Before trusting a split

  1. List the keyframes and note the longest gap.
  2. Pick a segment_time that is a multiple of a steady gap, or accept overruns up to the longest one.
  3. Add -reset_timestamps 1.
  4. Do not let a clock alone name the files.
  5. Count the output files and sum their lengths against the source. That check is what caught the strftime loss. The exit code did not.

Try it: Background Camera RemoteStream on Google Play -- record with the screen off and keep the footage on the phone. Setup notes and other write-ups are at superfunicular.com.

Sources. Option descriptions are quoted verbatim from the output of ffmpeg -h muxer=segment on ffmpeg 4.4.2; the FFmpeg formats documentation covers the segment muxer in more detail. Test results come from the scripts above, run as shown. A clean exit tells you ffmpeg finished, and only a count of the files tells you what it kept.

Top comments (0)