Mike Slinn
Mike Slinn

Trimming Media Files with FFmpeg

Published 2022-01-23. Last modified 2023-11-27.
Time to read: 4 minutes.

This page is part of the av_studio collection, categorized under Media, OBS Studio.

When I set up a 4K camera on location and let it roll while I play a set, I get really large video files. I also get large files when using OBS Studio; my usual setup records video from Cam Link 4K, and audio using RME TotalMix. I need to be able to trim video files so the portions before and after the good stuff are discarded.

Lots of online conversations revolve around trimming video files. Dozens of PC and Mac programs exist to do that task, mostly low quality and / or bothersome to use. Other solutions are overkill, for example Adobe Premiere Pro and DaVinci Resolve.

In this post I present a bash script that can dramatically reduce file size, while preserving quality, and properly trimming to specified time periods.

What Is FFmpeg?

FFmpeg is a universal media converter. It can read a wide variety of inputs – including live grabbing/recording devices – filter, and transcode them into a plethora of output formats.

FFmpeg reads from an arbitrary number of input “files” (which can be regular files, pipes, network streams, grabbing devices, etc.), specified by the ‑i option, and writes to an arbitrary number of output “files”, which are specified by a plain output url. Anything found on the command line which cannot be interpreted as an option is considered to be an output url.

Each input or output url can, in principle, contain any number of streams of different types (video, audio, subtitle, attachment, or data). The allowed number and/or types of streams may be limited by the container format. Selecting which streams from which inputs will go into which output is either done automatically or with the ‑map option (see the Stream selection chapter).

DJV

Need a way to figure out the start and stop times to trim a video? Djv is an excellent video player for this purpose. It is one of the very few video players than can step through a video frame-by-frame, forwards and backwards.

  • Mac, Windows, Linux
  • Allows frame-by-frame stepping
  • Displays the current time reliabily
  • F/OSS
  • High quality

Note to Programmers

For any programmers who might read this:

  • Some of the ffmpeg options this script uses are not available in older versions.
  • The most important thing to know about options that might be passed to ffmpeg is that arbitrary start and end times can be specified accurately when trimming if the video stream is re-encoded. To force a video re-encoding, simply do not specify the -vcodec copy option.
  • We discard extra (non-essential) streams from video files. See Selecting streams with the -map option.

This is the trim script:

#!/usr/bin/env ruby

require 'colorator'
require 'fileutils'
require 'optparse'
require 'time'

def help(msg=nil)
  puts "Error: #{msg}.\n".red if msg
  puts <<~END_HELP
    #{File.basename $PROGRAM_NAME} - Trim an audio or video file using ffmpeg

    Works with all formats supported by ffmpeg.
    Seeks to the nearest frame positions by re-encoding.
    Reduces file size procduced by OBS Studio by >80%.

    #{File.basename $PROGRAM_NAME} names the output file by adding '.trim' before the file extension.
    By default, does not overwrite pre-existing output files.
    Displays the trimmed file, unless the -q option is specified

    Usage:
      #{File.basename $PROGRAM_NAME} [OPTIONS] dir/file.ext start [[to|for] END]

    OPTIONS are:
      -d Enable debug output
      -f Overwrite output file if present
      -v Verbose output
      -V Do not view the trimmed file when complete.

    start and END have the format [HH:[MM:]]SS[.XXX]
    END defaults to end of audio/video file

    Examples:
      # Crop dir/file.mp4 from 15.0 seconds to the end of the video, save to dir/file.trim.mp4:
      #{File.basename $PROGRAM_NAME} dir/file.mp4 15

      # Crop dir/file.mkv from 3.25 minutes to 9 minutes, 35 seconds, save to dir/file.trim.mkv:
      #{File.basename $PROGRAM_NAME} dir/file.mkv 3:25 9:35

      # Same as the previous example, using optional 'to' syntax:
      #{File.basename $PROGRAM_NAME} dir/file.mkv 3:25 to 9:35

      # Save as the previous example, but specify the duration instead of the end time:
      #{File.basename $PROGRAM_NAME} dir/file.mkv 3:25 for 6:10

    Need a way to figure out the start and stop times to trim a video?
    DJV is an excellent video viewer https://darbyjohnston.github.io/DJV/
    - allows frame-by-frame stepping
    - displays the current time reliabily
    - F/OSS
    - Mac, Windows, Linux
    - High quality
  END_HELP
  exit 1
