module MovieMasher::ShellHelper

executes commands and optionally checks created files for duration

Public Class Methods

__atrim(offset, duration) click to toggle source
# File lib/util/shell_helper.rb, line 295
def self.__atrim(offset, duration)
  offset = ShellHelper.escape(offset)
  duration = ShellHelper.escape(duration)
  "'atrim=start=#{offset}:duration=#{duration},asetpts=expr=PTS-STARTPTS'"
end
__audio_gains(volume, graph) click to toggle source
# File lib/util/shell_helper.rb, line 300
def self.__audio_gains(volume, graph)
  start = graph[:start]
  length = graph[:length]
  loops = graph[:loop] || 1
  audio_cmd = ''
  if Mash.gain_changes(volume)
    volume = volume.to_s unless volume.is_a?(String)
    volume = "0,#{volume},1,#{volume}" unless volume.include?(',')
    volume = volume.split(',')
    z = volume.length / 2
    audio_cmd += " -ea:0 -klg:1,0,100,#{z}"
    z.times do |i|
      p = (i + 1) * 2
      pos = volume[p - 2].to_f
      val = volume[p - 1].to_f
      if FloatUtil.gtr(pos, FloatUtil::ZERO)
        pos = (length * loops.to_f * pos)
      end
      audio_cmd += ",#{FloatUtil.precision(start + pos)},#{val}"
    end
  end
  audio_cmd
end
__audio_graph(graph, counter) click to toggle source
# File lib/util/shell_helper.rb, line 235
def self.__audio_graph(graph, counter)
  audio_cmd = ''
  loops = graph[:loop] || 1
  volume = graph[:gain]
  audio_cmd += " -a:#{counter} -i "
  audio_cmd += 'audioloop,' if 1 < loops
  audio_cmd += "playat,#{graph[:start]},"
  audio_cmd += "select,#{graph[:offset]}"
  audio_cmd += ",#{graph[:length]},#{graph[:waved_file]}"
  audio_cmd += __audio_loops(loops, graph[:length])
  audio_cmd += __audio_gains(volume, graph)
  audio_cmd
end
__audio_loops(loops, length) click to toggle source
# File lib/util/shell_helper.rb, line 323
def self.__audio_loops(loops, length)
  (1 < loops ? " -t:#{FloatUtil.string(length)}" : '')
end
__audio_path(out_path, audio_cmd) click to toggle source
# File lib/util/shell_helper.rb, line 228
def self.__audio_path(out_path, audio_cmd)
  hex = Digest::SHA2.new(256).hexdigest(audio_cmd)
  "#{out_path}audio-#{hex}.#{Intermediate::AUDIO_EXTENSION}"
end
__audio_raw(graphs) click to toggle source
# File lib/util/shell_helper.rb, line 326
def self.__audio_raw(graphs)
  graph = graphs.first
  __raise_if_negative(graph[:start], "negative start time #{graph}")
  __raise_if_zero(graph[:length], "zero length #{graph}")
  raw = (1 == graphs.length)
  raw &&= (1 == graph[:loop])
  raw &&= !Mash.gain_changes(graph[:gain])
  raw &&= FloatUtil.cmp(graph[:start], FloatUtil::ZERO)
  raw
end
__audio_silence(c, duration) click to toggle source
# File lib/util/shell_helper.rb, line 225
def self.__audio_silence(c, duration)
  " -a:#{c} -i playat,0,tone,sine,0,#{duration} -a:all -z:mixmode,sum -o "
end
__audio_switches(graph, audio_dur) click to toggle source
# File lib/util/shell_helper.rb, line 248
def self.__audio_switches(graph, audio_dur)
  switches = []
  trim_not_needed = FloatUtil.cmp(graph[:offset], FloatUtil::ZERO)
  trim_not_needed &&= FloatUtil.cmp(graph[:length], graph[:duration])
  unless trim_not_needed
    switches << switch(__atrim(graph[:offset], audio_dur), 'af')
  end
  switches << switch(1, 'async')
  switches
end
__graph_command(graph, output) click to toggle source
# File lib/util/shell_helper.rb, line 105
def self.__graph_command(graph, output)
  cmd = graph.graph_command(output)
  __raise_if_empty(cmd, "could not build graph command #{graph}")
  switch_unescaped(%Q("#{cmd}"), 'filter_complex')
end
__is_two_pass(video_or_audio_output, v_graphs) click to toggle source
# File lib/util/shell_helper.rb, line 232
def self.__is_two_pass(video_or_audio_output, v_graphs)
  video_or_audio_output && v_graphs.length < 2
