Class: MediaTrim

Inherits:
Object
  • Object
show all
Defined in:
lib/trim_main.rb,
lib/trim_run.rb,
lib/trim_help.rb,
lib/trim_class.rb,
lib/trim_preflight.rb

Overview

Define a new StandardError subclass

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(filename = nil, trimmed_filename = nil, start = '0', to = nil, **options) ⇒ MediaTrim

Returns a new instance of MediaTrim.

Parameters:

  • to (String) (defaults to: nil)

    end timecode; duration not supported for API



8
9
10
11
12
13
14
15
16
17
18
19
20
21
# File 'lib/trim_main.rb', line 8

def initialize(filename = nil, trimmed_filename = nil, start = '0', to = nil, **options)
  @fname = MediaTrim.expand_env(filename) if filename
  @copy_filename = MediaTrim.expand_env(trimmed_filename) if trimmed_filename
  @start = MediaTrim.time_format(MediaTrim.to_seconds(start))
  @interval = ['-ss', @start]
  @debug = options[:debug] || false
  @no_hwaccel = options[:no_hwaccel] || false

  @overwrite = options[:overwrite] ? '-y' : '-n'
  @quiet     = options[:quiet].nil? || options[:quiet] ? ['-hide_banner', '-loglevel', 'error', '-nostats'] : []
  @view      = options[:view].nil? || options[:view]

  prepare(@start, to, mode: :timecode) if to
end

Instance Attribute Details

#copy_filenameObject

Returns the value of attribute copy_filename.



5
6
7
# File 'lib/trim_main.rb', line 5

def copy_filename
  @copy_filename
end

#debugObject

Returns the value of attribute debug.



5
6
7
# File 'lib/trim_main.rb', line 5

def debug
  @debug
end

#fnameObject

Returns the value of attribute fname.



5
6
7
# File 'lib/trim_main.rb', line 5

def fname
  @fname
end

#intervalObject

Returns the value of attribute interval.



5
6
7
# File 'lib/trim_main.rb', line 5

def interval
  @interval
end

#msg_endObject

Returns the value of attribute msg_end.



5
6
7
# File 'lib/trim_main.rb', line 5

def msg_end
  @msg_end
end

#overwriteObject

Returns the value of attribute overwrite.



5
6
7
# File 'lib/trim_main.rb', line 5

def overwrite
  @overwrite
end

#quietObject

Returns the value of attribute quiet.



5
6
7
# File 'lib/trim_main.rb', line 5

def quiet
  @quiet
end

#startObject

Returns the value of attribute start.



5
6
7
# File 'lib/trim_main.rb', line 5

def start
  @start
end

#viewObject

Returns the value of attribute view.



5
6
7
# File 'lib/trim_main.rb', line 5

def view
  @view
end

Class Method Details

.add_times(str1, str2) ⇒ Object



2
3
4
5
6
7
8
9
10
11
12
13
# File 'lib/trim_class.rb', line 2

def self.add_times(str1, str2)
  time1 = Time.parse mk_time str1
  time2 = Time.parse mk_time str2
  h = time2.strftime('%H').to_i
  m = time2.strftime('%M').to_i
  s = time2.strftime('%S').to_i
  millis = time2.strftime('%L').to_f / 1000.0
  sum = (time1 + (h * 60 * 60) + (m * 60) + s + millis)
  return sum.strftime('%H:%M:%S') if h.positive?

  sum.strftime('%M:%S')
end

.duration(str1, str2) ⇒ Object

Returns time difference HH:MM:SS, ignoring millis.

Returns:

  • time difference HH:MM:SS, ignoring millis



40
41
42
43
44
45
# File 'lib/trim_class.rb', line 40

def self.duration(str1, str2)
  time1 = Time.parse mk_time str1
  time2 = Time.parse mk_time str2

  MediaTrim.time_format(time2 - time1)
end

.expand_env(str, die_if_undefined: false) ⇒ Object

Expand an environment variable reference



48
49
50
51
52
53
54
55
56
# File 'lib/trim_class.rb', line 48

def self.expand_env(str, die_if_undefined: false)
  str&.gsub(/\$([a-zA-Z_][a-zA-Z0-9_]*)|\${\g<1>}|%\g<1>%/) do
    envar = Regexp.last_match(1)
    raise TrimError, "MediaTrim error: #{envar} is undefined".red, [] \
      if !ENV.key?(envar) && die_if_undefined # Suppress stack trace

    ENV.fetch(envar, nil)
  end
end

.help(msg = nil) ⇒ Object