end

def add_times(str1, str2)
  time1 = Time.parse mk_time str1
  time2 = Time.parse mk_time str2
  m = time2.strftime("%M").to_i
  h = time2.strftime("%H").to_i
  s = time2.strftime("%S").to_i
  millis = time2.strftime("%L").to_f / 1000.0
  (time1 + (h * 60 * 60) + (m * 60) + s + millis).strftime("%H:%M:%S")
end

def time_format(elapsed_seconds)
  elapsed_time = elapsed_seconds.to_i
  hours = (elapsed_time / (60 * 60)).to_i
  minutes = ((elapsed_time - (hours * 60)) / 60).to_i
  seconds = elapsed_time - (hours * 60 * 60) - (minutes * 60)

  result = "#{minutes.to_s.rjust 2, '0'}:#{seconds.to_s.delete_suffix('.0').rjust 2, '0'}"
  result = "#{hours}:#{result}}" unless hours.zero?
  result
end

# @return time difference HH:MM:SS, ignoring millis
def duration(str1, str2)
  time1 = Time.parse mk_time str1
  time2 = Time.parse mk_time str2

  time_format(time2 - time1)
end

def mk_time(str)
  case str.count ':'
  when 0 then "0:0:#{str}"
  when 1 then "0:#{str}"
  when 2 then str
  else raise StandardError, "Error: #{str} is not a valid time"
  end
end

def to_seconds(str)
  array = str.split(':').map(&:to_i).reverse
  case array.length
  when 1 then str.to_i
  when 2 then array[0] + (array[1] * 60)
  when 3 then array[0] + (array[1] * 60) + (array[2] * 60 * 60)
  else raise StandardError, "Error: #{str} is not a valid time"
  end
end

overwrite = '-n'
quiet = ['-hide_banner', '-loglevel', 'error', '-nostats']
view = true
OptionParser.new do |opts|
  opts.banner = "Usage: #{$PROGRAM_NAME} [options]"

  opts.on('-f', '--[no-]overwrite', 'Overwrite any previous output') do |f|
    overwrite = f ? '-y' : '-n'
  end
  opts.on('-v', '--[no-]verbose', 'Run verbosely') do |v|
    quiet = [] if v
  end
  opts.on('-h', '', 'Run verbosely') do |_|
    help
  end
  opts.on('-V', '--[no-]view', 'View ffmpeg output') do |v|
    view = false if v
  end
end.parse!

help 'Please specify the name of the video file to trim' unless ARGV[0]
fname = ARGV[0]
unless File.exist? fname
  puts "Error: '#{FileUtils.pwd}/#{fname}' does not exist.".red
  exit 1
end
original_filename = File.basename fname, '.*'
ext = File.extname fname
copy_filename = "#{File.dirname fname}/#{original_filename}.trim#{ext}"

help 'Please specify the time to start trimming the video file from' unless ARGV[1]
start = ARGV[1]

interval = ['-ss', start]
msg_end = ''
to_index = 2
if ARGV[to_index]
  to_index += 1 if ARGV[to_index] == 'to'
  if ARGV[to_index] == 'for'
    to = time_format ARGV[to_index + 1]
    help 'No duration was specified' unless to
    interval += ['-t', to]
    time_end = add_times start, to
    msg_end = " for a duration of #{to} (until #{time_end})"
  else
    to = time_format(to_seconds(ARGV[to_index]))
    elapsed_time = duration start, to
    interval += ['-to', to]
    msg_end = " to #{to} (duration #{elapsed_time})"
  end
  if start >= to
    puts "Error: start time (#{start}) must be before end time (#{to})"
    exit 2
  end