end
__output_duration(video_or_audio_output, max_dur) click to toggle source
# File lib/util/shell_helper.rb, line 222
def self.__output_duration(video_or_audio_output, max_dur)
  (video_or_audio_output ? max_dur : nil)
end
__output_graphs(output, job) click to toggle source
# File lib/util/shell_helper.rb, line 91
def self.__output_graphs(output, job)
  v_graphs = []
  a_graphs = []
  avb = output[:av]
  unless AV::AUDIO_ONLY == avb
    v_graphs = job.video_graphs
    avb = AV::AUDIO_ONLY if v_graphs.empty?
  end
  unless AV::VIDEO_ONLY == avb
    a_graphs = job.audio_graphs
    avb = AV::VIDEO_ONLY if a_graphs.empty?
  end
  [avb, v_graphs, a_graphs]
end
__raise_if_empty(s, msg) click to toggle source
# File lib/util/shell_helper.rb, line 359
def self.__raise_if_empty(s, msg)
  raise(Error::JobInput, msg) if s.empty?
end
__raise_if_negative(f, msg) click to toggle source
# File lib/util/shell_helper.rb, line 362
def self.__raise_if_negative(f, msg)
  raise(Error::JobInput, msg) unless FloatUtil.gtre(f, FloatUtil::ZERO)
end
__raise_if_no_file(out_file, result) click to toggle source
# File lib/util/shell_helper.rb, line 365
def self.__raise_if_no_file(out_file, result)
  logs = []
  if out_file.include?('%')
    file_count = Dir["#{File.dirname(out_file)}/"].count
    msg = "created #{file_count} file#{1 == file_count ? '' : 's'}"
    raise(Error::JobRender.new(result, msg)) if file_count.zero?
    logs << { info: (proc { msg }) }
  else
    if File.exist?(out_file)
      size = File.size?(out_file).to_i
      if size.zero?
        raise(Error::JobRender.new(result, "couldn't create #{out_file}"))
      else
        logs << { info: (proc { "created #{size} byte file #{out_file}" }) }
      end
    else
      raise(Error::JobRender.new(result, "couldn't create #{out_file}"))
    end
  end
  logs
end
__raise_if_zero(f, msg) click to toggle source
# File lib/util/shell_helper.rb, line 386
def self.__raise_if_zero(f, msg)
  raise(Error::JobInput, msg) unless FloatUtil.gtr(f, FloatUtil::ZERO)
end
__raise_unless_duration(result, duration, precision, out_file) click to toggle source
# File lib/util/shell_helper.rb, line 336
def self.__raise_unless_duration(result, duration, precision, out_file)
  logs = []
  has_no_video = Info.get(out_file, Info::DIMENSIONS).to_s.empty?
  dur_key = (has_no_video ? Info::AUDIO_DURATION : Info::VIDEO_DURATION)
  test_duration = Info.get(out_file, dur_key).to_f
  msg = "rendered with duration: #{test_duration} #{out_file}"
  logs << { debug: (proc { msg }) }
  if test_duration.zero?
    msg = "could not determine if #{duration} == duration of #{out_file}"
    raise(Error::JobRender.new(result, msg))
  end
  ok = FloatUtil.cmp(duration, test_duration, precision.abs)
  unless ok
    logs << { warn: (proc { result }) }
    if -1 < precision
      msg = "expected #{has_no_video ? 'audio' : 'video'} duration of "             "#{duration} but found #{test_duration} in #{out_file}"
      raise(Error::JobRender.new(result, msg))
    end
    logs << { warn: (proc { msg }) }
  end
  logs
end
__type_duration(type, max_dur) click to toggle source
# File lib/util/shell_helper.rb, line 219
def self.__type_duration(type, max_dur)
  (Type::IMAGES.include?(type) ? nil : max_dur)
end
__waveform_switches(graph, output) click to toggle source
# File lib/util/shell_helper.rb, line 258
def self.__waveform_switches(graph, output)
  switches = []
  dimensions = output[:dimensions].split 'x'
  switches << switch(graph[:waved_file], '--input')
  switches << switch(dimensions.first, '--width')
  switches << switch(dimensions.last, '--height')
  switches << switch(output[:forecolor], '--linecolor')
  switches << switch(output[:backcolor], '--backgroundcolor')
  switches << switch('0', '--padding')
  switches << switch('', '--output')
  switches
