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

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
# 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

  @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
# 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.

    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



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

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
  end.parse!
end

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



78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/trim_main.rb', line 78

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
# 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
  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 #{MediaTrim.time_format elapsed.to_i}".cyan
  $stdout.flush
  exit 1 unless status

  # 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:



45
46
47
48
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
# File 'lib/trim_main.rb', line 45

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