end

puts "Trimming '#{fname}' from #{start}#{msg_end}".cyan
command = ['ffmpeg',
           *quiet,
           '-hwaccel', 'auto',
           overwrite,
           '-i', fname,
           '-acodec', 'aac',
           *interval,
           copy_filename
          ]
# puts command.join(' ').yellow
start_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
status = system(*command)
end_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
elapsed = end_clock - start_clock
puts "Trim took #{time_format elapsed.to_i}".cyan
$stdout.flush
exit 1 unless status

# View trimmed file unless -q option was specified
if view
  # Open in Windows if running in WSL
  if File.exist? '/mnt/c/Program Files/DJV2/bin/djv.com'
    realpath = File.realpath copy_filename
    windows_path = `wslpath -m '#{realpath}'`.chomp
    spawn 'cmd.exe', '/c',
          "C:\\Program Files\\DJV2\\bin\\djv.com",
          '-full_screen',
          '-full_screen_monitor', '2',
          windows_path
  elsif `which cmd.exe`
    exec 'cmd.exe', '/C', 'start', copy_filename, "--extraintf='luaintf{intf=\"looper_custom_time\"}'"
  elsif `which xdg-open`
    # Open any file with its default Linux application with xdg-open.
    # Define default apps in ~/.local/share/applications/defaults.list,
    # which is read on every invocation.
    # See https://askubuntu.com/questions/809981/set-the-default-video-player-from-the-command-line
    exec 'xdg-open', copy_filename
  end
end

Following is a sample usage of trim, which extracts the portion of VideoFile.mkv from 00:00:25.000 to 00:02:52.000, and writes the extracted portion to a new file called VideoFile.trim.mkv:

Shell
$ trim VideoFile.mkv 25 2:52
ffmpeg version 4.4-6ubuntu5 Copyright (c) 2000-2021 the FFmpeg developers
  built with gcc 11 (Ubuntu 11.2.0-7ubuntu1)
  configuration: --prefix=/usr --extra-version=6ubuntu5 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librabbitmq --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opencl --enable-opengl --enable-sdl2 --enable-pocketsphinx --enable-librsvg --enable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-nvenc --enable-chromaprint --enable-frei0r --enable-libx264 --enable-shared
  libavutil      56. 70.100 / 56. 70.100
  libavcodec     58.134.100 / 58.134.100
  libavformat    58. 76.100 / 58. 76.100
  libavdevice    58. 13.100 / 58. 13.100
  libavfilter     7.110.100 /  7.110.100
  libswscale      5.  9.100 /  5.  9.100
  libswresample   3.  9.100 /  3.  9.100
  libpostproc    55.  9.100 / 55.  9.100
Input #0, matroska,webm, from 'VideoFile.mkv':
  Metadata:
    ENCODER         : Lavf58.29.100
  Duration: 00:02:57.93, start: 0.000000, bitrate: 8137 kb/s
  Stream #0:0: Video: h264 (High), yuv420p(tv, bt709, progressive), 1280x720 [SAR 1:1 DAR 16:9], 30 fps, 30 tbr, 1k tbn, 60 tbc (default)
    Metadata:
      DURATION        : 00:02:57.933000000
  Stream #0:1: Audio: aac (LC), 48000 Hz, stereo, fltp (default)
    Metadata:
      title           : simple_aac_recording
      DURATION        : 00:02:57.813000000
Stream mapping:
  Stream #0:0 -> #0:0 (h264 (native) -> h264 (libx264))
  Stream #0:1 -> #0:1 (copy)