end
audio_command(path) click to toggle source
# File lib/util/shell_helper.rb, line 7
def self.audio_command(path)
  if path.to_s.empty? || !File.exist?(path)
    raise(Error::Parameter, "__audio_from_file with invalid path #{path}")
  end
  file_name = File.basename(path, File.extname(path))
  file_name = "#{file_name}-intermediate.#{Intermediate::AUDIO_EXTENSION}"
  out_file = Path.concat(File.dirname(path), file_name)
  switches = []
  switches << switch(path, 'i')
  switches << switch(2, 'ac')
  switches << switch(44_100, 'ar')
  exec_opts = {}
  exec_opts[:command] = switches.join
  exec_opts[:file] = out_file
  exec_opts
end
capture(cmd) click to toggle source
# File lib/util/shell_helper.rb, line 23
def self.capture(cmd)
  result = Open3.capture3(cmd)
  result = result.reject { |s| s.to_s.empty? }
  result = result.join("\n")
  # puts result
  # make sure result is utf-8 encoded
  enc_options = {}
  enc_options[:invalid] = :replace
  enc_options[:undef] = :replace
  enc_options[:replace] = '?'
  # enc_options[:universal_newline] = true
  result.encode(Encoding::UTF_8, enc_options)
end
command(options) click to toggle source
# File lib/util/shell_helper.rb, line 36
def self.command(options)
  out_file = options[:file].to_s
  app = options[:app] || 'ffmpeg'
  app_path = MovieMasher.configuration["#{app}_path".to_sym]
  app_path = app if app_path.to_s.empty?
  cmd = "#{app_path} #{options[:command]}"
  cmd += " #{out_file}" unless out_file.empty?
  cmd
end
escape(s) click to toggle source
# File lib/util/shell_helper.rb, line 45
def self.escape(s)
  Shellwords.escape(s)
end
execute(options) click to toggle source
# File lib/util/shell_helper.rb, line 48
def self.execute(options)
  capture(command(options))
end
output_command(output, av_type, duration = nil) click to toggle source
# File lib/util/shell_helper.rb, line 61
def self.output_command(output, av_type, duration = nil)
  switches = []
  switches << switch(FloatUtil.string(duration), 't') if duration
  unless AV::VIDEO_ONLY == av_type # we have audio output
    switches << switch(output[:audio_bitrate], 'b:a', 'k')
    switches << switch(output[:audio_rate], 'r:a')
    switches << switch(output[:audio_codec], 'c:a')
  end
  unless AV::AUDIO_ONLY == av_type # we have visuals
    case output[:type]
    when Type::VIDEO
      switches << switch(output[:dimensions], 's')
      switches << switch(output[:video_format], 'f:v')
      switches << switch(output[:video_codec], 'c:v')
      switches << switch(output[:video_bitrate], 'b:v', 'k')
      switches << switch(output[:video_rate], 'r:v')
    when Type::IMAGE
      switches << switch(output[:quality], 'q:v')
      if output[:offset]
        output_time = TimeRange.input_time(output, :offset)
        switches << switch(output_time, 'ss')
      end
    when Type::SEQUENCE
      switches << switch(output[:quality], 'q:v')
      switches << switch(output[:video_rate], 'r:v')
    end
  end
  switches << switch(output[:metadata], 'metadata')
  switches.join('')
end
raise_unless_rendered(result, options) click to toggle source
# File lib/util/shell_helper.rb, line 51
def self.raise_unless_rendered(result, options)
  logs = []
  out_file = options[:file].to_s
  outputs = !['', '/dev/null'].include?(out_file)
  dur = options[:duration]
  precision = options[:precision] || 1
  logs += __raise_if_no_file(out_file, result) if outputs
  logs += __raise_unless_duration(result, dur, precision, out_file) if dur
  logs