2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# File 'lib/trim_help.rb', line 2

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

    - Works with all formats supported by ffmpeg, including mp3, mp4, mkv, and many more.
    - Seeks to the nearest frame positions by re-encoding the media.
    - Reduces file size produced by OBS Studio by over 80 percent.
    - Can be used as a Ruby gem.
    - Installs the 'trim' command.

    When run as a command, output files are named by adding a 'trim.' prefix to the media file name, e.g., 'dir/trim.file.ext'.
    By default, the trim command does not overwrite pre-existing output files.
    When trimming is complete, the trim command displays the trimmed file, unless the -q option is specified

    Command-line Usage:
      trim [OPTIONS] dir/file.ext start [[to|for] end]

    - The start and end timecodes have the format [HH:[MM:]]SS[.XXX]
      Note that decimal seconds may be specified, but frames may not;
      this is consistent with how ffmpeg parses timecodes.
    - end defaults to the end of the audio/video file

    OPTIONS are:
      -d Enable debug output
      -f Overwrite output file if present
      -h Display help information.
      -v Verbose output
      -V Do not view the trimmed file when complete.
      -H Disable hardware acceleration and use software decoding. If hardware
         acceleration initialization fails on your system, the command will
         automatically retry using software decoding. Software decoding is
         slower, but it will produce the same output. If a required software
         decoder is missing then the program will halt with an error.

    Examples:
      # Crop dir/file.mp4 from 15.0 seconds to the end of the video, save to demo/trim.demo.mp4:
      trim demo/demo.mp4 15

      # Crop dir/file.mkv from 3 minutes, 25 seconds to 9 minutes, 35 seconds, save to demo/trim.demo.mp4:
      trim demo/demo.mp4 3:25 9:35

      # Same as the previous example, using optional 'to' syntax:
      trim demo/demo.mp4 3:25 to 9:35

      # Save as the previous example, but specify the duration instead of the end time by using the for keyword:
      trim demo/demo.mp4 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 reliably
    - F/OSS
    - Mac, Windows, and Linux
    - High quality
  END_HELP
  exit 1
end

.mk_time(str) ⇒ Object



58
59
60
61
62
63
64
65
# File 'lib/trim_class.rb', line 58

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

.time_format(elapsed_seconds) ⇒ Object



15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# File 'lib/trim_class.rb', line 15

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

  # Format seconds with proper padding
  if seconds == seconds.to_i
    # No decimal part
    seconds_str = seconds.to_i.to_s.rjust(2, '0')
  else
    # Has decimal part
    whole_seconds = seconds.to_i
    decimal_part = seconds - whole_seconds
    decimal_str = format('%.2f', decimal_part)[1..]  # Remove leading "0" from "0.XX"
    seconds_str = "#{whole_seconds.to_s.rjust(2, '0')}#{decimal_str}"
  end

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

.to_seconds(str) ⇒ Object



67
68
69
70
71
72
73
74
75
# File 'lib/trim_class.rb', line 67

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

Instance Method Details

#optionsObject



23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/trim_main.rb', line 23

def options
  OptionParser.new do |opts|
    opts.banner = "Usage: #{$PROGRAM_NAME} [options] file start [[to|for] end]"

    opts.on('-f', '--overwrite', 'Overwrite any previous output') do
      @overwrite = '-y'
    end
    opts.on('-v', '--verbose', 'Run verbosely') do
      @quiet = []
    end
    opts.on('-h', '--help', 'Display help') do
      MediaTrim.help
    end
    opts.on('-V', '--no-view', 'Do not view the trimmed file when complete') do
      @view = false
    end
    opts.on('-d', '--debug', 'Enable debug output') do
      @debug = true
    end
    opts.on('-H', '--no-hwaccel', 'Disable hardware acceleration and use software decoding') do
      @no_hwaccel = true
    end
  end.parse!
end

#preflight_check(input) ⇒ Object

Run a set of preflight checks and either exit on fatal errors or return an array of warning messages. The method uses clear, full-sentence messages so the user understands what is missing or degraded.



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/trim_preflight.rb', line 8

def preflight_check(input)
  warnings = []

  # Check that ffmpeg is installed
  unless system('which ffmpeg > /dev/null 2>&1')
    puts "Fatal: ffmpeg is not installed. Please install ffmpeg and try again.".red
    exit 1
  end

  # Probe hardware acceleration initialization in a lightweight way. If
  # this fails with hardware-specific errors we will warn and allow a
  # software retry later. If probing fails for decoding reasons, that is fatal.
  probe_cmd = ['ffmpeg', '-v', 'error', '-hwaccel', 'auto', '-i', input, '-t', '1', '-f', 'null', '-']
  _, stderr, status = Open3.capture3(*probe_cmd)

  if !status.success?
    hwaccel_errors = [/CUDA_ERROR_NO_DEVICE/, /Failed to initialise VAAPI/, /Cannot open the X11 display/, /Device does not support VK_KHR_video_decode_queue/, /hwaccel/]
    if hwaccel_errors.any? { |rx| stderr =~ rx }
      warnings << "Hardware acceleration could not be initialized: #{stderr.lines.first(2).join(' ').strip}. The command will be retried using software decoding, which is slower but produces the same result." 
    else
      # If hw probe fails for other reasons, check software decoding capability.
      sw_cmd = ['ffmpeg', '-v', 'error', '-i', input, '-t', '1', '-f', 'null', '-']
      _, sw_stderr, sw_status = Open3.capture3(*sw_cmd)
      unless sw_status.success?
        puts "Fatal: ffmpeg cannot decode the input file. #{sw_stderr.lines.first(2).join(' ').strip}".red
        exit 1
      end
    end
  end

  # Check available disk space in destination directory.
  begin
    dest_dir = Shellwords.escape(File.dirname(input))
    avail = `df --output=avail -B1 #{dest_dir} 2>/dev/null`.lines.last.to_i
  rescue StandardError
    avail = 0
  end
  if avail == 0 || avail < 100_000_000
    puts "Fatal: There is less than 100 MB of free space available in the destination directory. Please free space and try again.".red
    exit 1
  end

  # If we reach here, return any collected warnings for user information.
  warnings.each { |w| puts "Warning: #{w}".yellow }
  warnings