Press [q] to stop, [?] for help
[libx264 @ 0x55fe8a8a6d40] using SAR=1/1
[libx264 @ 0x55fe8a8a6d40] using cpu capabilities: MMX2 SSE2Fast SSSE3 SSE4.2 AVX
[libx264 @ 0x55fe8a8a6d40] profile High, level 3.1, 4:2:0, 8-bit
[libx264 @ 0x55fe8a8a6d40] 264 - core 160 r3011 cde9a93 - H.264/MPEG-4 AVC codec - Copyleft 2003-2020 - http://www.videolan.org/x264.html - options: cabac=1 ref=3 deblock=1:0:0 analyse=0x3:0x113 me=hex subme=7 psy=1 psy_rd=1.00:0.00 mixed_ref=1 me_range=16 chroma_me=1 trellis=1 8x8dct=1 cqm=0 deadzone=21,11 fast_pskip=1 chroma_qp_offset=-2 threads=12 lookahead_threads=2 sliced_threads=0 nr=0 decimate=1 interlaced=0 bluray_compat=0 constrained_intra=0 bframes=3 b_pyramid=2 b_adapt=1 b_bias=0 direct=1 weightb=1 open_gop=0 weightp=2 keyint=250 keyint_min=25 scenecut=40 intra_refresh=0 rc_lookahead=40 rc=crf mbtree=1 crf=23.0 qcomp=0.60 qpmin=0 qpmax=69 qpstep=4 ip_ratio=1.40 aq=1:1.00
Output #0, matroska, to 'VideoFile.trim.mkv':
  Metadata:
    encoder         : Lavf58.76.100
  Stream #0:0: Video: h264 (H264 / 0x34363248), yuv420p(tv, bt709, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 30 fps, 1k tbn (default)
    Metadata:
      DURATION        : 00:02:57.933000000
      encoder         : Lavc58.134.100 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: N/A
  Stream #0:1: Audio: aac (LC) ([255][0][0][0] / 0x00FF), 48000 Hz, stereo, fltp (default)
    Metadata:
      title           : simple_aac_recording
      DURATION        : 00:02:57.813000000
frame= 4410 fps= 56 q=-1.0 Lsize=   29863kB time=00:02:26.98 bitrate=1664.3kbits/s speed=1.85x
video:26295kB audio:3489kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.266039%
[libx264 @ 0x55fe8a8a6d40] frame I:18    Avg QP:19.94  size: 91473
[libx264 @ 0x55fe8a8a6d40] frame P:1111  Avg QP:22.35  size: 14426
[libx264 @ 0x55fe8a8a6d40] frame B:3281  Avg QP:26.70  size:  2820
[libx264 @ 0x55fe8a8a6d40] consecutive B-frames:  0.8%  0.0%  0.1% 99.1%
[libx264 @ 0x55fe8a8a6d40] mb I  I16..4:  9.1% 68.8% 22.1%
[libx264 @ 0x55fe8a8a6d40] mb P  I16..4:  0.1%  1.2%  0.4%  P16..4: 41.3% 11.1%  8.7%  0.0%  0.0%    skip:37.2%
[libx264 @ 0x55fe8a8a6d40] mb B  I16..4:  0.0%  0.2%  0.0%  B16..8: 25.8%  2.6%  0.6%  direct: 0.7%  skip:70.0%  L0:42.9% L1:51.0% BI: 6.1%
[libx264 @ 0x55fe8a8a6d40] 8x8 transform intra:71.2% inter:72.8%
[libx264 @ 0x55fe8a8a6d40] coded y,uvDC,uvAC intra: 81.3% 83.4% 41.2% inter: 6.8% 12.5% 0.3%
[libx264 @ 0x55fe8a8a6d40] i16 v,h,dc,p: 27% 14% 15% 44%
[libx264 @ 0x55fe8a8a6d40] i8 v,h,dc,ddl,ddr,vr,hd,vl,hu: 15% 15% 12%  7% 13%  9% 13%  7%  9%
[libx264 @ 0x55fe8a8a6d40] i4 v,h,dc,ddl,ddr,vr,hd,vl,hu: 15% 13% 11% 10% 17% 11% 10%  6%  7%
[libx264 @ 0x55fe8a8a6d40] i8c dc,h,v,p: 46% 21% 21% 11%
[libx264 @ 0x55fe8a8a6d40] Weighted P-Frames: Y:0.5% UV:0.1%
[libx264 @ 0x55fe8a8a6d40] ref P L0: 56.5% 10.1% 21.4% 12.0%  0.0%
[libx264 @ 0x55fe8a8a6d40] ref B L0: 87.7%  8.9%  3.4%
[libx264 @ 0x55fe8a8a6d40] ref B L1: 94.4% 