end
set_output_commands(job, output) click to toggle source
# File lib/util/shell_helper.rb, line 110
def self.set_output_commands(job, output)
  output[:commands] = []
  switches = []
  end_switches = []
  rend_path = job.render_path(output)
  out_path = job.output_path(output)
  avb, v_graphs, a_graphs = __output_graphs(output, job)
  raise(Error::JobInput, 'no graphs') if v_graphs.empty? && a_graphs.empty?
  video_or_audio_output = Type::RAW_AVS.include?(output[:type])
  audio_dur = video_dur = FloatUtil::ZERO
  unless AV::AUDIO_ONLY == avb
    switches << switch(output[:video_rate], 'r:v')
    if 1 == v_graphs.length
      graph = v_graphs.first
      video_dur = graph.duration
      switches << __graph_command(graph, output)
    else
      ffconcat_lines = []
      ffconcat_lines << 'ffconcat version 1.0'
      concat_files = []
      v_graphs.length.times do |index|
        graph = v_graphs[index]
        duration = graph.duration
        video_dur += duration
        out_file_name = "concat-#{index}.#{output[:extension]}"
        out_file = "#{out_path}#{out_file_name}"
        concat_files << out_file
        ffconcat_lines << "file '#{out_file_name}'"
        ffconcat_lines << "duration #{duration}"
        cmd = '-y' + switches.join + __graph_command(graph, output)
        cmd += output_command(output, AV::VIDEO_ONLY, duration)
        cmd += switch('0', 'qp')
        output[:commands] << {
          command: cmd,
          pass: true,
          duration: duration,
          precision: output[:precision],
          file: out_file
        }
      end
      file_path = "#{out_path}concat.txt"
      exec_opts = {}
      exec_opts[:content] = ffconcat_lines.join("\n")
      exec_opts[:file] = file_path
      output[:commands] << exec_opts
      switches << switch("'#{file_path}'", 'i')
      end_switches << switch('copy', 'c:v')
    end
  end
  unless AV::VIDEO_ONLY == avb
    if __audio_raw(a_graphs)
      # just one non-looping graph, starting at zero with no gain change
      graph = a_graphs.first
      audio_dur = graph[:length]
      cmd_hash = audio_command(graph[:cached_file])
      output[:commands] << cmd_hash
      graph[:waved_file] = cmd_hash[:file]
    else
      # merge audio and feed resulting file to ffmpeg
      audio_cmd = ''
      counter = 1
      a_graphs.length.times do |index|
        graph = a_graphs[index]
        __raise_if_negative(graph[:start], "negative start time #{graph}")
        __raise_if_zero(graph[:length], "zero length #{graph}")
        audio_dur = FloatUtil.max(audio_dur, graph[:start] + graph[:length])
        cmd_hash = audio_command(graph[:cached_file])
        output[:commands] << cmd_hash
        graph[:waved_file] = cmd_hash[:file]
        audio_cmd += __audio_graph(graph, counter)
        counter += 1
      end
      max_dur = FloatUtil.max(audio_dur, video_dur)
      audio_cmd += __audio_silence(counter, max_dur)
      path = __audio_path(out_path, audio_cmd)
      output[:commands] << {
        app: 'ecasound', command: audio_cmd, precision: output[:precision],
        file: path, duration: max_dur
      }
      graph = {
        type: Type::AUDIO, offset: FloatUtil::ZERO, length: audio_dur,
        waved_file: path
      }
    end
    # audio graph now represents just one file
    if Type::WAVEFORM == output[:type]
      switches += __waveform_switches(graph, output)
      output[:commands] << {
        app: 'wav2png', command: switches.join, file: rend_path
      }
      switches = []
    else
      switches << switch(graph[:waved_file], 'i')
      switches << __audio_switches(graph, audio_dur)
    end
  end
  unless switches.empty?
    # we've got audio and/or video
    max_dur = FloatUtil.max(audio_dur, video_dur)
    output_dur = __output_duration(video_or_audio_output, max_dur)
    cmd = '-y ' + (switches + end_switches).join
    cmd += output_command(output, avb, output_dur)
    output[:commands] << {
      command: cmd, file: rend_path, precision: output[:precision],
      pass: __is_two_pass(video_or_audio_output, v_graphs),
      duration: __type_duration(output[:type], max_dur)
    }
  end
end
switch(value, prefix = '', suffix = '', dont_escape = false) click to toggle source
# File lib/util/shell_helper.rb, line 273
def self.switch(value, prefix = '', suffix = '', dont_escape = false)
  cmd = ''
  if value
    value = value.to_s.strip
    unless dont_escape
      splits = Shellwords.split(value)
      splits = splits.map { |word| escape(word) }
      # puts "SPLITS: #{splits}" if 1 < splits.length
      value = splits.join(' ')
    end
    cmd += ' ' # always add a leading space
    if value.start_with?('-') # it's a switch, just include and ignore rest
      cmd += value
    else # prepend value with prefix and space
      cmd += '-' unless prefix.start_with?('-')
      cmd += prefix
      cmd += ' ' + value unless value.empty?
      cmd += suffix unless cmd.end_with?(suffix) # note lack of space!
    end
  end
  cmd
end
switch_unescaped(value, prefix = '', suffix = '') click to toggle source
# File lib/util/shell_helper.rb, line 270
def self.switch_unescaped(value, prefix = '', suffix = '')
  switch(value, prefix, suffix, true)
end