end

#prepare(from, duration_or_timecode, mode: :duration) ⇒ Object



82
83
84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/trim_main.rb', line 82

def prepare(from, duration_or_timecode, mode: :duration)
  if mode == :duration
    timecode = MediaTrim.time_format(MediaTrim.to_seconds(duration_or_timecode))
    time_end = MediaTrim.add_times from, timecode
    @interval += ['-t', time_end]
    @msg_end = " for a duration of #{timecode} (until #{time_end})"
  else # end timecode was specified
    time_end = MediaTrim.time_format(MediaTrim.to_seconds(duration_or_timecode))
    elapsed_time = MediaTrim.duration from, time_end
    @interval += ['-to', time_end]
    @msg_end = " to #{time_end} (duration #{elapsed_time})"
  end
  time_end
end

#runObject

Raises:



2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/trim_run.rb', line 2

def run
  raise TrimError, 'Error: No filename was specified'.red unless @fname
  raise TrimError, 'Error: No trimmed filename was specified'.red unless @copy_filename
  raise TrimError, 'Error: No starting timestamp was specified'.red unless @start
  raise TrimError, 'Error: Starting timestamp must be a string'.red unless @start.instance_of? String

  puts "Trimming '#{@fname}' from #{@start}#{@msg_end}".cyan

  # Run preflight checks which may print warnings or exit on fatal errors.
  preflight_warnings = preflight_check @fname

  # Build ffmpeg command. Respect explicit no-hwaccel option.
  base_args = ['ffmpeg', *@quiet, @overwrite, '-i', @fname, '-acodec', 'aac', *@interval, @copy_filename]

  if @no_hwaccel
    command = base_args
  else
    command = ['ffmpeg', *@quiet, '-hwaccel', 'auto', @overwrite, '-i', @fname, '-acodec', 'aac', *@interval, @copy_filename]
  end

  # Execute ffmpeg (timed). If hwaccel was used and failed, retry once without hwaccel.
  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 #{MediaTrim.time_format elapsed.to_i}".cyan
  $stdout.flush

  unless status
    # If we attempted hwaccel and it failed, retry in software mode once.
    if !@no_hwaccel && command.include?('-hwaccel')
      puts "Warning: hardware acceleration failed. Retrying with software decoding, which may be slower but will produce the correct result.".yellow
      soft_command = base_args
      start_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      status = system(*soft_command)
      end_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      elapsed = end_clock - start_clock
      puts "Retry (software) took #{MediaTrim.time_format elapsed.to_i}".cyan
      $stdout.flush
    end
    exit 1 unless status
  end

  # View trimmed file unless -q option was specified
  return unless @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

#setup(argv) ⇒ Object

Parameters:

  • argv (array)

    Copy of ARGV

Raises:



49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/trim_main.rb', line 49

def setup(argv)
  MediaTrim.help 'Please specify the name of the video file to trim' unless argv[0]
  @fname = MediaTrim.expand_env argv[0]
  unless File.exist? @fname
    puts "Error: '#{File.realpath @fname}' does not exist.".red
    exit 1
  end
  original_filename = File.basename @fname, '.*'
  ext = File.extname @fname
  @copy_filename = "#{File.dirname @fname}/trim.#{original_filename}#{ext}"

  MediaTrim.help 'Please specify the time to @start trimming the video file from' unless argv[1]
  @start = MediaTrim.time_format(MediaTrim.to_seconds(argv[1]))

  @interval = ['-ss', @start]
  @msg_end = ''
  index = 2
  return unless argv.length > index

  if argv[index] == 'for' # duration
    index += 1
    MediaTrim.help 'No duration was specified' unless argv.length > index
    to = prepare @start, argv[index], mode: :duration
  else # end timecode
    index += 1 if argv[index] == 'to'
    MediaTrim.help 'No end time was specified' unless argv.length > index
    to = prepare @start, argv[index], mode: :timecode
  end
  return unless @start >= to

  raise TrimError, "Error: @start time (#{@start}) must be before end time (#{to})" if @start >= to
end