The trimmed file is quite a bit smaller than the original. I have not noticed any decrease in quality.

Shell
$ ls -AlF
total 1546136
-rw-r--r-- 1 mslinn mslinn   30579665 Jan 23 13:03 'VideoFile.trim.mkv'
-rwxrwxrwx 1 mslinn mslinn  180988555 Jan 22 18:31 'VideoFile.mkv' 

Sony A7iii Media Fil3

The trim script above incorporates the information discussed in this section.

I wanted to trim a video file created by my Sony A7iii camera, so only the portion from 0:51 through 2:45 was extracted.

FFprobe

First let’s use ffprobe to examine the streams within the media file.

FFprobe gathers information from multimedia streams and prints it in human- and machine-readable fashion.

For example, it can be used to check the format of the container used by a multimedia stream and the format and type of each media stream contained in it.
Shell
$ ffprobe myvideo.mp4
ffprobe version 5.1.2-3ubuntu1 Copyright (c) 2007-2022 the FFmpeg developers
  built with gcc 12 (Ubuntu 12.2.0-14ubuntu2)
  configuration: --prefix=/usr --extra-version=3ubuntu1 --toolchain=hardened --libdir=/usr/lib/x86_64-linux-gnu --incdir=/usr/include/x86_64-linux-gnu --arch=amd64 --enable-gpl --disable-stripping --enable-gnutls --enable-ladspa --enable-libaom --enable-libass --enable-libbluray --enable-libbs2b --enable-libcaca --enable-libcdio --enable-libcodec2 --enable-libdav1d --enable-libflite --enable-libfontconfig --enable-libfreetype --enable-libfribidi --enable-libglslang --enable-libgme --enable-libgsm --enable-libjack --enable-libmp3lame --enable-libmysofa --enable-libopenjpeg --enable-libopenmpt --enable-libopus --enable-libpulse --enable-librabbitmq --enable-librist --enable-librubberband --enable-libshine --enable-libsnappy --enable-libsoxr --enable-libspeex --enable-libsrt --enable-libssh --enable-libsvtav1 --enable-libtheora --enable-libtwolame --enable-libvidstab --enable-libvorbis --enable-libvpx --enable-libwebp --enable-libx265 --enable-libxml2 --enable-libxvid --enable-libzimg --enable-libzmq --enable-libzvbi --enable-lv2 --enable-omx --enable-openal --enable-opencl --enable-opengl --enable-sdl2 --disable-sndio --enable-libjxl --enable-pocketsphinx --enable-librsvg --enable-libmfx --enable-libdc1394 --enable-libdrm --enable-libiec61883 --enable-chromaprint --enable-frei0r --enable-libx264 --enable-libplacebo --enable-librav1e --enable-shared
  libavutil      57. 28.100 / 57. 28.100
  libavcodec     59. 37.100 / 59. 37.100
  libavformat    59. 27.100 / 59. 27.100
  libavdevice    59.  7.100 / 59.  7.100
  libavfilter     8. 44.100 /  8. 44.100
  libswscale      6.  7.100 /  6.  7.100
  libswresample   4.  7.100 /  4.  7.100
  libpostproc    56.  6.100 / 56.  6.100
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x560a973df5c0] st: 0 edit list: 1 Missing key frame while searching for timestamp: 1001
[mov,mp4,m4a,3gp,3g2,mj2 @ 0x560a973df5c0] st: 0 edit list 1 Cannot find an index entry before timestamp: 1001.
Input #0, mov,mp4,m4a,3gp,3g2,mj2, from 'myvideo.mp4':
  Metadata:
    major_brand     : XAVC
    minor_version   : 16785407
    compatible_brands: XAVCmp42iso2
    creation_time   : 2023-01-05T00:52:24.000000Z
  Duration: 00:10:58.16, start: 0.000000, bitrate: 51445 kb/s
  Stream #0:0[0x1](und): Video: h264 (High) (avc1 / 0x31637661), yuv420p(tv, bt709/bt709/iec61966-2-4, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 49370 kb/s, 59.94 fps, 59.94 tbr, 60k tbn (default)
    Metadata:
      creation_time   : 2023-01-05T00:52:24.000000Z
      handler_name    : Video Media Handler
      vendor_id       : [0][0][0][0]
      encoder         : AVC Coding
  Stream #0:1[0x2](und): Audio: pcm_s16be (twos / 0x736F7774), 48000 Hz, 2 channels, s16, 1536 kb/s (default)
    Metadata:
      creation_time   : 2023-01-05T00:52:24.000000Z
      handler_name    : Sound Media Handler
      vendor_id       : [0][0][0][0]
  Stream #0:2[0x3](und): Data: none (rtmd / 0x646D7472), 491 kb/s (default)
    Metadata:
      creation_time   : 2023-01-05T00:52:24.000000Z
      handler_name    : Timed Metadata Media Handler
      timecode        : 03:52:30:26
Unsupported codec with id 0 for input stream 2 

FFprobe shows us that myvideo.mp4 has 3 streams, numbered from origin zero:

  1. MPEG-4 video stream – encoded using the H.264 high profile, which is the most commonly used H.264 profile), with 1920x1080 resolution, recorded at 50 Mb/s, and 59.94 fps. This stream was recoded to the same format using FFmpeg’s default stream handling. The recoding process computed keyframes for the new start and points after trimming.
  2. CD-quality audio streampcm_s16be (16-bit WAV format), at 48 kHz, in stereo, with a bit rate of 1536 kb/s. In contrast, 24 bits is commonly used in 2023 for streaming audio. WAV encoding is uncompressed, and so it is disallowed by strict mp4 compliance.

    Because FFmpeg follows string mp4 compliance by default, FFmpeg will not copy audio encoded in WAV format. However, the audio stream can be transcoded to a compressed format, so it can be included in the output file. You could relax FFmpeg’s strict mp4 compliance by providing the -strict experimental option to FFmpeg. Instead, I decided to compress the audio using AAC because this results in a smaller file that sounds just as good.
  3. Data stream – normally ignored by ffmpeg when creating output. ffprobe shows an error for this stream (Unsupported codec with id 0 for input stream 2), but we do not need this stream, so the trim script will also ignore it.

Transcoding Audio

Audio is best transcoded to a lossless compressed format that is supported by the MP4 container, like aac or Opus.

The hierarchy of audio encoder quality has been reported as: libopus > libvorbis >= libfdk_aac > libmp3lame >= eac3/ac3 > aac > libtwolame > vorbis > mp2 > wmav2/wmav1.

Ffmpeg’s AAC transcoder gives poor results for bit rates less than 128k. Opus gives a better result, however some hardware devices may not have support. My 3-year-old TCL TV does not support Opus, for example.

FFMpeg supports 5 AAC encoders, including the regular AAC encoder, and the Fraunhofer FDK AAC codec. The latter requires that you compile FFmpeg because of licensing issues. This article will not get into that.

Regular AAC codec

Following is the incantation used by the trim script. It yields regular an mp4 with a video stream and an AAC stereo stream.

Shell
$ ffmpeg -y -ss 51 -to 2:45 -i input.mp4 -acodec aac output.mp4

Both the video and audio streams are recoded. The video stream selected from the input file is the highest resolution video stream in that file. Non-essential streams for regular playback are ignored.

😁

The resulting file is a balance of the highest quality audio and video streams, with the smallest overall file size that can usually be expected to play on most devices.

Fraunhofer FDK AAC codec

You could go one better and build FFmpeg with the Fraunhofer FDK AAC codec, then invoke it with:

Shell
$ ffmpeg -y -ss 51 -to 2:45 -i input.mp4 -c:a libfdk_aac output.mp4