loading
Generated 2022-12-31T08:59:11+00:00

All Files ( 91.83% covered at 12326.18 hits/line )

29 files in total.
2094 relevant lines, 1923 lines covered and 171 lines missed. ( 91.83% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
lib/defaults.rb 94.59 % 55 37 35 2 0.95
lib/ffmprb.rb 94.87 % 67 39 37 2 13005.90
lib/ffmprb/execution.rb 47.83 % 39 23 11 12 0.48
lib/ffmprb/file.rb 92.08 % 200 101 93 8 280.63
lib/ffmprb/file/sample.rb 91.30 % 48 23 21 2 56.65
lib/ffmprb/file/threaded_buffered.rb 100.00 % 48 23 23 0 25.70
lib/ffmprb/filter.rb 93.08 % 323 159 148 11 123.09
lib/ffmprb/find_silence.rb 82.61 % 45 23 19 4 3.91
lib/ffmprb/process.rb 96.08 % 206 102 98 4 53.66
lib/ffmprb/process/input.rb 95.31 % 117 64 61 3 100.36
lib/ffmprb/process/input/chain_base.rb 100.00 % 31 13 13 0 19.38
lib/ffmprb/process/input/channeled.rb 93.33 % 40 15 14 1 2.73
lib/ffmprb/process/input/cropped.rb 100.00 % 73 30 30 0 6.07
lib/ffmprb/process/input/cut.rb 92.59 % 66 27 25 2 24.56
lib/ffmprb/process/input/looping.rb 100.00 % 129 55 55 0 13.29
lib/ffmprb/process/input/loud.rb 100.00 % 42 17 17 0 5.18
lib/ffmprb/process/input/paced.rb 100.00 % 35 15 15 0 1.93
lib/ffmprb/process/input/postprocessed.rb 100.00 % 29 11 11 0 1.00
lib/ffmprb/process/input/reversed.rb 100.00 % 29 11 11 0 1.00
lib/ffmprb/process/input/temp.rb 100.00 % 19 8 8 0 3.50
lib/ffmprb/process/output.rb 95.00 % 406 180 171 9 59.79
lib/ffmprb/util.rb 81.52 % 178 92 75 17 11079.91
lib/ffmprb/util/proc_vis.rb 43.53 % 165 85 37 48 1105.85
lib/ffmprb/util/thread.rb 100.00 % 120 76 76 0 866.00
lib/ffmprb/util/threaded_io_buffer.rb 94.30 % 340 158 149 9 10252.91
spec/exe_spec.rb 100.00 % 68 22 22 0 1.59
spec/ffmprb_spec.rb 92.65 % 889 476 441 35 4.33
spec/file_spec.rb 100.00 % 264 143 143 0 156838.13
spec/util/thread_spec.rb 96.97 % 112 66 64 2 1.23

lib/defaults.rb

94.59% lines covered

37 relevant lines. 35 lines covered and 2 lines missed.
    
  1. 1 module Ffmprb
  2. 1 File.image_extname_regex = /^\.(jpe?g|a?png|y4m)$/i
  3. 1 File.sound_extname_regex = /^\.(mp3|wav)$/i
  4. 1 File.movie_extname_regex = /^\.(mp4|flv|mov)$/i
  5. 1 Filter.silence_noise_max_db = -40
  6. # NOTE ducking is currently not for streams
  7. 1 Process.duck_audio_silent_min = 3
  8. 1 Process.duck_audio_transition_length = 1
  9. 1 Process.duck_audio_transition_in_start = -0.4
  10. 1 Process.duck_audio_transition_out_start = -0.6
  11. 1 Process.duck_audio_volume_hi = 0.9
  12. 1 Process.duck_audio_volume_lo = 0.1
  13. 1 Process.timeout = 30
  14. 1 Process.input_video_auto_rotate = false
  15. 1 Process.input_video_fps = nil # NOTE the documented ffmpeg default is 25
  16. 1 Process.output_video_resolution = CGA
  17. 1 Process.output_video_fps = 16 # NOTE the documented ffmpeg default is 25
  18. 1 Process.output_audio_encoder = 'libmp3lame'
  19. 1 Process.output_audio_sampling_freq = nil # NOTE Use ffmpeg default by default, specify otherwise e.g. 44100
  20. 1 Util.cmd_timeout = 30
  21. 1 Util.ffmpeg_cmd = %w[ffmpeg -y]
  22. 1 Util.ffmpeg_inputs_max = 31
  23. 1 Util.ffprobe_cmd = ['ffprobe']
  24. 1 Util::ThreadedIoBuffer.blocks_max = 1024
  25. 1 Util::ThreadedIoBuffer.block_size = 64*1024
  26. 1 Util::ThreadedIoBuffer.timeout = 1
  27. 1 Util::ThreadedIoBuffer.timeout_limit = 15
  28. # NOTE all this effectively sets the minimum throughput: blocks_max * blocks_size / timeout * timeout_limit
  29. 1 Util::ThreadedIoBuffer.io_wait_timeout = 1
  30. 1 Util::Thread.timeout = 15
  31. # NOTE http://12factor.net etc
  32. 1 self.log_level = Logger::INFO
  33. 1 self.ffmpeg_debug = ENV.fetch('FFMPRB_FFMPEG_DEBUG', '') !~ Ffmprb::ENV_VAR_FALSE_REGEX
  34. 1 self.debug = ENV.fetch('FFMPRB_DEBUG', '') !~ Ffmprb::ENV_VAR_FALSE_REGEX
  35. 1 proc_vis_firebase = ENV['FFMPRB_PROC_VIS_FIREBASE']
  36. 1 if Ffmprb::FIREBASE_AVAILABLE
  37. 1 fail Error, "Please provide just the name of the firebase in FFMPRB_PROC_VIS_FIREBASE (e.g. my-proc-vis-io for https://my-proc-vis-io.firebaseio.com/proc/)" if proc_vis_firebase =~ /\//
  38. 1 self.proc_vis_firebase = proc_vis_firebase
  39. elsif proc_vis_firebase
  40. logger.warn "Firebase unavailable (have firebase gem installed or unset FFMPRB_PROC_VIS_FIREBASE to get rid of this warning)"
  41. end
  42. end

lib/ffmprb.rb

94.87% lines covered

39 relevant lines. 37 lines covered and 2 lines missed.
    
  1. 1 require 'logger'
  2. 1 require 'ostruct'
  3. 1 require 'timeout'
  4. # IMPORTANT NOTE ffmprb uses threads internally, however, it is not "thread-safe"
  5. 1 require_relative 'ffmprb/version'
  6. 1 require_relative 'ffmprb/util' # NOTE utils are like (micro-)gem candidates, errors are also there
  7. 1 module Ffmprb
  8. 1 ENV_VAR_FALSE_REGEX = /^(0|no?|false)?$/i
  9. 1 CGA = '320x200'
  10. 1 QVGA = '320x240'
  11. 1 HD_480p = '854x480'
  12. 1 HD_720p = '1280x720'
  13. 1 HD_1080p = '1920x1080'
  14. 1 HD_4K = '3840x2160'
  15. 1 class << self
  16. # TODO limit:
  17. 1 def process(*args, name: nil, **opts, &blk)
  18. 60 fail Error, "process: nothing ;( gimme a block!" unless blk
  19. 180 name ||= blk.source_location.map(&:to_s).map{ |s| File.basename s.to_s, File.extname(s) }.join(':')
  20. 60 process = Process.new(name: name, **opts)
  21. # TODO simply include the ProcVis if it makes into a gem
  22. 59 proc_vis_node process if respond_to? :proc_vis_node
  23. 59 logger.debug{"Starting process with #{args} #{opts} in #{blk.source_location}"}
  24. 59 process.instance_exec *args, &blk
  25. 57 logger.debug{"Initialized process with #{args} #{opts} in #{blk.source_location}"}
  26. 57 process.run.tap do
  27. 52 logger.debug{"Finished process with #{args} #{opts} in #{blk.source_location}"}
  28. end
  29. end
  30. 1 alias :action! :process # ;)
  31. 1 attr_accessor :debug, :ffmpeg_debug, :log_level
  32. 1 def logger
  33. 506560 @logger ||= Logger.new(STDERR).tap do |logger|
  34. 1 logger.level = debug ? Logger::DEBUG : Ffmprb.log_level
  35. end
  36. end
  37. 1 def logger=(logger)
  38. @logger.close if @logger
  39. @logger = logger
  40. end
  41. end
  42. 1 include Util::ProcVis if FIREBASE_AVAILABLE
  43. end
  44. 1 require_relative 'ffmprb/execution'
  45. 1 require_relative 'ffmprb/file'
  46. 1 require_relative 'ffmprb/filter'
  47. 1 require_relative 'ffmprb/find_silence'
  48. 1 require_relative 'ffmprb/process'
  49. 1 require_relative 'defaults'

lib/ffmprb/execution.rb

47.83% lines covered

23 relevant lines. 11 lines covered and 12 lines missed.
    
  1. 1 require 'thor'
  2. 1 module Ffmprb
  3. 1 class Execution < Thor
  4. 1 def self.exit_on_failure?; true; end
  5. 1 class_option :debug, :type => :boolean, :default => false
  6. 1 class_option :verbose, :aliases => '-v', :type => :boolean, :default => false
  7. 1 class_option :quiet, :aliases => '-q', :type => :boolean, :default => false
  8. 1 default_task :process
  9. 1 desc :process, "Reads an ffmprb script from STDIN and carries it out. See #{GEM_GITHUB_URL}"
  10. 1 def process(*ios)
  11. script = eval("lambda{#{STDIN.read}}")
  12. Ffmprb.log_level =
  13. if options[:debug]
  14. Logger::DEBUG
  15. elsif options[:verbose]
  16. Logger::INFO
  17. elsif options[:quiet]
  18. Logger::ERROR
  19. else
  20. Logger::WARN
  21. end
  22. Ffmprb.process *ios, &script
  23. end
  24. # NOTE a hack from http://stackoverflow.com/a/23955971/714287
  25. 1 def method_missing(method, *args)
  26. args = [:process, method.to_s] + args
  27. self.class.start(args)
  28. end
  29. end
  30. end

lib/ffmprb/file.rb

92.08% lines covered

101 relevant lines. 93 lines covered and 8 lines missed.
    
  1. 1 require 'json'
  2. 1 require 'mkfifo'
  3. 1 require 'tempfile'
  4. 1 module Ffmprb
  5. 1 class File < ::File
  6. 1 include Util::ProcVis::Node
  7. 1 class << self
  8. # NOTE careful when subclassing, it doesn't inherit the attr values
  9. 1 attr_accessor :image_extname_regex, :sound_extname_regex, :movie_extname_regex
  10. # NOTE must be timeout-safe
  11. 1 def opener(file, mode=nil)
  12. 221 ->{
  13. 243 path = file.respond_to?(:path)? file.path : file
  14. 243 mode ||= file.respond_to?(mode)? file.mode.to_s[0] : 'r'
  15. 243 Ffmprb.logger.debug{"Trying to open #{path} (for #{mode}-buffering or something)"}
  16. 243 ::File.open path, mode
  17. }
  18. end
  19. 1 def create(path)
  20. 536 new(path: path, mode: :write).tap do |file|
  21. 536 Ffmprb.logger.debug{"Created file with path: #{file.path}"}
  22. end
  23. end
  24. 1 def access(path)
  25. 3 new(path: path, mode: :read).tap do |file|
  26. 3 Ffmprb.logger.debug{"Accessed file with path: #{file.path}"}
  27. end
  28. end
  29. 1 def temp(extname)
  30. 307 file = create(Tempfile.new(['', extname]))
  31. 307 path = file.path
  32. 307 Ffmprb.logger.debug{"Created temp file with path: #{path}"}
  33. 307 return file unless block_given?
  34. begin
  35. 139 yield file
  36. ensure
  37. begin
  38. 139 file.unlink
  39. rescue
  40. Ffmprb.logger.warn "#{$!.class.name} removing temp file with path #{path}: #{$!.message}"
  41. end
  42. 139 Ffmprb.logger.debug{"Removed temp file with path: #{path}"}
  43. end
  44. end
  45. 1 def temp_fifo(extname='.tmp', &blk)
  46. 227 path = temp_fifo_path(extname)
  47. 227 mkfifo path
  48. 227 fifo_file = create(path)
  49. 227 return fifo_file unless block_given?
  50. 3 path = fifo_file.path
  51. begin
  52. 3 yield fifo_file
  53. ensure
  54. begin
  55. 3 fifo_file.unlink
  56. rescue
  57. Ffmprb.logger.warn "#{$!.class.name} removing temp file with path #{path}: #{$!.message}"
  58. end
  59. 3 Ffmprb.logger.debug{"Removed temp file with path: #{path}"}
  60. end
  61. end
  62. 1 def temp_fifo_path(extname)
  63. 227 join Dir.tmpdir, "#{rand(2**222)}p#{extname}"
  64. end
  65. 1 def image?(extname)
  66. 911 !!(extname =~ image_extname_regex)
  67. end
  68. 1 def sound?(extname)
  69. 997 !!(extname =~ sound_extname_regex)
  70. end
  71. 1 def movie?(extname)
  72. 1459 !!(extname =~ movie_extname_regex)
  73. end
  74. end
  75. 1 attr_reader :mode
  76. 1 def initialize(path:, mode:)
  77. 539 @mode = mode.to_sym
  78. 539 fail Error, "Open for read, create for write, ??? for #{@mode}" unless %i[read write].include?(@mode)
  79. 539 @path = path
  80. 539 @path.close if @path && @path.respond_to?(:close) # NOTE we operate on closed files
  81. 539 path! # NOTE early (exception) raiser
  82. end
  83. 1 def label
  84. basename
  85. end
  86. 1 def path
  87. 2401 path!
  88. end
  89. # Info
  90. 1 def exist?
  91. 5 File.exist? path
  92. end
  93. 1 def basename
  94. @basename ||= File.basename(path)
  95. end
  96. 1 def extname
  97. 3381 @extname ||= File.extname(path)
  98. end
  99. 1 def channel?(medium)
  100. 1908 case medium
  101. when :video
  102. 911 self.class.image?(extname) || self.class.movie?(extname)
  103. when :audio
  104. 997 self.class.sound?(extname) || self.class.movie?(extname)
  105. end
  106. end
  107. 1 def length(force=false)
  108. 42 @duration = nil if force
  109. 42 return @duration if @duration
  110. # NOTE first attempt
  111. 39 @duration = probe(force)['format']['duration']
  112. 39 @duration &&= @duration.to_f
  113. 39 return @duration if @duration
  114. # NOTE a harder try
  115. @duration = probe(true)['frames'].reduce(0) do |sum, frame|
  116. sum + frame['pkt_duration_time'].to_f
  117. end
  118. end
  119. 1 def resolution
  120. 5 v_stream = probe['streams'].first
  121. 5 "#{v_stream['width']}x#{v_stream['height']}"
  122. end
  123. # Manipulation
  124. 1 def read
  125. File.read path
  126. end
  127. 1 def write(s)
  128. 2 File.write path, s
  129. end
  130. 1 def unlink
  131. 322 if path.respond_to? :unlink
  132. path.unlink
  133. else
  134. 322 FileUtils.remove_entry path
  135. end
  136. 322 Ffmprb.logger.debug{"Removed file with path: #{path}"}
  137. 322 @path = nil
  138. end
  139. 1 private
  140. 1 def path!
  141. (
  142. 2940 @path.respond_to?(:path)? @path.path : @path
  143. ).tap do |path|
  144. # TODO ensure readabilty/writability/readiness
  145. 2940 fail Error, "'#{path}' is un#{@mode.to_s[0..3]}able" unless path && !path.empty?
  146. end
  147. end
  148. 1 def probe(harder=false)
  149. 44 return @probe unless !@probe || harder
  150. 39 cmd = ['-v', 'quiet', '-i', path, '-print_format', 'json', '-show_format', '-show_streams']
  151. 39 cmd << '-show_frames' if harder
  152. 39 @probe = JSON.parse(Util::ffprobe *cmd).tap do |probe|
  153. 39 fail Error, "This doesn't look like a ffprobable file" unless probe['streams']
  154. end
  155. end
  156. end
  157. end
  158. 1 require_relative 'file/sample'
  159. 1 require_relative 'file/threaded_buffered'

lib/ffmprb/file/sample.rb

91.3% lines covered

23 relevant lines. 21 lines covered and 2 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class File
  3. 1 def sample(
  4. at: 0.01,
  5. video: true,
  6. audio: true,
  7. &blk
  8. )
  9. 94 audio = File.temp('.wav') if audio == true
  10. 94 video = File.temp('.png') if video == true
  11. 94 Ffmprb.logger.debug{"Snap shooting files, video path: #{video ? video.path : 'NONE'}, audio path: #{audio ? audio.path : 'NONE'}"}
  12. 94 fail Error, "Incorrect output extname (must be image)" unless !video || video.channel?(:video) && !video.channel?(:audio)
  13. 94 fail Error, "Incorrect audio extname (must be sound)" unless !audio || audio.channel?(:audio) && !audio.channel?(:video)
  14. 94 fail Error, "Can sample either video OR audio UNLESS a block is given" unless block_given? || !!audio != !!video
  15. 94 cmd = %W[-i #{path}]
  16. 94 cmd.concat %W[-deinterlace -an -ss #{at} -vframes 1 #{video.path}] if video
  17. 94 cmd.concat %W[-vn -ss #{at} -t 1 #{audio.path}] if audio
  18. 94 Util.ffmpeg *cmd
  19. 82 return video || audio unless block_given?
  20. begin
  21. 66 yield *[video || nil, audio || nil].compact
  22. ensure
  23. begin
  24. 66 video.unlink if video
  25. 66 audio.unlink if audio
  26. 66 Ffmprb.logger.debug{"Removed sample files"}
  27. rescue
  28. Ffmprb.logger.warn "#{$!.class.name} removing sample files: #{$!.message}"
  29. end
  30. end
  31. end
  32. 1 def sample_video(*video, at: 0.01, &blk)
  33. sample at: at, video: (video.first || true), audio: false, &blk
  34. end
  35. 1 def sample_audio(*audio, at: 0.01, &blk)
  36. 12 sample at: at, video: false, audio: (audio.first || true), &blk
  37. end
  38. end
  39. end

lib/ffmprb/file/threaded_buffered.rb

100.0% lines covered

23 relevant lines. 23 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class File
  3. 1 class << self
  4. 1 def threaded_buffered_fifo(extname='.tmp', reader_open_on_writer_idle_limit: nil, proc_vis: nil)
  5. 14 input_fifo_file = temp_fifo(extname)
  6. 14 output_fifo_file = temp_fifo(extname)
  7. 14 Ffmprb.logger.debug{"Opening #{input_fifo_file.path}>#{output_fifo_file.path} for buffering"}
  8. 14 Util::Thread.new do
  9. begin
  10. 14 io_buff = Util::ThreadedIoBuffer.new(opener(input_fifo_file, 'r'), opener(output_fifo_file, 'w'), keep_outputs_open_on_input_idle_limit: reader_open_on_writer_idle_limit)
  11. 14 if proc_vis
  12. 9 proc_vis.proc_vis_edge input_fifo_file, io_buff
  13. 9 proc_vis.proc_vis_edge io_buff, output_fifo_file
  14. end
  15. begin
  16. # yield input_fifo_file, output_fifo_file, io_buff if block_given?
  17. ensure
  18. 14 Util::Thread.join_children!
  19. end
  20. 13 Ffmprb.logger.debug{"IoBuffering from #{input_fifo_file.path} to #{output_fifo_file.path} ended"}
  21. ensure
  22. 14 input_fifo_file.unlink if input_fifo_file
  23. 14 output_fifo_file.unlink if output_fifo_file
  24. end
  25. end
  26. 14 Ffmprb.logger.debug{"IoBuffering from #{input_fifo_file.path} to #{output_fifo_file.path} started"}
  27. 14 [input_fifo_file, output_fifo_file]
  28. end
  29. end
  30. 1 def threaded_buffered_copy_to(*dsts)
  31. 15 Util::ThreadedIoBuffer.new(
  32. self.class.opener(self, 'r'),
  33. 178 *dsts.map{|io| self.class.opener io, 'w'}
  34. ).tap do |io_buff|
  35. 15 proc_vis_edge self, io_buff
  36. 193 dsts.each{ |dst| proc_vis_edge io_buff, dst }
  37. end
  38. end
  39. end
  40. end

lib/ffmprb/filter.rb

93.08% lines covered

159 relevant lines. 148 lines covered and 11 lines missed.
    
  1. 1 module Ffmprb
  2. 1 module Filter
  3. 1 class Error < Ffmprb::Error; end
  4. 1 class << self
  5. 1 attr_accessor :silence_noise_max_db
  6. 1 def alphamerge(inputs, output=nil)
  7. 6 inout 'alphamerge', inputs, output
  8. end
  9. 1 def afade_in(duration, input=nil, output=nil)
  10. 7 inout 'afade=in:d=%{duration}:curve=hsin', input, output, duration: duration
  11. end
  12. 1 def afade_out(duration, input=nil, output=nil)
  13. 7 inout 'afade=out:d=%{duration}:curve=hsin', input, output, duration: duration
  14. end
  15. 1 def amix_to_first_same_volume(inputs, output=nil)
  16. 12 filters = []
  17. 12 new_inputs = inputs.map do |input|
  18. 24 if input == inputs.first
  19. 12 input
  20. else
  21. 12 "apd#{input}".tap do |lbl_aux|
  22. 12 filters +=
  23. inout("apad", input, lbl_aux) # NOTE we'll see if we really need this filter separate
  24. end
  25. end
  26. end
  27. 12 filters +
  28. inout('amix=%{inputs_count}:duration=shortest:dropout_transition=0, volume=%{inputs_count}',
  29. 12 new_inputs, output, inputs_count: (inputs.empty?? nil : inputs.size))
  30. end
  31. 1 def anull(input=nil, output=nil)
  32. 467 inout 'anull', input, output
  33. end
  34. 1 def anullsink(input=nil)
  35. inout 'anullsink', input, nil
  36. end
  37. 1 def asplit(inputs=nil, outputs=nil)
  38. 2 inout 'asplit', inputs, outputs
  39. end
  40. 1 def areverse(input=nil, output=nil)
  41. 1 inout 'areverse', input, output
  42. end
  43. 1 def atempo(tempo, input=nil, output=nil)
  44. fail Error, "Push the tempo!" unless
  45. 3 tempo > 0
  46. 3 fil = ''
  47. 3 tmp = tempo
  48. 3 while tmp > 2.0
  49. 1 fil += 'atempo=2.0, '
  50. 1 tmp /= 2.0
  51. end
  52. 3 while tmp < 0.5
  53. fil += 'atempo=0.5, '
  54. tmp /= 0.5
  55. end
  56. 3 inout "#{fil}atempo=#{tmp.to_f}", input, output
  57. end
  58. 1 def atrim(st, en=nil, input=nil, output=nil)
  59. 46 inout 'atrim=%{start_end}, asetpts=PTS-STARTPTS', input, output,
  60. start_end: [st, en].compact.join(':')
  61. end
  62. 1 def blank_source(duration, resolution, fps, output=nil)
  63. 38 color_source '0x000000@0', duration, resolution, fps, output
  64. end
  65. 1 def blend_a(duration, inputs, output=nil)
  66. 7 fail Error, "must be given 2 inputs" unless inputs.size == 2
  67. 7 aux_lbl = "blnd#{inputs[0]}"
  68. 7 auxx_lbl = "x#{aux_lbl}"
  69. [
  70. 7 *afade_out(duration, inputs[0], aux_lbl),
  71. *afade_in(duration, inputs[1], auxx_lbl),
  72. *amix_to_first_same_volume([auxx_lbl, aux_lbl], output)
  73. ]
  74. end
  75. 1 def blend_v(duration, resolution, fps, inputs, output=nil)
  76. 6 fail Error, "must be given 2 inputs" unless inputs.size == 2
  77. 6 aux_lbl = "blnd#{inputs[0]}"
  78. 6 auxx_lbl = "x#{aux_lbl}"
  79. [
  80. 6 *white_source(duration, resolution, fps, aux_lbl),
  81. *inout([
  82. *alphamerge([inputs[0], aux_lbl]),
  83. *fade_out_alpha(duration)
  84. ].join(', '), nil, auxx_lbl),
  85. *overlay(0, 0, [inputs[1], auxx_lbl], output),
  86. ]
  87. end
  88. 1 def color_source(color, duration, resolution, fps, output=nil)
  89. 44 inout 'color=%{color}:d=%{duration}:s=%{resolution}:r=%{fps}', nil, output,
  90. color: color, duration: duration, resolution: resolution, fps: fps
  91. end
  92. 1 def concat_v(inputs, output=nil)
  93. 57 return copy(inputs, output) if inputs.size == 1
  94. 57 inout 'concat=%{count}:v=1:a=0', inputs, output, count: inputs.size
  95. end
  96. 1 def concat_a(inputs, output=nil)
  97. 61 return anull(inputs, output) if inputs.size == 1
  98. 61 inout 'concat=%{count}:v=0:a=1', inputs, output, count: inputs.size
  99. end
  100. 1 def concat_av(inputs, output=nil)
  101. fail Error, "must be given an even number of inputs" unless inputs.size.even?
  102. inout 'concat=%{count}:v=1:a=1', inputs, output, count: inputs.size/2
  103. end
  104. 1 def copy(input=nil, output=nil)
  105. 13 inout 'copy', input, output
  106. end
  107. # TODO unused at the moment
  108. 1 def crop(crop, input=nil, output=nil)
  109. inout 'crop=x=%{left}:y=%{top}:w=%{width}:h=%{height}', input, output, crop
  110. end
  111. 1 def crop_prop(crop, input=nil, output=nil)
  112. 8 inout 'crop=%{crop_exp}', input, output,
  113. crop_exp: crop_prop_exps(crop).join(':')
  114. end
  115. 1 def crop_prop_exps(crop)
  116. 8 exps = []
  117. 8 if crop[:left]
  118. 7 exps << "x=in_w*#{crop[:left]}"
  119. end
  120. 8 if crop[:top]
  121. 7 exps << "y=in_h*#{crop[:top]}"
  122. end
  123. 8 if crop[:right] && crop[:left]
  124. 5 fail Error, "Must specify two of {left, right, width} at most" if crop[:width]
  125. 5 crop[:width] = 1 - crop[:right] - crop[:left]
  126. 3 elsif crop[:width]
  127. 3 if !crop[:left] && crop[:right]
  128. crop[:left] = 1 - crop[:width] - crop[:right]
  129. exps << "x=in_w*#{crop[:left]}"
  130. end
  131. end
  132. 8 exps << "w=in_w*#{crop[:width]}"
  133. 8 if crop[:bottom] && crop[:top]
  134. 5 fail Error, "Must specify two of {top, bottom, height} at most" if crop[:height]
  135. 5 crop[:height] = 1 - crop[:bottom] - crop[:top]
  136. 3 elsif crop[:height]
  137. 3 if !crop[:top] && crop[:bottom]
  138. crop[:top] = 1 - crop[:height] - crop[:bottom]
  139. exps << "y=in_h*#{crop[:top]}"
  140. end
  141. end
  142. 8 exps << "h=in_h*#{crop[:height]}"
  143. 8 exps
  144. end
  145. 1 def fade_out_alpha(duration, input=nil, output=nil)
  146. 6 inout 'fade=out:d=%{duration}:alpha=1', input, output, duration: duration
  147. end
  148. 1 def fps(fps, input=nil, output=nil)
  149. 153 inout 'fps=fps=%{fps}', input, output, fps: fps
  150. end
  151. 1 def interpolate_v(fps, input=nil, output=nil)
  152. 157 inout 'framerate=fps=%{fps}', input, output, fps: fps
  153. end
  154. # TODO other effects like... minterpolate=fps=%{fps}:mi_mode=mci:mc_mode=aobmc:vsbmc=1
  155. # NOTE might be very useful with UGC: def cropdetect
  156. 1 def nullsink(input=nil)
  157. inout 'nullsink', input, nil
  158. end
  159. 1 def overlay(x=0, y=0, inputs=nil, output=nil)
  160. 6 inout 'overlay=x=%{x}:y=%{y}:eof_action=pass', inputs, output, x: x, y: y
  161. end
  162. 1 def pad(resolution, input=nil, output=nil)
  163. 153 width, height = resolution.to_s.split('x')
  164. 153 inout [
  165. inout('pad=%{width}:%{height}:(%{width}-iw*min(%{width}/iw\\,%{height}/ih))/2:(%{height}-ih*min(%{width}/iw\\,%{height}/ih))/2',
  166. width: width, height: height),
  167. *setsar(1) # NOTE the scale & pad formulae damage SAR a little, unfortunately
  168. ].join(', '), input, output
  169. end
  170. 1 def pp(input=nil, output=nil)
  171. 1 inout 'pp=hb/vb/dr/al', input, output
  172. end
  173. 1 def reverse(input=nil, output=nil)
  174. 1 inout 'reverse', input, output
  175. end
  176. 1 def setsar(ratio, input=nil, output=nil)
  177. 306 inout 'setsar=%{ratio}', input, output, ratio: ratio
  178. end
  179. 1 def setpts(ratio, input=nil, output=nil)
  180. 3 inout 'setpts=%{r_fps}*PTS', input, output, r_fps: 1.0/ratio
  181. end
  182. 1 def scale(resolution, input=nil, output=nil)
  183. 153 width, height = resolution.to_s.split('x')
  184. 153 inout [
  185. inout('scale=iw*min(%{width}/iw\\,%{height}/ih):ih*min(%{width}/iw\\,%{height}/ih)', width: width, height: height),
  186. *setsar(1) # NOTE the scale & pad formulae damage SAR a little, unfortunately
  187. ].join(', '), input, output
  188. end
  189. 1 def scale_pad(resolution, input=nil, output=nil)
  190. 153 inout [
  191. *scale(resolution),
  192. *pad(resolution)
  193. ].join(', '), input, output
  194. end
  195. 1 def scale_pad_fps(resolution, _fps, input=nil, output=nil)
  196. 145 inout [
  197. *scale_pad(resolution),
  198. *fps(_fps)
  199. ].join(', '), input, output
  200. end
  201. 1 def silencedetect(input=nil, output=nil)
  202. 4 inout 'silencedetect=d=1:n=%{silence_noise_max_db}dB', input, output,
  203. silence_noise_max_db: silence_noise_max_db
  204. end
  205. 1 def silent_source(duration, output=nil)
  206. 41 inout 'aevalsrc=0:d=%{duration}', nil, output, duration: duration
  207. end
  208. # NOTE might be very useful with transitions: def smartblur
  209. 1 def split(inputs=nil, outputs=nil)
  210. 2 inout 'split', inputs, outputs
  211. end
  212. 1 def trim(st, en=nil, input=nil, output=nil)
  213. 42 inout 'trim=%{start_end}, setpts=PTS-STARTPTS', input, output,
  214. start_end: [st, en].compact.join(':')
  215. end
  216. 1 def volume(volume, input=nil, output=nil)
  217. 10 inout "volume='%{volume_exp}':eval=frame", input, output,
  218. volume_exp: volume_exp(volume)
  219. end
  220. # NOTE supposedly volume list is sorted
  221. 1 def volume_exp(volume)
  222. 10 return volume unless volume.is_a?(Hash)
  223. 6 fail Error, "volume cannot be empty" if volume.empty?
  224. 6 prev_at = 0.0
  225. 6 prev_vol = volume[prev_at] || 1.0
  226. 6 exp = "#{volume[volume.keys.last]}"
  227. 6 volume.each do |at, vol|
  228. 32 next if at == 0.0
  229. vol_exp =
  230. 28 if (vol - prev_vol).abs < 0.001
  231. 11 vol
  232. else
  233. 17 "(#{vol - prev_vol}*t + #{prev_vol*at - vol*prev_at})/#{at - prev_at}"
  234. end
  235. 28 exp = "if(between(t, #{prev_at}, #{at}), #{vol_exp}, #{exp})"
  236. 28 prev_at = at
  237. 28 prev_vol = vol
  238. end
  239. 6 exp
  240. end
  241. 1 def white_source(duration, resolution, fps, output=nil)
  242. 6 color_source '0xFFFFFF@1', duration, resolution, fps, output
  243. end
  244. 1 def complex_args(*filters)
  245. 81 [].tap do |args|
  246. args << '-filter_complex' << filters.join('; ') unless
  247. 81 filters.empty?
  248. end
  249. end
  250. 1 private
  251. 1 def inout(filter, inputs=nil, outputs=nil, **values)
  252. 2394 values.each do |key, value|
  253. 1720 fail Error, "#{filter} needs #{key}" if value.to_s.empty?
  254. end
  255. 2394 filter = filter % values
  256. 3859 filter = "#{[*inputs].map{|s| "[#{s}]"}.join ' '} " + filter if inputs
  257. 3568 filter = filter + " #{[*outputs].map{|s| "[#{s}]"}.join ' '}" if outputs
  258. 2394 [filter]
  259. end
  260. end
  261. end
  262. end

lib/ffmprb/find_silence.rb

82.61% lines covered

23 relevant lines. 19 lines covered and 4 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class << self
  3. # NOTE not for streaming just yet
  4. 1 def find_silence(input_file, output_file)
  5. 4 path = "#{input_file.path}->#{output_file.path}"
  6. 4 logger.debug{"Finding silence (#{path})"}
  7. 4 silence = []
  8. 4 Util.ffmpeg('-i', input_file.path, *find_silence_detect_args, output_file.path).
  9. scan(SILENCE_DETECT_REGEX).each do |mark, time|
  10. 14 time = time.to_f
  11. 14 case mark
  12. when 'start'
  13. 7 silence << OpenStruct.new(start_at: time)
  14. when 'end'
  15. 7 if silence.empty?
  16. silence << OpenStruct.new(start_at: 0.0, end_at: time)
  17. else
  18. 7 fail Error, "ffmpeg is being stupid: silence_end with no silence_start" if silence.last.end_at
  19. 7 silence.last.end_at = time
  20. end
  21. else
  22. Ffmprb.warn "Unknown silence mark: #{mark}"
  23. end
  24. end
  25. 4 logger.debug{
  26. silence_map = silence.map{|t,v| "#{t}: #{v}"}
  27. "Found silence (#{path}): [#{silence_map}]"
  28. }
  29. 4 silence
  30. end
  31. 1 private
  32. 1 SILENCE_DETECT_REGEX = /\[silencedetect\s.*\]\s*silence_(\w+):\s*(\d+\.?\d*)/
  33. 1 def find_silence_detect_args
  34. 4 Filter.complex_args Filter.silencedetect
  35. end
  36. end
  37. end

lib/ffmprb/process.rb

96.08% lines covered

102 relevant lines. 98 lines covered and 4 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 include Util::ProcVis::Node
  4. 1 class << self
  5. 1 attr_accessor :duck_audio_volume_hi, :duck_audio_volume_lo,
  6. :duck_audio_silent_min
  7. 1 attr_accessor :duck_audio_transition_length,
  8. :duck_audio_transition_in_start, :duck_audio_transition_out_start
  9. 1 attr_accessor :input_video_auto_rotate
  10. 1 attr_accessor :input_video_fps
  11. 1 attr_accessor :output_video_resolution
  12. 1 attr_accessor :output_video_fps
  13. 1 attr_accessor :output_audio_encoder
  14. 1 attr_accessor :output_audio_sampling_freq
  15. 1 attr_accessor :timeout
  16. 1 def intermediate_channel_extname(video:, audio:)
  17. 12 if video
  18. 6 if audio
  19. 6 '.flv' # TODO optimise this by using http://superuser.com/a/522853 or something
  20. else
  21. '.y4m'
  22. end
  23. else
  24. 6 if audio
  25. 6 '.wav'
  26. else
  27. fail Error, "I don't know how to channel [#{media.join ', '}]"
  28. end
  29. end
  30. end
  31. 1 def input_video_options
  32. {
  33. 404 auto_rotate: input_video_auto_rotate,
  34. fps: input_video_fps # TODO seen failing on apng (w/ffmpeg v4.x)
  35. }
  36. end
  37. 1 def input_audio_options
  38. 481 {
  39. }
  40. end
  41. 1 def output_video_options
  42. {
  43. 105 fps: output_video_fps,
  44. resolution: output_video_resolution
  45. }
  46. end
  47. 1 def output_audio_options
  48. {
  49. 126 encoder: output_audio_encoder,
  50. sampling_freq: output_audio_sampling_freq
  51. }
  52. end
  53. # NOTE Temporarily, av_main_i/o and not a_main_i/o
  54. 1 def duck_audio(av_main_i, a_overlay_i, silence, av_main_o,
  55. volume_lo: duck_audio_volume_lo,
  56. volume_hi: duck_audio_volume_hi,
  57. silent_min: duck_audio_silent_min,
  58. process_options: {},
  59. video:, # NOTE Temporarily, video should not be here
  60. audio:
  61. )
  62. 3 Ffmprb.process **process_options do
  63. 3 in_main = input(av_main_i)
  64. 3 in_over = input(a_overlay_i)
  65. 3 output(av_main_o, video: video, audio: audio) do
  66. 3 roll in_main
  67. 3 ducked_overlay_volume = {0.0 => volume_lo}
  68. 3 silence.each do |silent|
  69. 5 next if silent.end_at && silent.start_at && (silent.end_at - silent.start_at) < silent_min
  70. 5 if silent.start_at
  71. 5 transition_in_start = silent.start_at + Process.duck_audio_transition_in_start
  72. 5 ducked_overlay_volume.merge!(
  73. [transition_in_start, 0.0].max => volume_lo,
  74. 5 (transition_in_start + Process.duck_audio_transition_length) => volume_hi
  75. )
  76. end
  77. 5 if silent.end_at
  78. 5 transition_out_start = silent.end_at + Process.duck_audio_transition_out_start
  79. 5 ducked_overlay_volume.merge!(
  80. [transition_out_start, 0.0].max => volume_hi,
  81. 5 (transition_out_start + Process.duck_audio_transition_length) => volume_lo
  82. )
  83. end
  84. end
  85. 3 overlay in_over.volume ducked_overlay_volume
  86. 3 Ffmprb.logger.debug{
  87. ducked_overlay_volume_map = ducked_overlay_volume.map{|t,v| "#{t}: #{v}"}
  88. "Ducking audio with volumes: {#{ducked_overlay_volume_map.join ', '}}"
  89. }
  90. end
  91. end
  92. end
  93. end
  94. 1 attr_accessor :timeout
  95. 1 attr_accessor :name
  96. 1 attr_reader :parent
  97. 1 attr_accessor :ignore_broken_pipes
  98. 1 def initialize(*args, **opts)
  99. 60 self.timeout = opts.delete(:timeout) || Process.timeout
  100. 60 @name = opts.delete(:name)
  101. 60 @parent = opts.delete(:parent)
  102. 60 parent.proc_vis_node self if parent
  103. 60 self.ignore_broken_pipes = opts.delete(:ignore_broken_pipes)
  104. 60 Util.assert_options_empty! opts
  105. 59 @inputs, @outputs = [], []
  106. end
  107. 1 def input(io, video: true, audio: true)
  108. 259 Input.new(io, self,
  109. video: channel_params(video, Process.input_video_options),
  110. audio: channel_params(audio, Process.input_audio_options)
  111. ).tap do |inp|
  112. 259 fail Error, "Too many inputs to the process, try breaking it down somehow" if @inputs.size > Util.ffmpeg_inputs_max
  113. 258 @inputs << inp
  114. 258 proc_vis_edge inp.io, self
  115. end
  116. end
  117. 1 def input_label(input)
  118. 245 @inputs.index input
  119. end
  120. 1 def output(io, video: true, audio: true, &blk)
  121. 59 Output.new(io, self,
  122. video: channel_params(video, Process.output_video_options),
  123. audio: channel_params(audio, Process.output_audio_options)
  124. ).tap do |outp|
  125. 59 @outputs << outp
  126. 59 proc_vis_edge self, outp.io
  127. 59 outp.instance_exec &blk if blk
  128. end
  129. end
  130. 1 def output_index(output)
  131. 58 @outputs.index output
  132. end
  133. # NOTE the one and the only entry-point processing function which spawns threads etc
  134. 1 def run(limit: nil) # TODO (async: false)
  135. # NOTE this is both for the future async: option and according to
  136. # the threading policy (a parent death will be noticed and handled by children)
  137. 57 thr = Util::Thread.new main: !parent do
  138. 57 proc_vis_node Thread.current
  139. # NOTE yes, an exception can occur anytime, and we'll just die, it's ok, see above
  140. 57 cmd = command
  141. 57 opts = {limit: limit, timeout: timeout}
  142. 57 opts[:ignore_broken_pipes] = ignore_broken_pipes unless ignore_broken_pipes.nil?
  143. 57 Util.ffmpeg(*cmd, **opts).tap do |res|
  144. 52 Util::Thread.join_children! limit, timeout: timeout
  145. end
  146. 52 proc_vis_node Thread.current, :remove
  147. end
  148. 57 thr.value if thr.join limit # NOTE should not block for more than limit
  149. end
  150. 1 private
  151. 1 def command
  152. 57 input_args + filter_args + output_args
  153. end
  154. 1 def input_args
  155. 57 filter_args # NOTE must run first
  156. 57 @input_args ||= @inputs.map(&:args).reduce(:+)
  157. end
  158. # NOTE must run first
  159. 1 def filter_args
  160. 171 @filter_args ||= Filter.complex_args(
  161. @outputs.map(&:filters).reduce :+
  162. )
  163. end
  164. 1 def output_args
  165. 57 filter_args # NOTE must run first
  166. 57 @output_args ||= @outputs.map(&:args).reduce(:+)
  167. end
  168. 1 def channel_params(value, default)
  169. 625 default.merge(value.respond_to?(:to_h)? value.to_h : {}) unless
  170. 636 value == false
  171. end
  172. end
  173. end
  174. 1 require_relative 'process/input'
  175. 1 require_relative 'process/output'

lib/ffmprb/process/input.rb

95.31% lines covered

64 relevant lines. 61 lines covered and 3 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 class << self
  5. 1 def resolve(io)
  6. return io unless
  7. 259 io.is_a? String
  8. 3 File.access(io).tap do |file|
  9. 3 Ffmprb.logger.warn "Input file does no exist (#{file.path}), will probably fail" unless file.exist?
  10. end
  11. end
  12. # TODO! check for unknown options
  13. 1 def video_args(video=nil)
  14. 145 video = Process.input_video_options.merge(video.to_h)
  15. 145 [].tap do |args|
  16. 145 fps = nil # NOTE ah, ruby
  17. 145 args.concat %W[-noautorotate] unless video.delete(:auto_rotate)
  18. 145 args.concat %W[-r #{fps}] if (fps = video.delete(:fps))
  19. 145 Util.assert_options_empty! video
  20. end
  21. end
  22. 1 def audio_args(audio=nil)
  23. 222 audio = Process.input_audio_options.merge(audio.to_h)
  24. 222 [].tap do |args|
  25. 222 Util.assert_options_empty! audio
  26. end
  27. end
  28. end
  29. 1 attr_accessor :io
  30. 1 attr_reader :process
  31. 1 def initialize(io, process, video:, audio:)
  32. 259 @io = self.class.resolve(io)
  33. 259 @process = process
  34. @channels = {
  35. 259 video: video && @io.channel?(:video) && OpenStruct.new(video),
  36. audio: audio && @io.channel?(:audio) && OpenStruct.new(audio)
  37. }
  38. end
  39. 1 def copy(input)
  40. 6 input.chain_copy self
  41. end
  42. 1 def args
  43. 225 [].tap do |args|
  44. 225 args.concat self.class.video_args(channel :video) if channel? :video
  45. 225 args.concat self.class.audio_args(channel :audio) if channel? :audio
  46. 225 args.concat ['-i', io.path]
  47. end
  48. end
  49. 1 def filters_for(lbl, video:, audio:)
  50. 245 in_lbl = process.input_label(self)
  51. [
  52. 245 *(if video && channel?(:video)
  53. 157 if video.resolution && video.fps
  54. 145 Filter.scale_pad_fps video.resolution, video.fps, "#{in_lbl}:v", "#{lbl}:v"
  55. 12 elsif video.resolution
  56. Filter.scale_pad video.resolution, "#{in_lbl}:v", "#{lbl}:v"
  57. 12 elsif video.fps
  58. 8 Filter.fps video.fps, "#{in_lbl}:v", "#{lbl}:v"
  59. else
  60. 4 Filter.copy "#{in_lbl}:v", "#{lbl}:v"
  61. end
  62. 88 elsif video
  63. fail Error, "No video stream to provide"
  64. end),
  65. 245 *(if audio && channel?(:audio)
  66. 233 Filter.anull "#{in_lbl}:a", "#{lbl}:a"
  67. 12 elsif audio
  68. fail Error, "No audio stream to provide"
  69. end)
  70. ]
  71. end
  72. 1 def channel?(medium)
  73. 1134 io.channel? medium
  74. end
  75. 1 def channel(medium)
  76. 367 @channels[medium]
  77. end
  78. 1 def chain_copy(src_input)
  79. 6 src_input
  80. end
  81. end
  82. end
  83. end
  84. 1 require_relative 'input/chain_base'
  85. 1 require_relative 'input/channeled'
  86. 1 require_relative 'input/cropped'
  87. 1 require_relative 'input/cut'
  88. 1 require_relative 'input/looping'
  89. 1 require_relative 'input/loud'
  90. 1 require_relative 'input/paced'
  91. 1 require_relative 'input/postprocessed'
  92. 1 require_relative 'input/reversed'
  93. 1 require_relative 'input/temp'

lib/ffmprb/process/input/chain_base.rb

100.0% lines covered

13 relevant lines. 13 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 class ChainBase < Input
  5. 1 def initialize(unfiltered)
  6. 85 @io = unfiltered
  7. end
  8. 83 def unfiltered; @io; end
  9. 3 def unfiltered=(input); @io = input; end
  10. 1 def chain_copy(src_input) # TODO SPEC ME
  11. 2 dup.tap do |top|
  12. 2 top.unfiltered = unfiltered.chain_copy(src_input)
  13. end
  14. end
  15. 1 def filters_for(lbl, video:, audio:)
  16. # Doing nothing
  17. 70 unfiltered.filters_for lbl, video: video, audio: audio
  18. end
  19. end
  20. end
  21. end
  22. end

lib/ffmprb/process/input/channeled.rb

93.33% lines covered

15 relevant lines. 14 lines covered and 1 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def video
  5. 4 Channeled.new self, audio: false
  6. end
  7. 1 def audio
  8. 4 Channeled.new self, video: false
  9. end
  10. 1 class Channeled < ChainBase
  11. 1 def initialize(unfiltered, video: true, audio: true)
  12. 8 super unfiltered
  13. 8 @limited_channels = {video: video, audio: audio}
  14. end
  15. 1 def channel(medium)
  16. super(medium) if @limited_channels[medium]
  17. end
  18. 1 def filters_for(lbl, video:, audio:)
  19. # Doing basically nothing
  20. 8 unfiltered.filters_for lbl,
  21. video: channel?(:video) && video, audio: channel?(:audio) && audio
  22. end
  23. end
  24. end
  25. end
  26. end

lib/ffmprb/process/input/cropped.rb

100.0% lines covered

30 relevant lines. 30 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def crop(ratio) # NOTE ratio is either a CROP_PARAMS symbol-ratio hash or a single (global) ratio
  5. 9 Cropped.new self, crop: ratio
  6. end
  7. 1 class Cropped < ChainBase
  8. 1 attr_reader :ratios
  9. 1 def initialize(unfiltered, crop:)
  10. 9 super unfiltered
  11. 9 self.ratios = crop
  12. end
  13. 1 def filters_for(lbl, video:, audio:)
  14. # Cropping
  15. 8 lbl_aux = "cp#{lbl}"
  16. 8 lbl_tmp = "tmp#{lbl}"
  17. 8 super(lbl_aux, video: unsize(video), audio: audio) +
  18. [
  19. 8 *((video && channel?(:video))? [
  20. Filter.crop_prop(ratios, "#{lbl_aux}:v", "#{lbl_tmp}:v"),
  21. Filter.scale_pad(video.resolution, "#{lbl_tmp}:v", "#{lbl}:v")
  22. ]: nil),
  23. 8 *((audio && channel?(:audio))? Filter.anull("#{lbl_aux}:a", "#{lbl}:a"): nil)
  24. ]
  25. end
  26. 1 private
  27. 1 CROP_PARAMS = %i[top left bottom right width height]
  28. 1 def unsize(video)
  29. 8 fail Error, "requires resolution" unless video.resolution
  30. 8 OpenStruct.new(video).tap do |video|
  31. 8 video.resolution = nil
  32. end
  33. end
  34. 1 def ratios=(ratios)
  35. @ratios =
  36. 9 if ratios.is_a?(Numeric)
  37. 6 {top: ratios, left: ratios, bottom: ratios, right: ratios}
  38. else
  39. 3 ratios
  40. end.tap do |ratios| # NOTE validation
  41. fail "Allowed crop params are: #{CROP_PARAMS}" unless
  42. 9 ratios && ratios.respond_to?(:keys) && (ratios.keys - CROP_PARAMS).empty?
  43. 9 ratios.each do |key, value|
  44. fail Error, "Crop #{key} must be between 0 and 1 (not '#{value}')" unless
  45. 34 (0...1).include? value
  46. end
  47. fail Error, "Unreasonable crop args (#{ratios})" unless
  48. 9 (!ratios.include?(:left) || !ratios.include?(:right) || ratios[:left] + ratios[:right] < 1) &&
  49. (!ratios.include?(:top) || !ratios.include?(:bottom) || ratios[:top] + ratios[:bottom] < 1)
  50. end
  51. end
  52. end
  53. end
  54. end
  55. end

lib/ffmprb/process/input/cut.rb

92.59% lines covered

27 relevant lines. 25 lines covered and 2 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def cut(from: 0, to: nil)
  5. 47 Cut.new self, from: from, to: to
  6. end
  7. 1 class Cut < ChainBase
  8. 1 attr_reader :from, :to
  9. 1 def initialize(unfiltered, from:, to:)
  10. 47 super unfiltered
  11. 47 @from = from
  12. 47 @to = to.to_f == 0 ? nil : to
  13. 47 fail Error, "cut from: must be" unless from
  14. 47 fail Error, "cut from: must be less than to:" unless !to || from < to
  15. end
  16. 1 def filters_for(lbl, video:, audio:)
  17. fail Error, "cut needs resolution and fps (reorder your filters?)" unless
  18. 47 !video || (video.resolution && video.fps)
  19. # Trimming
  20. 47 lbl_aux = "tm#{lbl}"
  21. 47 super(lbl_aux, video: video, audio: audio) +
  22. 47 if to
  23. 44 lbl_blk = "bl#{lbl}"
  24. 44 lbl_pad = "pd#{lbl}"
  25. [
  26. 44 *((video && channel?(:video))?
  27. Filter.blank_source(to - from, video.resolution, video.fps, "#{lbl_blk}:v") +
  28. Filter.concat_v(["#{lbl_aux}:v", "#{lbl_blk}:v"], "#{lbl_pad}:v") +
  29. Filter.trim(from, to, "#{lbl_pad}:v", "#{lbl}:v")
  30. : nil),
  31. 44 *((audio && channel?(:audio))?
  32. Filter.silent_source(to - from, "#{lbl_blk}:a") +
  33. Filter.concat_a(["#{lbl_aux}:a", "#{lbl_blk}:a"], "#{lbl_pad}:a") +
  34. Filter.atrim(from, to, "#{lbl_pad}:a", "#{lbl}:a")
  35. : nil)
  36. ]
  37. 3 elsif from == 0
  38. [
  39. *((video && channel?(:video))? Filter.copy("#{lbl_aux}:v", "#{lbl}:v"): nil),
  40. *((audio && channel?(:audio))? Filter.anull("#{lbl_aux}:a", "#{lbl}:a"): nil)
  41. ]
  42. else # !to
  43. [
  44. 3 *((video && channel?(:video))? Filter.trim(from, nil, "#{lbl_aux}:v", "#{lbl}:v"): nil),
  45. 3 *((audio && channel?(:audio))? Filter.atrim(from, nil, "#{lbl_aux}:a", "#{lbl}:a"): nil)
  46. ]
  47. end
  48. end
  49. end
  50. end
  51. end
  52. end

lib/ffmprb/process/input/looping.rb

100.0% lines covered

55 relevant lines. 55 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def loop(times=Util.ffmpeg_inputs_max)
  5. 6 Ffmprb.logger.warn "Looping more than #{Util.ffmpeg_inputs_max} times is 'unstable': either use double looping or ask for this feature" if times > Util.ffmpeg_inputs_max
  6. 6 Looping.new self, times
  7. end
  8. 1 class Looping < ChainBase
  9. 1 attr_reader :times
  10. 1 def initialize(unfiltered, times)
  11. 6 super unfiltered
  12. 6 @times = times
  13. 6 @raw = @_unfiltered = unfiltered
  14. # NOTE find the actual input io (not a filter)
  15. 6 @raw = @raw.unfiltered while @raw.respond_to? :unfiltered
  16. end
  17. 1 def filters_for(lbl, video:, audio:)
  18. # The plan:
  19. # 1) Create and route an aux input which would hold the filtered, looped and parameterised stream off the raw input (keep the raw input)
  20. # 2) Tee+buffer the original raw input io: one stream goes back into the process throw the raw input io replacement fifo; the other is fed into the filtering process
  21. # 3) Which uses the same underlying filters to produce a filtered and parameterised stream, which is fed into the looping process through a N-Tee+buffer
  22. # 4) Invoke the looping process which just concatenates its N inputs and produces the new raw input (the aux input)
  23. # Looping
  24. # NOTE all the processing is done before looping
  25. 6 aux_input(video: video, audio: audio).filters_for lbl,
  26. video: video && OpenStruct.new, audio: audio && OpenStruct.new
  27. end
  28. 1 protected
  29. 1 def aux_input(video:, audio:)
  30. 6 Ffmprb.logger.debug{"Creating aux inp with #{audio} / #{video}"}
  31. # NOTE (2)
  32. # NOTE replace the raw input io with a copy io, getting original fifo/file
  33. 6 intermediate_extname = Process.intermediate_channel_extname(video: @raw.io.channel?(:video), audio: @raw.io.channel?(:audio))
  34. 6 src_io = @raw.temporise_io!(intermediate_extname)
  35. 6 if src_io.extname != intermediate_extname # NOTE kinda like src_io is not suitable for piping
  36. 3 meh_src_io, src_io = src_io, File.temp_fifo(intermediate_extname)
  37. 3 Util::Thread.new "source converter" do
  38. 3 Ffmprb.process do
  39. 3 inp = input(meh_src_io)
  40. # TODO this is not properly tested, unfortunately
  41. 3 output src_io, video: video, audio: audio do
  42. 3 lay inp
  43. end
  44. end
  45. end
  46. end
  47. 6 cpy_io = File.temp_fifo(src_io.extname)
  48. 6 Ffmprb.logger.debug{"(L2) Temporising the raw input (#{src_io.path}) and creating copy (#{cpy_io.path})"}
  49. 6 src_io.threaded_buffered_copy_to @raw.io, cpy_io
  50. # NOTE (3)
  51. # NOTE preprocessed and filtered fifo
  52. 6 dst_io = File.temp_fifo(intermediate_extname)
  53. 6 @raw.process.proc_vis_node dst_io
  54. 6 Util::Thread.new "looping input processor" do
  55. 6 Ffmprb.logger.debug{"(L3) Pre-processing into (#{dst_io.path})"}
  56. 6 Ffmprb.process @_unfiltered, parent: @raw.process do |unfiltered| # TODO limit:
  57. 6 inp = input(cpy_io)
  58. 6 output(dst_io, video: video, audio: audio) do
  59. 6 lay inp.copy(unfiltered)
  60. end
  61. end
  62. end
  63. 163 buff_ios = (1..times).map{File.temp_fifo intermediate_extname}
  64. 6 Ffmprb.logger.debug{"Preprocessed #{dst_io.path} will be teed to #{buff_ios.map(&:path).join '; '}"}
  65. 6 Util::Thread.new "cloning buffer watcher" do
  66. 6 dst_io.threaded_buffered_copy_to(*buff_ios).tap do |io_buff|
  67. 6 Util::Thread.join_children!
  68. 6 Ffmprb.logger.warn "Looping ~from #{src_io.path} finished before its consumer: if you just wanted to loop input #{Util.ffmpeg_inputs_max} times, that's fine, but if you expected it to loop indefinitely... #{Util.ffmpeg_inputs_max} is the maximum #loop can do at the moment, and it may just not be enough in this case (workaround by concatting or something)." if times == Util.ffmpeg_inputs_max && io_buff.stats.blocks_buff == 0
  69. end
  70. end
  71. # NOTE additional (filtered, processed and looped) input io
  72. 6 aux_io = File.temp_fifo(intermediate_extname)
  73. # NOTE (4)
  74. 6 Util::Thread.new "looper" do
  75. 6 Ffmprb.logger.debug{"Looping #{buff_ios.size} times"}
  76. 6 Ffmprb.logger.debug{"(L4) Looping (#{buff_ios.map &:path}) into (#{aux_io.path})"}
  77. begin # NOTE may not write its entire output, it's ok
  78. 6 Ffmprb.process parent: @raw.process, ignore_broken_pipes: false do
  79. 163 ins = buff_ios.map{ |i| input i }
  80. 6 output(aux_io, video: video, audio: audio) do
  81. 163 ins.each{ |i| lay i }
  82. end
  83. end
  84. rescue Util::BrokenPipeError
  85. 4 looping_max = false # NOTE see the above warning
  86. end
  87. end
  88. # NOTE (1)
  89. 6 Ffmprb.logger.debug{"(L1) Creating a new input (#{aux_io.path}) to the process"}
  90. 6 @raw.process.input(aux_io)
  91. end
  92. end
  93. end
  94. end
  95. end

lib/ffmprb/process/input/loud.rb

100.0% lines covered

17 relevant lines. 17 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def mute
  5. 1 Loud.new self, volume: 0
  6. end
  7. 1 def volume(vol)
  8. 9 Loud.new self, volume: vol
  9. end
  10. 1 class Loud < ChainBase
  11. 1 def initialize(unfiltered, volume:)
  12. 10 super unfiltered
  13. 10 @volume = volume
  14. 10 fail Error, "volume cannot be nil" if volume.nil?
  15. end
  16. 1 def filters_for(lbl, video:, audio:)
  17. # Modulating volume
  18. 10 lbl_aux = "ld#{lbl}"
  19. 10 super(lbl_aux, video: video, audio: audio) +
  20. [
  21. 10 *((video && channel?(:video))? Filter.copy("#{lbl_aux}:v", "#{lbl}:v"): nil),
  22. 10 *((audio && channel?(:audio))? Filter.volume(@volume, "#{lbl_aux}:a", "#{lbl}:a"): nil)
  23. ]
  24. end
  25. end
  26. end
  27. end
  28. end

lib/ffmprb/process/input/paced.rb

100.0% lines covered

15 relevant lines. 15 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. # TODO? speed-up/slow-down
  5. 1 def pace(ratio)
  6. 3 Paced.new self, pace: ratio
  7. end
  8. 1 class Paced < ChainBase
  9. 1 attr_reader :ratio
  10. 1 def initialize(unfiltered, pace:)
  11. 3 super unfiltered
  12. 3 @ratio = pace
  13. end
  14. 1 def filters_for(lbl, video:, audio:)
  15. # Pacing
  16. 3 lbl_aux = "pc#{lbl}"
  17. 3 super(lbl_aux, video: video, audio: audio) +
  18. [
  19. 3 *((video && channel?(:video))? Filter.setpts(@ratio, "#{lbl_aux}:v", "#{lbl}:v"): nil),
  20. 3 *((audio && channel?(:audio))? Filter.atempo(@ratio, "#{lbl_aux}:a", "#{lbl}:a"): nil)
  21. ]
  22. end
  23. end
  24. end
  25. end
  26. end

lib/ffmprb/process/input/postprocessed.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def pp
  5. 1 Postprocessed.new self
  6. end
  7. # TODO test this somehow
  8. 1 class Postprocessed < ChainBase
  9. 1 def filters_for(lbl, video:, audio:)
  10. # Postprocessing
  11. 1 lbl_aux = "pp#{lbl}"
  12. 1 super(lbl_aux, video: video, audio: audio) +
  13. [
  14. 1 *((video && channel?(:video))? Filter.pp("#{lbl_aux}:v", "#{lbl}:v"): nil),
  15. 1 *((audio && channel?(:audio))? Filter.anull("#{lbl_aux}:a", "#{lbl}:a"): nil)
  16. ]
  17. end
  18. end
  19. end
  20. end
  21. end

lib/ffmprb/process/input/reversed.rb

100.0% lines covered

11 relevant lines. 11 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def reverse
  5. 1 Reversed.new self
  6. end
  7. 1 class Reversed < ChainBase
  8. # TODO check this is reasonable and not a (live) stream...
  9. 1 def filters_for(lbl, video:, audio:)
  10. # Reversing
  11. 1 lbl_aux = "rv#{lbl}"
  12. 1 super(lbl_aux, video: video, audio: audio) +
  13. [
  14. 1 *((video && channel?(:video))? Filter.reverse("#{lbl_aux}:v", "#{lbl}:v"): nil),
  15. 1 *((audio && channel?(:audio))? Filter.areverse("#{lbl_aux}:a", "#{lbl}:a"): nil)
  16. ]
  17. end
  18. end
  19. end
  20. end
  21. end

lib/ffmprb/process/input/temp.rb

100.0% lines covered

8 relevant lines. 8 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Input
  4. 1 def temporise_io!(extname=nil)
  5. 6 process.proc_vis_edge @io, process, :remove
  6. 6 @io.tap do
  7. 6 @io = File.temp_fifo(extname || io.extname)
  8. 6 process.proc_vis_edge @io, process
  9. end
  10. end
  11. end
  12. end
  13. end

lib/ffmprb/process/output.rb

95.0% lines covered

180 relevant lines. 171 lines covered and 9 lines missed.
    
  1. 1 module Ffmprb
  2. 1 class Process
  3. 1 class Output
  4. 1 class << self
  5. 1 def video_args(video=nil)
  6. 46 video = Process.output_video_options.merge(video.to_h)
  7. 46 [].tap do |args|
  8. 46 if (encoder = video.delete(:encoder)) # NOTE extra encoder options possible
  9. args.concat "-c:v #{encoder}".split(' ')
  10. end
  11. 46 if (pixel_format = video.delete(:pixel_format))
  12. args.concat %W[-pix_fmt #{pixel_format}]
  13. end
  14. 46 video.delete :resolution # NOTE is handled otherwise
  15. 46 video.delete :fps # NOTE is handled otherwise
  16. 46 Util.assert_options_empty! video
  17. end
  18. end
  19. 1 def audio_args(audio=nil)
  20. 67 audio = Process.output_audio_options.merge(audio.to_h)
  21. 67 [].tap do |args|
  22. 67 if (encoder = audio.delete(:encoder)) # NOTE extra encoder options possible
  23. 61 args.concat "-c:a #{encoder}".split(' ')
  24. end
  25. 67 if (sampling_freq = audio.delete(:sampling_freq))
  26. args.concat %W[-ar #{sampling_freq}]
  27. end
  28. 67 Util.assert_options_empty! audio
  29. end
  30. end
  31. 1 def resolve(io)
  32. return io unless
  33. 59 io.is_a? String
  34. 2 File.create(io).tap do |file|
  35. 2 Ffmprb.logger.warn "Output file exists (#{file.path}), will probably overwrite" if file.exist?
  36. end
  37. end
  38. end
  39. 1 attr_reader :io
  40. 1 attr_reader :process
  41. 1 def initialize(io, process, video:, audio:)
  42. 59 @io = self.class.resolve(io)
  43. 59 @process = process
  44. @channels = {
  45. 59 video: video && @io.channel?(:video) && OpenStruct.new(video),
  46. audio: audio && @io.channel?(:audio) && OpenStruct.new(audio)
  47. }
  48. 59 if channel?(:video)
  49. 45 channel(:video).resolution.to_s.split('x').each do |dim|
  50. 90 fail Error, "Both dimensions of a resolution must be divisible by 2, sorry about that" unless dim.to_i % 2 == 0
  51. end
  52. end
  53. end
  54. # TODO This method is exceptionally long at the moment. This is not too grand.
  55. # However, structuring the code should be undertaken with care, as not to harm the composition clarity.
  56. 1 def filters
  57. fail Error, "Nothing to roll..." unless
  58. 58 @reels
  59. fail Error, "Supporting just full_screen for now, sorry." unless
  60. 58 @reels.all? &:full_screen?
  61. fail Error, "Supporting just a known output FPS" unless
  62. 58 !channel(:video) || (video_fps = channel(:video).fps)
  63. 58 return @filters if @filters
  64. 58 idx = process.output_index(self)
  65. 58 @filters = []
  66. # Concatting
  67. 58 segments = []
  68. 58 @reels.each_with_index do |curr_reel, i|
  69. 237 lbl = nil
  70. 237 if curr_reel.reel
  71. # NOTE mapping input to this lbl
  72. 237 lbl = "o#{idx}rl#{i}"
  73. 237 lbl_aux = "t#{lbl}"
  74. # NOTE Image-Padding to match the target resolution
  75. # TODO full screen only at the moment (see exception above)
  76. 237 Ffmprb.logger.debug{"#{self} asking for filters of #{curr_reel.reel.io.inspect} video: #{channel(:video)}, audio: #{channel(:audio)}"}
  77. 237 @filters.concat(
  78. [
  79. *curr_reel.reel.filters_for(lbl_aux, video: channel(:video), audio: channel(:audio)),
  80. 237 *(channel?(:video)? Filter.interpolate_v(video_fps, "#{lbl_aux}:v", "#{lbl}:v"): nil),
  81. 237 *(channel?(:audio)? Filter.anull("#{lbl_aux}:a", "#{lbl}:a"): nil)
  82. ]
  83. )
  84. end
  85. 237 trim_prev_at = curr_reel.after || (curr_reel.transition && 0)
  86. 237 transition_length = curr_reel.transition ? curr_reel.transition.length : 0
  87. 237 if trim_prev_at
  88. # NOTE make sure previous reel rolls _long_ enough AND then _just_ enough
  89. 9 prev_lbl = segments.pop
  90. 9 lbl_pad = "bl#{prev_lbl}#{i}"
  91. # NOTE generously padding the previous segment to support for all the cases
  92. @filters.concat(
  93. Filter.blank_source trim_prev_at + transition_length,
  94. channel(:video).resolution, video_fps, "#{lbl_pad}:v"
  95. 9 ) if channel?(:video)
  96. @filters.concat(
  97. Filter.silent_source trim_prev_at + transition_length, "#{lbl_pad}:a"
  98. 9 ) if channel?(:audio)
  99. 9 if prev_lbl
  100. 3 lbl_aux = lbl_pad
  101. 3 lbl_pad = "pd#{prev_lbl}#{i}"
  102. @filters.concat(
  103. Filter.concat_v ["#{prev_lbl}:v", "#{lbl_aux}:v"], "#{lbl_pad}:v"
  104. 3 ) if channel?(:video)
  105. @filters.concat(
  106. Filter.concat_a ["#{prev_lbl}:a", "#{lbl_aux}:a"], "#{lbl_pad}:a"
  107. 3 ) if channel?(:audio)
  108. end
  109. 9 if curr_reel.transition
  110. # NOTE Split the previous segment for transition
  111. 7 if trim_prev_at > 0
  112. @filters.concat(
  113. Filter.split "#{lbl_pad}:v", ["#{lbl_pad}a:v", "#{lbl_pad}b:v"]
  114. 2 ) if channel?(:video)
  115. @filters.concat(
  116. Filter.asplit "#{lbl_pad}:a", ["#{lbl_pad}a:a", "#{lbl_pad}b:a"]
  117. 2 ) if channel?(:audio)
  118. 2 lbl_pad, lbl_pad_ = "#{lbl_pad}a", "#{lbl_pad}b"
  119. else
  120. 5 lbl_pad, lbl_pad_ = nil, lbl_pad
  121. end
  122. end
  123. 9 if lbl_pad
  124. # NOTE Trim the previous segment finally
  125. 4 new_prev_lbl = "tm#{prev_lbl}#{i}a"
  126. @filters.concat(
  127. Filter.trim 0, trim_prev_at, "#{lbl_pad}:v", "#{new_prev_lbl}:v"
  128. 4 ) if channel?(:video)
  129. @filters.concat(
  130. Filter.atrim 0, trim_prev_at, "#{lbl_pad}:a", "#{new_prev_lbl}:a"
  131. 4 ) if channel?(:audio)
  132. 4 segments << new_prev_lbl
  133. 4 Ffmprb.logger.debug{"Concatting segments: #{new_prev_lbl} pushed"}
  134. end
  135. 9 if curr_reel.transition
  136. # NOTE snip the end of the previous segment and combine with this reel
  137. 7 lbl_end1 = "o#{idx}tm#{i}b"
  138. 7 lbl_reel = "o#{idx}tn#{i}"
  139. 7 if !lbl # no reel
  140. lbl_aux = "o#{idx}bk#{i}"
  141. @filters.concat(
  142. Filter.blank_source transition_length, channel(:video).resolution, video_fps, "#{lbl_aux}:v"
  143. ) if channel?(:video)
  144. @filters.concat(
  145. Filter.silent_source transition_length, "#{lbl_aux}:a"
  146. ) if channel?(:audio)
  147. end # NOTE else hope lbl is long enough for the transition
  148. @filters.concat(
  149. Filter.trim trim_prev_at, trim_prev_at + transition_length, "#{lbl_pad_}:v", "#{lbl_end1}:v"
  150. 7 ) if channel?(:video)
  151. @filters.concat(
  152. Filter.atrim trim_prev_at, trim_prev_at + transition_length, "#{lbl_pad_}:a", "#{lbl_end1}:a"
  153. 7 ) if channel?(:audio)
  154. # TODO the only supported transition, see #*lay
  155. @filters.concat(
  156. Filter.blend_v transition_length, channel(:video).resolution, video_fps, ["#{lbl_end1}:v", "#{lbl || lbl_aux}:v"], "#{lbl_reel}:v"
  157. 7 ) if channel?(:video)
  158. @filters.concat(
  159. Filter.blend_a transition_length, ["#{lbl_end1}:a", "#{lbl || lbl_aux}:a"], "#{lbl_reel}:a"
  160. 7 ) if channel?(:audio)
  161. 7 lbl = lbl_reel
  162. end
  163. end
  164. 237 segments << lbl # NOTE can be nil
  165. end
  166. 58 segments.compact!
  167. 58 lbl_out = segments[0]
  168. 58 if segments.size > 1
  169. 32 lbl_out = "o#{idx}o"
  170. @filters.concat(
  171. 138 Filter.concat_v segments.map{|s| "#{s}:v"}, "#{lbl_out}:v"
  172. 32 ) if channel?(:video)
  173. @filters.concat(
  174. 200 Filter.concat_a segments.map{|s| "#{s}:a"}, "#{lbl_out}:a"
  175. 32 ) if channel?(:audio)
  176. end
  177. # Overlays
  178. # NOTE in-process overlays first
  179. 58 @overlays.to_a.each_with_index do |over_reel, i|
  180. 8 next if over_reel.duck # NOTE this is currently a single case of multi-process... process
  181. 5 fail Error, "Video overlays are not implemented just yet, sorry..." if over_reel.reel.channel?(:video)
  182. # Audio overlaying
  183. 5 lbl_nxt = "o#{idx}o#{i}"
  184. 5 lbl_over = "o#{idx}l#{i}"
  185. 5 @filters.concat( # NOTE audio only, see above
  186. over_reel.reel.filters_for lbl_over, video: false, audio: channel(:audio)
  187. )
  188. @filters.concat(
  189. Filter.copy "#{lbl_out}:v", "#{lbl_nxt}:v"
  190. 5 ) if channel?(:video)
  191. @filters.concat(
  192. Filter.amix_to_first_same_volume ["#{lbl_out}:a", "#{lbl_over}:a"], "#{lbl_nxt}:a"
  193. 5 ) if channel?(:audio)
  194. 5 lbl_out = lbl_nxt
  195. end
  196. # NOTE multi-process overlays last
  197. 58 @channel_lbl_ios = {} # TODO this is a spaghetti machine
  198. 58 @channel_lbl_ios["#{lbl_out}:v"] = io if channel?(:video)
  199. 58 @channel_lbl_ios["#{lbl_out}:a"] = io if channel?(:audio)
  200. # TODO supporting just "full" overlays for now, see exception in #add_reel
  201. 58 @overlays.to_a.each_with_index do |over_reel, i|
  202. # NOTE this is currently a single case of multi-process... process
  203. 8 if over_reel.duck
  204. 3 fail Error, "Don't know how to duck video... yet" if over_reel.duck != :audio
  205. 3 Ffmprb.logger.info "ATTENTION: ducking audio (due to the absence of a simple ffmpeg filter) does not support streaming main input. yet."
  206. # So ducking just audio here, ye?
  207. # TODO! check if we're on audio channel
  208. 3 main_av_o = @channel_lbl_ios["#{lbl_out}:a"]
  209. 3 fail Error, "Main output does not contain audio to duck" unless main_av_o
  210. 3 intermediate_extname = Process.intermediate_channel_extname video: main_av_o.channel?(:video), audio: main_av_o.channel?(:audio)
  211. 3 main_av_inter_i, main_av_inter_o = File.threaded_buffered_fifo(intermediate_extname, reader_open_on_writer_idle_limit: Util::ThreadedIoBuffer.timeout * 2, proc_vis: process)
  212. 3 @channel_lbl_ios.each do |channel_lbl, io|
  213. 5 @channel_lbl_ios[channel_lbl] = main_av_inter_i if io == main_av_o # TODO ~~~spaghetti
  214. end
  215. 3 process.proc_vis_edge process, main_av_o, :remove
  216. 3 process.proc_vis_edge process, main_av_inter_i
  217. 3 Ffmprb.logger.debug{"Re-routed the main audio output (#{main_av_inter_i.path}->...->#{main_av_o.path}) through the process of audio ducking"}
  218. 3 over_a_i, over_a_o = File.threaded_buffered_fifo(Process.intermediate_channel_extname(audio: true, video: false), proc_vis: process)
  219. 3 lbl_over = "o#{idx}l#{i}"
  220. 3 @filters.concat(
  221. over_reel.reel.filters_for lbl_over, video: false, audio: channel(:audio)
  222. )
  223. 3 @channel_lbl_ios["#{lbl_over}:a"] = over_a_i
  224. 3 process.proc_vis_edge process, over_a_i
  225. 3 Ffmprb.logger.debug{"Routed and buffering auxiliary output fifos (#{over_a_i.path}>#{over_a_o.path}) for overlay"}
  226. 3 inter_i, inter_o = File.threaded_buffered_fifo(intermediate_extname, proc_vis: process)
  227. 3 Ffmprb.logger.debug{"Allocated fifos to buffer media (#{inter_i.path}>#{inter_o.path}) while finding silence"}
  228. 3 ignore_broken_pipes_was = process.ignore_broken_pipes # TODO? maybe throw an exception instead?
  229. 3 process.ignore_broken_pipes = true # NOTE audio ducking process may break the overlay pipe
  230. 3 Util::Thread.new "audio ducking" do
  231. 3 process.proc_vis_edge main_av_inter_o, inter_i # TODO mark it better
  232. 3 silence = Ffmprb.find_silence(main_av_inter_o, inter_i)
  233. 3 Ffmprb.logger.debug{
  234. silence_map = silence.map{|s| "#{s.start_at}-#{s.end_at}"}
  235. "Audio ducking with silence: [#{silence_map.join ', '}]"
  236. }
  237. 3 Process.duck_audio inter_o, over_a_o, silence, main_av_o,
  238. process_options: {parent: process, ignore_broken_pipes: ignore_broken_pipes_was, timeout: process.timeout},
  239. video: channel(:video), audio: channel(:audio)
  240. end
  241. end
  242. end
  243. 58 @filters
  244. end
  245. 1 def args
  246. 58 fail Error, "Must generate filters first." unless @channel_lbl_ios
  247. 58 [].tap do |args|
  248. 58 io_channel_lbls = {} # TODO ~~~spaghetti
  249. 58 @channel_lbl_ios.each do |channel_lbl, io|
  250. 99 (io_channel_lbls[io] ||= []) << channel_lbl
  251. end
  252. 58 io_channel_lbls.each do |io, channel_lbls|
  253. 61 channel_lbls.each do |channel_lbl|
  254. 99 args.concat ['-map', "[#{channel_lbl}]"]
  255. end
  256. 61 args.concat self.class.video_args(channel :video) if channel? :video
  257. 61 args.concat self.class.audio_args(channel :audio) if channel? :audio
  258. 61 args << io.path
  259. end
  260. end
  261. end
  262. 1 def input(io, video: true, audio: true)
  263. 2 process.input io, video: video, audio: audio
  264. end
  265. 1 def roll(
  266. reel,
  267. onto: :full_screen,
  268. after: nil,
  269. transition: nil
  270. )
  271. 237 fail Error, "Nothing to roll..." unless reel
  272. fail Error, "Supporting :transition with :after only at the moment, sorry." unless
  273. 237 !transition || after || @reels.to_a.empty?
  274. 237 add_reel reel, after, transition, (onto == :full_screen)
  275. end
  276. 1 alias :lay :roll
  277. 1 def overlay(
  278. reel,
  279. at: 0,
  280. duck: nil
  281. )
  282. 8 fail Error, "Nothing to overlay..." unless reel
  283. 8 fail Error, "Nothing to lay over yet..." if @reels.to_a.empty?
  284. 8 fail Error, "Ducking overlays should come last... for now" if !duck && @overlays.to_a.last && @overlays.to_a.last.duck
  285. 8 add_snip reel, at, duck
  286. end
  287. 1 def channel(medium)
  288. 1659 @channels[medium]
  289. end
  290. 1 def channel?(medium)
  291. 909 !!channel(medium)
  292. end
  293. 1 private
  294. 1 def reels_channel?(medium)
  295. @reels.to_a.all?{|r| !r.reel || r.reel.channel?(medium)}
  296. end
  297. 1 def add_reel(reel, after, transition, full_screen)
  298. 237 fail Error, "No time to roll..." if after && after.to_f <= 0
  299. 237 fail Error, "Partial (not coming last in process) overlays are currently unsupported, sorry." unless @overlays.to_a.empty?
  300. # NOTE limited functionality: transition = {effect => duration}
  301. # TODO temporary obviously, see rendering
  302. trans =
  303. 237 if transition
  304. fail "Unsupported (yet) transition, sorry." unless
  305. 7 transition.size == 1 && transition[:blend]
  306. 7 OpenStruct.new length: transition[:blend].to_f
  307. end
  308. 237 (@reels ||= []) <<
  309. OpenStruct.new(reel: reel, after: after, transition: trans, full_screen?: full_screen)
  310. end
  311. 1 def add_snip(reel, at, duck)
  312. 8 (@overlays ||= []) <<
  313. OpenStruct.new(reel: reel, at: at, duck: duck)
  314. end
  315. end
  316. end
  317. end

lib/ffmprb/util.rb

81.52% lines covered

92 relevant lines. 75 lines covered and 17 lines missed.
    
  1. 1 require 'open3'
  2. 1 module Ffmprb
  3. 1 class Error < StandardError; end
  4. 1 module Util
  5. 1 class BrokenPipeError < Error; end
  6. 1 class TimeLimitError < Error; end
  7. 1 FFMPEG_BROKEN_PIPE_ERROR_RE = /^.*\berror\b.*:.*\bbroken pipe\b.*$/i
  8. 1 class << self
  9. 1 attr_accessor :ffmpeg_cmd, :ffmpeg_inputs_max, :ffprobe_cmd
  10. 1 attr_accessor :cmd_timeout
  11. 1 def ffprobe(*args, limit: nil, timeout: cmd_timeout)
  12. 39 sh *ffprobe_cmd, *args, limit: limit, timeout: timeout
  13. end
  14. # TODO un-colorise ffmpeg output for logging, also, convert ^M into something
  15. 1 def ffmpeg(*args, limit: nil, timeout: cmd_timeout, ignore_broken_pipes: true)
  16. args = %w[-loglevel debug] + args if
  17. 175 Ffmprb.ffmpeg_debug
  18. 175 sh *ffmpeg_cmd, *args,
  19. output: :stderr,
  20. limit: limit,
  21. timeout: timeout,
  22. ignore_broken_pipes: ignore_broken_pipes,
  23. broken_pipe_error_re: FFMPEG_BROKEN_PIPE_ERROR_RE
  24. end
  25. 1 def sh(*cmd, input: nil, output: :stdout, limit: nil, timeout: cmd_timeout, ignore_broken_pipes: false, broken_pipe_error_re: nil)
  26. 300 cmd = cmd.map &:to_s unless cmd.size == 1
  27. 3651 cmd_str = cmd.size != 1 ? cmd.map{|c| sh_escape c}.join(' ') : cmd.first
  28. 300 cmd_log_line = "#{log_hash cmd_str}: `#{cmd_str}`"
  29. 300 timeout = [timeout, limit].compact.min
  30. 300 thr = Thread.new cmd_log_line do
  31. 300 Ffmprb.logger.info "Popening #{cmd_log_line}..."
  32. 300 Open3.popen3(*cmd) do |stdin, stdout, stderr, wait_thr|
  33. begin
  34. 300 stdin.write input if input
  35. 300 stdin.close
  36. 300 log_cmd = cmd.first.upcase
  37. 300 stdout_r = Reader.new(stdout, store: output == :stdout, log_with: log_cmd)
  38. 300 stderr_r = Reader.new(stderr, store: true, log_with: log_cmd, log_as: output == :stderr && Logger::DEBUG || Logger::INFO)
  39. 300 stderr_s = nil
  40. 300 Thread.timeout_or_live(limit, log: "while waiting for #{cmd_log_line}", timeout: timeout) do |time|
  41. 300 value = wait_thr.value
  42. 300 status = value.exitstatus # NOTE blocking
  43. 300 if status != 0
  44. 21 stderr_s = stderr_r.read
  45. 21 if (value.signaled? && value.termsig == Signal.list['PIPE']) ||
  46. # NOTE this doesn't seem to work for ffmpeg 4.x (it ignores SIGPIPEs)
  47. (broken_pipe_error_re && status == 1 && stderr_s =~ broken_pipe_error_re)
  48. 9 if ignore_broken_pipes
  49. 4 Ffmprb.logger.info "Ignoring broken pipe: #{cmd_log_line}"
  50. else
  51. 5 fail BrokenPipeError, cmd_log_line
  52. end
  53. else
  54. 12 status ||= "sig##{value.termsig}"
  55. 12 fail Error, "#{cmd_log_line} (#{status}):\n#{stderr_s}"
  56. end
  57. end
  58. end
  59. 283 Ffmprb.logger.debug{"FINISHED: #{cmd_log_line}"}
  60. 283 Thread.join_children! limit, timeout: timeout
  61. # NOTE only one of them will return non-nil, see above
  62. 283 stdout_r.read || stderr_s || stderr_r.read
  63. ensure
  64. 300 process_dead! wait_thr, cmd_str, limit
  65. end
  66. end
  67. end
  68. 300 thr.value
  69. end
  70. 1 def assert_options_empty!(opts)
  71. 540 fail ArgumentError, "Unknown options: #{opts}" unless opts.empty?
  72. end
  73. 1 private
  74. 1 def log_hash(s)
  75. 300 n = s.hash
  76. 300 %w[bcdfghk aeiuy mnprqstvwxz].reduce '' do |hash, chars|
  77. 900 hash + chars[n % chars.length]
  78. end
  79. end
  80. 1 def broken_pipe_error_printed?(s)
  81. end
  82. # NOTE a best guess kinda method
  83. 1 def sh_escape(str)
  84. 3351 if str !~ /^[a-z0-9\/.:_-]*$/i && str !~ /"/
  85. 180 "\"#{str}\""
  86. else
  87. 3171 str
  88. end
  89. end
  90. 1 def process_dead!(wait_thr, cmd_str, limit)
  91. 300 grace = limit ? limit/4 : 1
  92. 300 return unless wait_thr.alive?
  93. # NOTE a simplistic attempt to gracefully terminate a child process
  94. # the successful completion is via exception...
  95. begin
  96. Ffmprb.logger.info "Sorry it came to this, but I'm terminating `#{cmd_str}`(#{wait_thr.pid})..."
  97. ::Process.kill 'TERM', wait_thr.pid
  98. sleep grace
  99. Ffmprb.logger.info "Very sorry it came to this, but I'm terminating `#{cmd_str}`(#{wait_thr.pid}) again..."
  100. ::Process.kill 'TERM', wait_thr.pid
  101. sleep grace
  102. Ffmprb.logger.warn "Die `#{cmd_str}`(#{wait_thr.pid}), die!.. (killing amok)"
  103. ::Process.kill 'KILL', wait_thr.pid
  104. sleep grace
  105. Ffmprb.logger.warn "Checking if `#{cmd_str}`(#{wait_thr.pid}) finally dead..."
  106. ::Process.kill 0, wait_thr.pid
  107. Ffmprb.logger.error "Still alive -- `#{cmd_str}`(#{wait_thr.pid}), giving up..."
  108. rescue Errno::ESRCH
  109. Ffmprb.logger.info "Apparently `#{cmd_str}`(#{wait_thr.pid}) is dead..."
  110. end
  111. fail Error, "System error or something: waiting for the thread running `#{cmd_str}`(#{wait_thr.pid})..." unless
  112. wait_thr.join limit
  113. end
  114. end
  115. 1 class Reader < Thread
  116. 1 def initialize(input, store: false, log_with: nil, log_as: Logger::DEBUG)
  117. 600 @output = ''
  118. 600 @queue = Queue.new
  119. 600 super "reader" do
  120. begin
  121. 600 while s = input.gets
  122. 497884 Ffmprb.logger.log log_as, "#{log_with}: #{s.chomp}" if log_with
  123. 497884 @output << s if store
  124. end
  125. 600 @queue.enq @output
  126. rescue Exception
  127. @queue.enq Error.new("Exception in a reader thread")
  128. end
  129. end
  130. end
  131. 1 def read
  132. 544 case res = @queue.deq
  133. when Exception
  134. fail res
  135. when ''
  136. nil
  137. else
  138. 300 res
  139. end
  140. end
  141. end
  142. end
  143. end
  144. # require 'ffmprb/util/synchro'
  145. 1 require_relative 'util/proc_vis'
  146. 1 require_relative 'util/thread'
  147. 1 require_relative 'util/threaded_io_buffer'

lib/ffmprb/util/proc_vis.rb

43.53% lines covered

85 relevant lines. 37 lines covered and 48 lines missed.
    
  1. 1 require 'set'
  2. 1 require 'monitor'
  3. 1 module Ffmprb
  4. 1 module Util
  5. 1 module ProcVis
  6. 1 UPDATE_PERIOD_SEC = 1
  7. 1 module Node
  8. 1 attr_accessor :_proc_vis
  9. 1 def proc_vis_name
  10. lbl = respond_to?(:label) && label ||
  11. short_name ||
  12. to_s
  13. # ).gsub(/\W+/, '_').sub(/^[^[:alpha:]]*/, '')
  14. "#{object_id} [labelType=\"html\" label=#{lbl.to_json}]"
  15. end
  16. 1 def proc_vis_node(node, op=:upsert)
  17. 92120 _proc_vis.proc_vis_node node, op if _proc_vis
  18. end
  19. 1 def proc_vis_edge(from, to, op=:upsert)
  20. 1668 _proc_vis.proc_vis_edge from, to, op if _proc_vis
  21. end
  22. 1 private
  23. 1 def short_name
  24. return unless respond_to? :name
  25. short =
  26. if name.length <= 30
  27. name
  28. else
  29. "#{name[0..13]}..#{name[-14..-1]}"
  30. end
  31. "#{self.class.name.split('::').last}: #{short}"
  32. end
  33. end
  34. 1 module ClassMethods
  35. 1 attr_accessor :proc_vis_firebase
  36. 1 def proc_vis_node(obj, op=:upsert)
  37. 59 return unless proc_vis_init?
  38. fail Error, "Must be a #{Node.name}" unless
  39. obj.kind_of? Node
  40. obj._proc_vis = self
  41. obj.proc_vis_name.tap do |lbl|
  42. proc_vis_sync do
  43. @_proc_vis_nodes ||= {}
  44. if op == :remove
  45. @_proc_vis_nodes.delete obj
  46. else
  47. @_proc_vis_nodes[obj] = lbl
  48. end
  49. end
  50. proc_vis_update # TODO optimise
  51. end
  52. end
  53. 1 def proc_vis_edge(from, to, op=:upsert)
  54. return unless proc_vis_init?
  55. if op == :upsert
  56. proc_vis_node from
  57. proc_vis_node to
  58. end
  59. "#{from.object_id} -> #{to.object_id}".tap do |edge|
  60. proc_vis_sync do
  61. @_proc_vis_edges ||= SortedSet.new
  62. if op == :remove
  63. @_proc_vis_edges.delete edge
  64. else
  65. @_proc_vis_edges << edge
  66. end
  67. end
  68. proc_vis_update
  69. end
  70. end
  71. 1 private
  72. 1 def proc_vis_update
  73. @_proc_vis_upq.enq 1
  74. end
  75. 1 def proc_vis_do_update
  76. nodes = @_proc_vis_nodes.map{ |_, node| "#{node};"}.join("\n") if @_proc_vis_nodes
  77. edges = @_proc_vis_edges.map{ |edge| "#{edge};"}.join("\n") if @_proc_vis_edges
  78. proc_vis_firebase_client.set proc_vis_pid, dot: [*nodes, *edges].join("\n")
  79. end
  80. 1 def proc_vis_pid
  81. @proc_vis_pid ||= object_id.tap do |pid|
  82. Ffmprb.logger.info "You may view your process visualised at: https://#{proc_vis_firebase}.firebaseapp.com/?pid=#{pid}"
  83. end
  84. end
  85. 1 def proc_vis_init?
  86. 59 !!proc_vis_firebase_client
  87. end
  88. 1 def proc_vis_up_init
  89. @_proc_vis_thr ||= Thread.new do # NOTE update throttling
  90. prev_t = Time.now
  91. while @_proc_vis_upq.deq # NOTE currently, runs forever (nil terminator needed)
  92. proc_vis_do_update
  93. Thread.current.live! # TODO? not the best we can do here
  94. while Time.now - prev_t < UPDATE_PERIOD_SEC
  95. @_proc_vis_upq.deq # NOTE drains the queue
  96. end
  97. @_proc_vis_upq.enq 1
  98. end
  99. end
  100. end
  101. 1 def proc_vis_sync_init
  102. 1 @_proc_vis_mon ||= Monitor.new
  103. 1 @_proc_vis_upq ||= Queue.new
  104. end
  105. 1 def proc_vis_sync(&blk)
  106. @_proc_vis_mon.synchronize &blk if blk
  107. end
  108. 1 def proc_vis_firebase_client
  109. 59 return @proc_vis_firebase_client if defined? @proc_vis_firebase_client
  110. @proc_vis_firebase_client =
  111. 1 if proc_vis_firebase
  112. url = "https://#{proc_vis_firebase}.firebaseio.com/proc/"
  113. Ffmprb.logger.debug{"Connecting to #{url}"}
  114. begin
  115. Firebase::Client.new(url).tap do
  116. Ffmprb.logger.info "Connected to #{url}"
  117. proc_vis_up_init
  118. end
  119. rescue
  120. Ffmprb.logger.error "Could not connect to #{url}"
  121. end
  122. end
  123. end
  124. end
  125. 1 def self.included(klass)
  126. 1 klass.extend ClassMethods
  127. 1 klass.send :proc_vis_sync_init
  128. end
  129. end
  130. end
  131. end

lib/ffmprb/util/thread.rb

100.0% lines covered

76 relevant lines. 76 lines covered and 0 lines missed.
    
  1. 1 module Ffmprb
  2. 1 module Util
  3. 1 class Thread < ::Thread
  4. 1 include ProcVis::Node
  5. 1 class Error < Ffmprb::Error; end
  6. 1 class ParentError < Error; end
  7. 1 class << self
  8. 1 attr_accessor :timeout
  9. 1 def timeout_or_live(limit=nil, log: "while doing this", timeout: self.timeout, &blk)
  10. 1022 started_at = Time.now
  11. 1022 timeouts = 0
  12. 1022 logged_timeouts = 1
  13. begin
  14. 1053 timeouts += 1
  15. 1053 time = Time.now - started_at
  16. 1053 fail TimeLimitError if limit && time > limit
  17. 1052 Timeout.timeout timeout do
  18. 1052 blk.call time
  19. end
  20. 52 rescue Timeout::Error
  21. 33 if timeouts > 2 * logged_timeouts
  22. 6 Ffmprb.logger.info "A little bit of timeout #{log.respond_to?(:call)? log.call : log} (##{timeouts}x#{timeout})"
  23. 6 logged_timeouts = timeouts
  24. end
  25. 33 current.live!
  26. 31 retry
  27. end
  28. end
  29. 1 def join_children!(limit=nil, timeout: self.timeout)
  30. 392 Thread.current.join_children! limit, timeout: timeout
  31. end
  32. end
  33. 1 attr_reader :name
  34. 1 attr_reader :backtrace
  35. 1 def initialize(name="some", main: false, &blk)
  36. 850 orig_caller = caller
  37. 850 @name = name
  38. 850 @parent = Thread.current
  39. 850 @live_children = []
  40. 850 @children_mon = Monitor.new
  41. 850 @dead_children_q = Queue.new
  42. 850 @backtrace = (@parent.respond_to?(:backtrace)? @parent.backtrace : []) + caller
  43. 850 Ffmprb.logger.debug{"about to launch #{'main ' if main}#{name}"}
  44. 850 sync_q = Queue.new
  45. 850 super() do
  46. 850 self.report_on_exception = false
  47. 850 @parent.proc_vis_node self if @parent.respond_to? :proc_vis_node
  48. 850 if @parent.respond_to? :child_lives
  49. 558 @parent.child_lives self
  50. 558 Ffmprb.logger.warn "Not the main: false thread run by a #{self.class.name} thread" if main
  51. else
  52. 292 Ffmprb.logger.warn "Not the main: true thread run by a not #{self.class.name} thread" unless main
  53. end
  54. 850 sync_q.enq :ok
  55. 850 Ffmprb.logger.debug{"#{name} thread launched"}
  56. begin
  57. 850 blk.call.tap do
  58. 818 Ffmprb.logger.debug{"#{name} thread done"}
  59. end
  60. rescue Exception
  61. 32 Ffmprb.logger.warn "#{$!.class.name} raised in #{name} thread: #{$!.message}\nBacktrace:\n\t#{($!.backtrace + backtrace ).join("\n\t")}"
  62. 32 cause = $!
  63. 32 Ffmprb.logger.warn "...caused by #{cause.class.name}: #{cause.message}\nBacktrace:\n\t#{cause.backtrace.join("\n\t")}" while
  64. cause = cause.cause
  65. 32 raise $! # TODO? I have no idea why I need to give it `$!` -- the docs say I need not
  66. ensure
  67. 850 @parent.child_dies self if @parent.respond_to? :child_dies
  68. 850 @parent.proc_vis_node self, :remove if @parent.respond_to? :proc_vis_node
  69. end
  70. end
  71. 850 sync_q.deq
  72. end
  73. # TODO protected: none of these methods should be called by a user code, the only public methods are above
  74. 1 def live!
  75. 30115 fail ParentError if @parent.status.nil?
  76. end
  77. 1 def child_lives(thr)
  78. 558 @children_mon.synchronize do
  79. 558 Ffmprb.logger.debug{"picking up #{thr.name} thread"}
  80. 558 @live_children << thr
  81. end
  82. 558 proc_vis_edge self, thr
  83. end
  84. 1 def child_dies(thr)
  85. 558 @children_mon.synchronize do
  86. 558 Ffmprb.logger.debug{"releasing #{thr.name} thread"}
  87. 558 @dead_children_q.enq thr
  88. 558 fail "System Error" unless @live_children.delete thr
  89. end
  90. 558 proc_vis_edge self, thr, :remove
  91. end
  92. 1 def join_children!(limit=nil, timeout: Thread.timeout)
  93. 392 timeout = [timeout, limit].compact.min
  94. 392 Ffmprb.logger.debug "joining threads: #{@live_children.size} live, #{@dead_children_q.size} dead"
  95. 392 until @live_children.empty? && @dead_children_q.empty?
  96. 525 thr = self.class.timeout_or_live limit, log: "joining threads: #{@live_children.size} live, #{@dead_children_q.size} dead", timeout: timeout do
  97. 525 @dead_children_q.deq
  98. end
  99. 525 Ffmprb.logger.debug "joining the late #{thr.name} thread"
  100. 525 fail "System Error" unless thr.join(timeout) # NOTE should not block
  101. end
  102. end
  103. end
  104. end
  105. end

lib/ffmprb/util/threaded_io_buffer.rb

94.3% lines covered

158 relevant lines. 149 lines covered and 9 lines missed.
    
  1. 1 require 'ostruct'
  2. 1 module Ffmprb
  3. 1 module Util
  4. # TODO the events mechanism is currently unused (and commented out) => synchro mechanism not needed
  5. 1 class ThreadedIoBuffer
  6. # TODO? include Synchro
  7. 1 include ProcVis::Node
  8. 1 class << self
  9. 1 attr_accessor :blocks_max
  10. 1 attr_accessor :block_size
  11. 1 attr_accessor :timeout
  12. 1 attr_accessor :timeout_limit
  13. 1 attr_accessor :io_wait_timeout
  14. end
  15. 1 attr_reader :stats
  16. # NOTE input/output can be lambdas for single asynchronic io evaluation
  17. # the lambdas must be timeout-interrupt-safe (since they are wrapped in timeout blocks)
  18. # NOTE all ios are being opened and closed as soon as possible
  19. 1 def initialize(input, *outputs, keep_outputs_open_on_input_idle_limit: nil)
  20. 29 super() # NOTE for the monitor, apparently
  21. 29 Ffmprb.logger.debug{"ThreadedIoBuffer initializing with (#{ThreadedIoBuffer.blocks_max}x#{ThreadedIoBuffer.block_size})"}
  22. 29 @input = input
  23. 29 @outputs = outputs.map do |outp|
  24. 192 OpenStruct.new _io: outp, q: SizedQueue.new(ThreadedIoBuffer.blocks_max)
  25. end
  26. 29 @stats = Stats.new(self)
  27. 29 @keep_outputs_open_on_input_idle_limit = keep_outputs_open_on_input_idle_limit
  28. # @events = {}
  29. 29 Thread.new "io buffer main" do
  30. 29 init_reader!
  31. 29 @outputs.each do |output|
  32. 192 init_writer_output! output
  33. 192 init_writer! output
  34. end
  35. 29 Thread.join_children!.tap do
  36. 28 Ffmprb.logger.debug{"ThreadedIoBuffer (#{@input.path}->#{@outputs.map(&:io).map(&:path)}) terminated successfully (#{stats})"}
  37. end
  38. end
  39. end
  40. # TODO?
  41. #
  42. # def once(event, &blk)
  43. # event = event.to_sym
  44. # wait_for_handler!
  45. # if @events[event].respond_to? :call
  46. # fail Error, "Once upon a time (one #once(event) at a time) please"
  47. # elsif @events[event]
  48. # Ffmprb.logger.debug{"ThreadedIoBuffer (post-)reacting to #{event}"}
  49. # @handler_thr = Util::Thread.new "#{event} handler", &blk
  50. # else
  51. # Ffmprb.logger.debug{"ThreadedIoBuffer subscribing to #{event}"}
  52. # @events[event] = blk
  53. # end
  54. # end
  55. # handle_synchronously :once
  56. #
  57. # def reader_done!
  58. # Ffmprb.logger.debug{"ThreadedIoBuffer reader terminated (#{stats})"}
  59. # fire! :reader_done
  60. # end
  61. #
  62. # def terminated!
  63. # fire! :terminated
  64. # end
  65. #
  66. # def timeout!
  67. # fire! :timeout
  68. # end
  69. # protected
  70. #
  71. # def fire!(event)
  72. # wait_for_handler!
  73. # Ffmprb.logger.debug{"ThreadedIoBuffer firing #{event}"}
  74. # if blk = @events.to_h[event.to_sym]
  75. # @handler_thr = Util::Thread.new "#{event} handler", &blk
  76. # end
  77. # @events[event.to_sym] = true
  78. # end
  79. # handle_synchronously :fire!
  80. #
  81. 1 def label
  82. "IObuff: Curr/Peak/Max=#{stats.blocks_buff}/#{stats.blocks_max}/#{ThreadedIoBuffer.blocks_max} In/Out=#{stats.bytes_in}/#{stats.bytes_out}"
  83. end
  84. 1 private
  85. 1 class AllOutputsBrokenError < Error
  86. end
  87. 1 def reader_input! # NOTE just for reader thread
  88. 33 if @input.respond_to?(:call)
  89. 29 Ffmprb.logger.debug{"Opening buffer input"}
  90. 29 @input = @input.call
  91. 29 Ffmprb.logger.debug{"Opened buffer input: #{@input.path}"}
  92. end
  93. 33 @input
  94. end
  95. # NOTE to be called after #init_writer_output! only
  96. 1 def writer_output!(output) # NOTE just for writer thread
  97. 192 if output.thr
  98. 192 output.thr.join
  99. 192 output.thr = nil
  100. end
  101. 192 output.io
  102. end
  103. # NOTE reads roughly as much input as writers can write, then closes the stream; times out on buffer overflow
  104. 1 def init_reader!
  105. 29 Thread.new("buffer reader") do
  106. begin
  107. 29 input_io = reader_input!
  108. 29 loop do # NOTE until EOFError, see below
  109. 23189 s = ''
  110. 23189 while s.length < ThreadedIoBuffer.block_size
  111. 51641 timeouts = 0
  112. 51641 logged_timeouts = 1
  113. begin
  114. 79774 ss = input_io.read_nonblock(ThreadedIoBuffer.block_size - s.length)
  115. 51617 stats.add_bytes_in ss.length
  116. 51617 s += ss
  117. rescue IO::WaitReadable
  118. 28135 if @keep_outputs_open_on_input_idle_limit && stats.bytes_in > 0 && stats.blocks_buff == 0 && timeouts * ThreadedIoBuffer.io_wait_timeout > @keep_outputs_open_on_input_idle_limit
  119. 2 if s.length > 0 # NOTE let's see if it helps outputting an incomplete block
  120. 1 Ffmprb.logger.debug{"ThreadedIoBuffer reader (from #{input_io.path}) giving a chance to write #{s.length}/#{ThreadedIoBuffer.block_size}b after waiting >#{@keep_outputs_open_on_input_idle_limit}s, after reading #{stats.bytes_in}b"}
  121. 1 break
  122. else
  123. 1 Ffmprb.logger.debug{"ThreadedIoBuffer reader (from #{input_io.path}) giving up after waiting >#{@keep_outputs_open_on_input_idle_limit}s, after reading #{stats.bytes_in}b, closing outputs"}
  124. 1 raise EOFError
  125. end
  126. else
  127. 28133 Thread.current.live!
  128. 28133 timeouts += 1
  129. 28133 if timeouts > 2 * logged_timeouts
  130. 6 Ffmprb.logger.debug{"ThreadedIoBuffer reader (from #{input_io.path}) retrying... (#{timeouts} reads): #{$!.class}"}
  131. 6 logged_timeouts = timeouts
  132. end
  133. 28133 IO.select [input_io], nil, nil, ThreadedIoBuffer.io_wait_timeout
  134. 28133 retry
  135. end
  136. rescue EOFError
  137. 22 output_enq! s
  138. 22 raise
  139. rescue IO::WaitWritable # NOTE should not really happen, so just for conformance
  140. Ffmprb.logger.error "ThreadedIoBuffer reader (from #{input_io.path}) gets a #{$!} - should not really happen."
  141. IO.select nil, [input_io], nil, ThreadedIoBuffer.io_wait_timeout
  142. retry
  143. end
  144. end
  145. 23166 output_enq! s
  146. end
  147. rescue EOFError
  148. 23 Ffmprb.logger.debug{"ThreadedIoBuffer reader (from #{input_io.path}) breaking off"}
  149. rescue AllOutputsBrokenError
  150. 5 Ffmprb.logger.info "All outputs broken"
  151. rescue Exception
  152. 1 @reader_failed = Error.new("Reader failed: #{$!}")
  153. 1 raise
  154. ensure
  155. begin
  156. 29 output_enq! nil # NOTE EOF signal
  157. rescue
  158. end
  159. begin
  160. 29 input_io.close if input_io.respond_to?(:close)
  161. rescue
  162. Ffmprb.logger.error "#{$!.class.name} closing ThreadedIoBuffer input: #{$!.message}"
  163. end
  164. # reader_done!
  165. 29 Ffmprb.logger.debug{"ThreadedIoBuffer reader terminated (#{stats})"}
  166. end
  167. end
  168. end
  169. 1 def init_writer_output!(output)
  170. 192 return output.io = output._io unless output._io.respond_to?(:call)
  171. 192 output.thr = Thread.new("buffer writer output helper") do
  172. 192 Ffmprb.logger.debug{"Opening buffer output"}
  173. 192 output.io =
  174. Thread.timeout_or_live nil, log: "in the buffer writer helper thread", timeout: ThreadedIoBuffer.timeout do |time|
  175. 214 fail Error, "giving up buffer writer init since the reader has failed (#{@reader_failed.message})" if @reader_failed
  176. 214 output._io.call
  177. end
  178. 192 Ffmprb.logger.debug{"Opened buffer output: #{output.io.path}"}
  179. end
  180. end
  181. # NOTE writes as much output as possible, then terminates when the reader dies
  182. 1 def init_writer!(output)
  183. 192 Thread.new("buffer writer") do
  184. begin
  185. 192 output_io = writer_output!(output)
  186. 192 while s = output_deq!(output) # NOTE until EOF signal
  187. 31863 timeouts = 0
  188. 31863 logged_timeouts = 1
  189. begin
  190. 33812 fail @reader_failed if @reader_failed # NOTE otherwise, output_io should not be nil
  191. 33811 written = output_io.write_nonblock(s)
  192. 32129 stats.add_bytes_out written
  193. 32129 if written != s.length
  194. 391 s = s[written..-1]
  195. 391 raise IO::EAGAINWaitWritable
  196. end
  197. rescue IO::WaitWritable
  198. 1949 Thread.current.live!
  199. 1949 timeouts += 1
  200. 1949 if timeouts > 2 * logged_timeouts
  201. 65 Ffmprb.logger.debug{"ThreadedIoBuffer writer (to #{output_io.path}) retrying... (#{timeouts} writes): #{$!.class}"}
  202. 65 logged_timeouts = timeouts
  203. end
  204. 1949 IO.select nil, [output_io], nil, ThreadedIoBuffer.io_wait_timeout
  205. 1949 retry
  206. rescue IO::WaitReadable # NOTE should not really happen, so just for conformance
  207. Ffmprb.logger.error "ThreadedIoBuffer writer (to #{output_io.path}) gets a #{$!} - should not really happen."
  208. IO.select [output_io], nil, ThreadedIoBuffer.io_wait_timeout
  209. retry
  210. end
  211. end
  212. 67 Ffmprb.logger.debug{"ThreadedIoBuffer writer (to #{output_io.path}) breaking off"}
  213. rescue Errno::EPIPE
  214. 124 Ffmprb.logger.debug{"ThreadedIoBuffer writer (to #{output_io.path}) broken"}
  215. 124 output.broken = true
  216. ensure
  217. # terminated!
  218. begin
  219. 192 output_io.close if !output.broken && output_io && output_io.respond_to?(:close)
  220. rescue
  221. Ffmprb.logger.error "#{$!.class.name} closing ThreadedIoBuffer output: #{$!.message}"
  222. end
  223. 192 output.broken = true
  224. 192 Ffmprb.logger.debug{"ThreadedIoBuffer writer (to #{output_io && output_io.path}) terminated (#{stats})"}
  225. end
  226. end
  227. end
  228. #
  229. # def wait_for_handler!
  230. # @handler_thr.join if @handler_thr
  231. # @handler_thr = nil
  232. # end
  233. 1 def output_enq!(item)
  234. 11 fail AllOutputsBrokenError if
  235. 23217 @outputs.select do |output|
  236. 58857 next if output.broken
  237. 38312 timeouts = 0
  238. 38312 logged_timeouts = 1
  239. begin
  240. # NOTE let's assume there's no race condition here between the possible timeout exception and enq
  241. 38320 Timeout.timeout(ThreadedIoBuffer.timeout) do
  242. 38320 output.q.enq item
  243. end
  244. 38308 stats.blocks_for output
  245. rescue Timeout::Error
  246. 12 next if output.broken
  247. 9 timeouts += 1
  248. 9 if timeouts == 2 * logged_timeouts
  249. 3 Ffmprb.logger.warn "A little bit of timeout (>#{timeouts*ThreadedIoBuffer.timeout}s idle) with #{ThreadedIoBuffer.blocks_max}x#{ThreadedIoBuffer.block_size}b blocks (buffering #{reader_input!.path}->...; #{@outputs.reject(&:io).size}/#{@outputs.size} unopen/total)"
  250. 3 logged_timeouts = timeouts
  251. end
  252. retry unless # NOTE the queue has probably overflown
  253. 9 timeouts >= ThreadedIoBuffer.timeout_limit
  254. 1 @reader_failed ||= # NOTE screw the race condition
  255. Error.new("the writer has failed with timeout limit while queuing")
  256. # timeout!
  257. 1 fail Error, "Looks like we're stuck (>#{ThreadedIoBuffer.timeout_limit*ThreadedIoBuffer.timeout}s idle) with #{ThreadedIoBuffer.blocks_max}x#{ThreadedIoBuffer.block_size}b blocks (buffering #{reader_input!.path}->...)..."
  258. end
  259. end.empty?
  260. end
  261. 1 def output_deq!(outp)
  262. 31930 outp.q.deq.tap do
  263. 31930 stats.blocks_for outp
  264. end
  265. end
  266. 1 class Stats < OpenStruct
  267. 1 include MonitorMixin
  268. 1 def initialize(proc)
  269. 29 @proc = proc
  270. 29 @output_blocks = {}
  271. 29 super blocks_buff: 0, blocks_max: 0, bytes_in: 0, bytes_out: 0
  272. end
  273. 1 def add_bytes_in(n)
  274. 51617 synchronize do
  275. 51617 self.bytes_in += n
  276. 51617 @proc.proc_vis_node @proc # NOTE update
  277. end
  278. end
  279. 1 def add_bytes_out(n)
  280. 32129 synchronize do
  281. 32129 self.bytes_out += n
  282. 32129 @proc.proc_vis_node @proc # NOTE update
  283. end
  284. end
  285. 1 def blocks_for(outp)
  286. 70238 synchronize do
  287. 70238 blocks = @output_blocks[outp.object_id] = outp.q.length
  288. 70238 if blocks > blocks_max
  289. 7128 self.blocks_max = blocks
  290. 7128 @proc.proc_vis_node @proc # NOTE update
  291. end
  292. 70238 self.blocks_buff = @output_blocks.values.reduce(0, :+)
  293. end
  294. end
  295. end
  296. end
  297. end
  298. end

spec/exe_spec.rb

100.0% lines covered

22 relevant lines. 22 lines covered and 0 lines missed.
    
  1. 1 describe Ffmprb::Execution do
  2. 1 around do |example|
  3. 4 Ffmprb::File.temp('.flv') do |tf|
  4. 4 @av_file_o = tf
  5. 4 example.run
  6. end
  7. end
  8. 1 it "should run the script (no params)" do
  9. 1 Ffmprb::File.temp('.ffmprb') do |tf|
  10. 1 tf.write <<-FFMPRB
  11. output('#{@av_file_o.path}') do
  12. roll input('#{@av_file_c_gor_9.path}')
  13. overlay input('#{@a_file_g_16.path}')
  14. end
  15. FFMPRB
  16. 1 cmd = "exe/ffmprb < #{tf.path}"
  17. 1 expect(Ffmprb::Util.sh cmd, output: :stderr).to match /WARN.+Output file exists/ # NOTE temp files are _created_ above
  18. 1 expect(@av_file_o.length).to be_approximately @av_file_c_gor_9.length
  19. end
  20. end
  21. 1 it "should run the script" do
  22. 1 Ffmprb::File.temp('.ffmprb') do |tf|
  23. 1 tf.write <<-FFMPRB
  24. |av_main_i, a_over_i, av_main_o|
  25. in1 = input(av_main_i)
  26. in2 = input(a_over_i)
  27. output av_main_o, video: {resolution: HD_480p} do
  28. roll in1
  29. overlay in2
  30. end
  31. FFMPRB
  32. 1 cmd = "exe/ffmprb #{@av_file_c_gor_9.path} #{@a_file_g_16.path} #{@av_file_o.path} < #{tf.path}"
  33. 1 expect(Ffmprb::Util.sh cmd, output: :stderr).to match /WARN.+Output file exists/ # NOTE temp files are _created_ above
  34. 1 expect(@av_file_o.length).to be_approximately @av_file_c_gor_9.length
  35. end
  36. end
  37. 1 [['', 300, :to], [' not', 90, :not_to]].each do |wat, cut, to_not_to|
  38. 2 it "should#{wat} warn about the looping limitation" do
  39. 2 inp_s = <<-FFMPRB
  40. in1 = input('#{@av_file_c_gor_9.path}')
  41. output('#{@av_file_o.path}') do
  42. roll in1.loop.cut(to: #{cut})
  43. end
  44. FFMPRB
  45. 2 expect(Ffmprb::Util.sh 'exe/ffmprb', input: inp_s, output: :stderr).send(
  46. to_not_to,
  47. match(/WARN.+Looping.+finished before its consumer/)
  48. )
  49. 2 expect(@av_file_o.length true).to be_approximately cut
  50. end
  51. end
  52. end

spec/ffmprb_spec.rb

92.65% lines covered

476 relevant lines. 441 lines covered and 35 lines missed.
    
  1. 1 require 'rmagick'
  2. 1 require 'sox'
  3. 1 MIN_VOLUME = -0xFFFF
  4. 1 describe Ffmprb do
  5. 1 it 'has a version number' do
  6. 1 expect(Ffmprb::VERSION).not_to be nil
  7. end
  8. # IMPORTANT NOTE Examples here use static (pre-generated) sample files, but the interface is streaming-oriented
  9. # So there's just a hope it all works well with streams, which really must be replaced by appropriate specs
  10. 1 context :process do
  11. 1 around do |example|
  12. 40 Ffmprb::File.temp('.mp4') do |tf|
  13. 40 @av_out_file = tf
  14. 40 Ffmprb::File.temp('.flv') do |tf|
  15. 40 @av_out_stream_file = tf
  16. 40 Ffmprb::File.temp('.mp3') do |tf|
  17. 40 @a_out_file = tf
  18. 40 example.run
  19. end
  20. end
  21. end
  22. end
  23. 1 def check_av_c_gor_at!(at, file: @av_out_file)
  24. 16 file.sample at: at do |shot, sound|
  25. 16 check_reddish! pixel_data(shot, 250, 10)
  26. 16 check_greenish! pixel_data(shot, 250, 110)
  27. 16 check_note! :C6, wave_data(sound)
  28. end
  29. end
  30. 1 def check_av_e_bow_at!(at)
  31. 3 @av_out_file.sample at: at do |shot, sound|
  32. 3 check_white! pixel_data(shot, 250, 10)
  33. 3 check_black! pixel_data(shot, 250, 110)
  34. 3 check_note! :E6, wave_data(sound)
  35. end
  36. end
  37. 1 def check_av_btn_wtb_at!(at, black: false)
  38. 8 @av_out_file.sample at: at do |shot, sound|
  39. 8 pixel = pixel_data(shot, 250, 110)
  40. 8 wave = wave_data(sound)
  41. 8 if black
  42. 2 check_black! pixel
  43. 2 expect(wave.volume).to eq MIN_VOLUME
  44. else
  45. 6 check_white! pixel
  46. 6 check_note! :B6, wave
  47. 6 expect(wave.volume).to be > MIN_VOLUME
  48. end
  49. end
  50. end
  51. 1 def check_black!(pixel)
  52. 9 expect(channel_max pixel).to be_approximately(0, 0xFFFF)
  53. end
  54. 1 def check_white!(pixel)
  55. 13 expect(channel_min pixel).to be_approximately 0xFFFF
  56. end
  57. 1 def check_greenish!(pixel)
  58. 38 expect(pixel.green).to be > pixel.red
  59. 38 expect(pixel.green).to be > pixel.blue
  60. 38 expect(2 * (pixel.red - pixel.blue).abs).to be < (pixel.green - pixel.blue).abs
  61. 38 expect(2 * (pixel.red - pixel.blue).abs).to be < (pixel.green - pixel.red).abs
  62. end
  63. 1 def check_reddish!(pixel)
  64. 17 expect(pixel.red).to be > pixel.green
  65. 17 expect(pixel.red).to be > pixel.blue
  66. 17 expect(2 * (pixel.green - pixel.blue).abs).to be < (pixel.red - pixel.blue).abs
  67. 17 expect(2 * (pixel.green - pixel.blue).abs).to be < (pixel.red - pixel.green).abs
  68. end
  69. 1 def check_note!(note, wave)
  70. 47 expect(wave.frequency).to be_approximately NOTES[note]
  71. end
  72. 1 it "should behave like README says it does" do
  73. 1 flick_mp4 = @av_file_btn_wtb_16.path
  74. 1 track_mp3 = @a_file_g_16.path
  75. 1 cine_flv = @av_out_stream_file.path
  76. 1 Ffmprb.process do
  77. 1 in_main = input(flick_mp4)
  78. 1 output cine_flv, video: {resolution: '1280x720'} do
  79. 1 roll in_main.crop(0.25).cut(from: 2, to: 5), transition: {blend: 1}
  80. 1 roll in_main.volume(2).cut(from: 6, to: 16), after: 2, transition: {blend: 1}
  81. 1 overlay input(track_mp3).volume(0.8)
  82. end
  83. end
  84. 1 expect(@av_out_stream_file.length).to be_approximately 12
  85. end
  86. 1 it "should fail on too much inputs" do
  87. 1 too_much = 99
  88. 1 expect {
  89. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  90. 34 inps = (0..100).map{input file_input}
  91. output file_output do
  92. inps.each do |inp|
  93. roll inp
  94. end
  95. end
  96. end
  97. }.to raise_error Ffmprb::Error
  98. end
  99. 1 it "should fail on unknown options" do
  100. 1 expect {
  101. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file, magic: :yes_please!) do |file_input, file_output|
  102. output file_output do
  103. lay file_input
  104. end
  105. end
  106. }.to raise_error ArgumentError
  107. end
  108. 1 it "should transcode" do
  109. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  110. 1 in1 = input(file_input)
  111. 1 output(file_output, video: {resolution: Ffmprb::HD_720p, fps: 30}) do
  112. 1 roll in1
  113. end
  114. end
  115. 1 check_av_c_gor_at! 1
  116. 1 expect(@av_out_file.resolution).to eq Ffmprb::HD_720p
  117. 1 expect(@av_out_file.length).to be_approximately 9
  118. end
  119. # TODO! not sure how to deal with this, apng's are bad at metadata (e.g. fps)
  120. 1 xdescribe "video only (no audio)" do
  121. 1 around do |example|
  122. Ffmprb::File.temp_fifo '.apng' do |tmp_papng|
  123. thr = Thread.new do
  124. Ffmprb::Util.ffmpeg '-filter_complex', 'color=white:d=2:r=25', tmp_papng.path
  125. end
  126. @v_in_fifo = tmp_papng
  127. begin
  128. example.run
  129. ensure
  130. thr.join
  131. end
  132. end
  133. end
  134. 1 it "should transcode with defaults (no audio track)" do
  135. Ffmprb.process(@v_in_fifo, @av_out_file) do |fifo_input, file_output|
  136. in1 = input(fifo_input, video: {fps: 25})
  137. output(file_output, audio: false) do
  138. roll in1
  139. end
  140. end
  141. expect(@av_out_file.length).to be_approximately 2
  142. @av_out_file.sample at: 0.5, audio: false do |image|
  143. check_white! pixel_data(image, 250, 110)
  144. end
  145. expect {
  146. @av_out_file.sample at: 0.5, video: false do |sound|
  147. expect(false).to be_truthy
  148. end
  149. }.to raise_error Ffmprb::Error
  150. end
  151. 1 xit "should transcode with defaults (silence)" do
  152. Ffmprb.process(@v_in_fifo, @av_out_file) do |fifo_input, file_output|
  153. in1 = input(fifo_input, video: {fps: 25})
  154. output(file_output) do
  155. roll in1
  156. end
  157. end
  158. expect(@av_out_file.length).to be_approximately 2
  159. @av_out_file.sample at: 0.5 do |image, sound|
  160. check_white! pixel_data(image, 250, 110)
  161. expect(wave_data(sound).volume).to eq MIN_VOLUME
  162. end
  163. end
  164. end
  165. 1 it "should partially support multiple outputs" do
  166. 1 Ffmprb::File.temp('.mp4') do |another_av_out_file|
  167. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output1|
  168. 1 in1 = input(file_input)
  169. 1 output(file_output1, video: {resolution: Ffmprb::HD_720p, fps: 30}) do
  170. 1 roll in1.cut(to: 6)
  171. end
  172. 1 output(another_av_out_file, video: {resolution: Ffmprb::HD_720p, fps: 30}) do
  173. 1 roll in1
  174. end
  175. end
  176. 1 check_av_c_gor_at! 1
  177. 1 check_av_c_gor_at! 1, file: another_av_out_file
  178. 1 expect(@av_out_file.resolution).to eq Ffmprb::HD_720p
  179. 1 expect(another_av_out_file.resolution).to eq Ffmprb::HD_720p
  180. 1 expect(@av_out_file.length).to be_approximately 6
  181. 1 expect(another_av_out_file.length).to be_approximately 9
  182. end
  183. end
  184. 1 it "should ignore broken pipes (or not)" do
  185. 1 [[:to, false, Ffmprb::Error], [:not_to, true, nil]].each do |to_not_to, ignore_broken_pipes, error|
  186. 2 Ffmprb::File.temp_fifo('.flv') do |av_pipe|
  187. 2 Thread.new do
  188. begin
  189. 2 tmp = File.open(av_pipe.path, 'r')
  190. 2 tmp.read(1)
  191. ensure
  192. 2 tmp.close if tmp
  193. end
  194. end
  195. 2 expect do
  196. 2 Ffmprb.process(@av_file_e_bow_9, ignore_broken_pipes: ignore_broken_pipes) do |file_input|
  197. 2 in1 = input(file_input)
  198. 2 output(av_pipe, video: {resolution: Ffmprb::HD_1080p, fps: 60}) do
  199. 2 roll in1.loop
  200. end
  201. end
  202. end.send to_not_to, raise_error(*error)
  203. end
  204. end
  205. end
  206. 1 it "should parse path arguments (and transcode)" do
  207. 1 Ffmprb.process(@av_file_e_bow_9.path, @av_out_file.path) do |file_input, file_output|
  208. 1 in1 = input(file_input)
  209. 1 output(file_output) do
  210. 1 roll in1
  211. end
  212. end
  213. 1 check_av_e_bow_at! 1
  214. 1 expect(@av_out_file.resolution).to eq Ffmprb::CGA
  215. 1 expect(@av_out_file.length).to be_approximately 9
  216. end
  217. 1 it "should concat" do
  218. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  219. 1 in1 = input(file_input)
  220. 1 output(file_output) do
  221. 1 roll in1
  222. 1 roll in1
  223. end
  224. end
  225. 1 check_av_c_gor_at! 2
  226. 1 check_av_c_gor_at! 8
  227. 1 expect(@av_out_file.length).to be_approximately 18
  228. end
  229. 1 it "should loop" do
  230. 1 Ffmprb::Util::ThreadedIoBuffer.block_size.tap do |default|
  231. begin
  232. # NOTE to check for excessive memory consumption during looping etc
  233. 1 Ffmprb::Util::ThreadedIoBuffer.block_size = 1024
  234. 1 Ffmprb.process(@av_file_btn_wtb_16, @av_out_stream_file) do |file_input, file_output|
  235. 1 in1 = input(file_input)
  236. 1 output(file_output) do
  237. 1 roll in1
  238. end
  239. end
  240. 1 expect(@av_out_stream_file.length).to be_approximately 16
  241. 1 Ffmprb.process(@av_out_stream_file, @av_out_file) do |file_input, file_output|
  242. 1 in1 = input(file_input)
  243. 1 output(file_output) do
  244. 1 roll in1.cut(to: 12).loop.cut(to: 47)
  245. end
  246. end
  247. 1 check_av_btn_wtb_at! 2
  248. 1 check_av_btn_wtb_at! 6, black: true
  249. 1 check_av_btn_wtb_at! 10
  250. 1 check_av_btn_wtb_at! 14
  251. 1 check_av_btn_wtb_at! 18, black: true
  252. 1 check_av_btn_wtb_at! 45
  253. 1 expect(@av_out_file.length).to be_approximately 47
  254. ensure
  255. 1 Ffmprb::Util::ThreadedIoBuffer.block_size = default
  256. end
  257. end
  258. end
  259. 1 it "should roll reels after specific time (cutting previous reels)" do
  260. 1 Ffmprb.process(@av_file_c_gor_9, @av_file_btn_wtb_16, @av_out_file) do |file_input, file_input_2, file_output|
  261. 1 in1 = input(file_input)
  262. 1 in2 = input(file_input_2)
  263. 1 output(file_output) do
  264. 1 roll in1
  265. 1 roll in2, after: 3
  266. end
  267. end
  268. 1 check_av_c_gor_at! 2
  269. 1 check_av_btn_wtb_at! 4
  270. 1 expect(@av_out_file.length).to be_approximately 19
  271. end
  272. 1 it "should roll reels after specific time (even the first one, adding blanks in the beginning)" do
  273. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  274. 1 in1 = input(file_input)
  275. 1 output(file_output) do
  276. 1 roll in1, after: 3
  277. end
  278. end
  279. 1 check_av_c_gor_at! 4
  280. 1 expect(@av_out_file.length).to be_approximately 12
  281. end
  282. 1 [12, 21].each do |duration|
  283. 2 it "should cut to precise duration (total 12 <=> cut after #{duration})" do
  284. 2 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  285. 2 in1 = input(file_input)
  286. 2 output(file_output) do
  287. 2 roll in1
  288. 2 roll in1.cut to: (duration - file_input.length)
  289. end
  290. end
  291. 2 check_av_c_gor_at! 5
  292. 2 check_av_c_gor_at! 7
  293. 2 expect(@av_out_file.length).to be_approximately duration
  294. end
  295. end
  296. 1 it "should crop segments" do
  297. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  298. 1 in1 = input(file_input)
  299. 1 output(file_output) do
  300. 1 roll in1.crop(0.25)
  301. 1 roll in1
  302. 1 roll in1.crop(width: 0.25, height: 0.25)
  303. 1 roll in1.crop(left: 0, top: 0, width: 0.25, height: 0.25)
  304. end
  305. end
  306. 1 @av_out_file.sample at: 5 do |snap, sound|
  307. 1 check_greenish! pixel_data(snap, 100, 10)
  308. 1 check_note! :C6, wave_data(sound)
  309. end
  310. 1 check_av_c_gor_at! 14
  311. 1 @av_out_file.sample at: 23 do |snap, sound|
  312. 1 check_greenish! pixel_data(snap, 100, 10)
  313. 1 check_note! :C6, wave_data(sound)
  314. end
  315. 1 @av_out_file.sample at: 32 do |snap, sound|
  316. 1 check_reddish! pixel_data(snap, 100, 10)
  317. 1 check_note! :C6, wave_data(sound)
  318. end
  319. end
  320. 1 it "should fail cropping segments unreasonably" do
  321. 1 expect do
  322. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  323. 1 output(file_output) do
  324. 1 roll input(file_input).crop 0.5
  325. end
  326. end
  327. end.to raise_error Ffmprb::Error
  328. end
  329. 1 it "should cut and crop segments" do
  330. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  331. 1 in1 = input(file_input)
  332. 1 output(file_output) do
  333. 1 roll in1.crop(0.25).cut(to: 3)
  334. 1 roll in1
  335. end
  336. end
  337. 1 @av_out_file.sample at: 2 do |snap, sound|
  338. 1 check_greenish! pixel_data(snap, 100, 10)
  339. 1 check_note! :C6, wave_data(sound)
  340. end
  341. 1 check_av_c_gor_at! 4
  342. 1 expect(@av_out_file.length).to be_approximately 12
  343. end
  344. 1 it "should cut and pace down segments" do
  345. 1 Ffmprb.process(@av_file_c_gor_9, @av_file_e_bow_9, @av_out_file) do |gor_file_input, bow_file_input, file_output|
  346. 1 in1 = input(gor_file_input)
  347. 1 in2 = input(bow_file_input)
  348. 1 output(file_output) do
  349. 1 roll in1.cut(to: 3).pace(0.5)
  350. 1 roll in2
  351. end
  352. end
  353. 1 check_av_c_gor_at! 5 # TODO sound wat?
  354. 1 check_av_e_bow_at! 7
  355. 1 expect(@av_out_file.length).to be_approximately 15
  356. end
  357. 1 it "should cut and pace up segments" do
  358. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |gor_file_input, file_output|
  359. 1 in1 = input(gor_file_input)
  360. 1 output(file_output) do
  361. 1 roll in1.pace(3).cut(from: 1)
  362. end
  363. end
  364. 1 expect(@av_out_file.length).to be_approximately 2
  365. end
  366. # TODO might be insufficient
  367. 1 it "should cut segments in any order" do
  368. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |file_input, file_output|
  369. 1 in1 = input(file_input)
  370. 1 output(file_output) do
  371. 1 roll in1.cut(from: 1)
  372. 1 roll in1.crop(0.25).cut(to: 5)
  373. end
  374. end
  375. 1 check_av_c_gor_at! 1
  376. 1 @av_out_file.sample at: 9 do |snap, sound|
  377. 1 check_greenish! pixel_data(snap, 100, 10)
  378. 1 check_note! :C6, wave_data(sound)
  379. end
  380. 1 expect(@av_out_file.length).to be_approximately 13
  381. end
  382. 1 it "should reverse segments etc" do
  383. 1 Ffmprb::File.temp_fifo('.flv') do |av_pipe|
  384. 1 Ffmprb::Util::Thread.new do
  385. 1 Ffmprb.process(@av_file_c_gor_9, @av_file_e_bow_9, av_pipe) do |gor_file_input, bow_file_input, stream_output|
  386. 1 in1 = input(gor_file_input)
  387. 1 in2 = input(bow_file_input)
  388. 1 output(stream_output) do
  389. 1 roll in1.cut(to: 3).pace(0.5)
  390. # NOTE a (very) basic 'pp' test
  391. 1 roll in2.cut(from: 1, to: 11).pp # NOTE a little padding
  392. end
  393. end
  394. end
  395. 1 Ffmprb.process(av_pipe, @av_out_file) do |stream_input, file_output|
  396. 1 in1 = input(stream_input)
  397. 1 output(file_output) do
  398. 1 roll in1.reverse
  399. end
  400. end
  401. end
  402. # NOTE the `pp` made me change the 'bow' check to be approximate
  403. 1 check_av_e_bow_at! 7
  404. 1 check_av_c_gor_at! 11
  405. 1 expect(@av_out_file.length).to be_approximately 16
  406. end
  407. 1 it "should change volume and mute" do
  408. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |av_i, av_o|
  409. 1 in1 = input(av_i)
  410. 1 output(av_o) do
  411. 1 roll in1.cut(to: 4)
  412. 1 roll in1.cut(to: 4).mute
  413. 1 roll in1.cut(to: 4).volume(0.5)
  414. end
  415. end
  416. 1 expect(
  417. 3 [5, 9, 1].map{|s| wave_data(@av_out_file.sample_audio at: s).volume}
  418. ).to be_ascending
  419. end
  420. 1 it "should modulate volume" do
  421. 1 Ffmprb.process(@av_file_c_gor_9, @av_out_file) do |av_i, av_o|
  422. 1 in1 = input(av_i)
  423. 1 output(av_o) do
  424. 1 roll in1.cut(to: 3)
  425. 1 roll in1.volume(1.9 => 0, 4.1 => 0, 6 => 0.5, 7.9 => 1)
  426. end
  427. end
  428. 8 volume_at = ->(sec){wave_data(@av_out_file.sample_audio at: sec).volume}
  429. 1 expect(volume_at.call 0.1).to be_approximately volume_at.call(11)
  430. 1 expect(
  431. [4, 3.75, 3.5].map(&volume_at)
  432. ).to be_ascending
  433. 1 expect(volume_at.call 5).to eq volume_at.call 6
  434. end
  435. 1 it "should detect silence and pass input to output" do
  436. 1 silence = Ffmprb.find_silence(@av_file_btn_wtb_16, @av_out_file)
  437. 1 expect(silence.length).to eq 2
  438. 1 prev_silent_end_at = 0
  439. 1 silence.each do |silent|
  440. 2 @av_out_file.sample at: silent.start_at + 1 do |image, sound|
  441. 2 expect(wave_data(sound).volume).to eq MIN_VOLUME
  442. 2 check_black! pixel_data(image, 100, 100)
  443. end
  444. 2 @av_out_file.sample at: (prev_silent_end_at + silent.start_at)/2 do |image, sound|
  445. 2 expect(wave_data(sound).volume).to be > MIN_VOLUME
  446. 2 check_white! pixel_data(image, 100, 100)
  447. end
  448. 2 prev_silent_end_at = silent.end_at
  449. end
  450. end
  451. 1 context "media" do
  452. 13 let(:m_input) {{video: @v_file_6, audio: @a_file_g_16}}
  453. 13 let(:m_output_extname) {{video: '.y4m', audio: '.wav'}}
  454. medium_params = {
  455. 1 video: {},
  456. audio: {encoder: nil}
  457. }
  458. 1 [[:video, :audio], [:audio, :video]].each do |medium, not_medium|
  459. [
  460. 2 lambda do |av_file_input, m_file_input, m_file_output| ##1
  461. 2 in1 = input(av_file_input)
  462. 2 output(m_file_output, medium => medium_params[medium], not_medium => false) do
  463. 2 roll in1.cut(from: 3, to: 5)
  464. 2 roll in1.cut(from: 3, to: 5)
  465. end
  466. end,
  467. lambda do |av_file_input, m_file_input, m_file_output| ##2
  468. 2 in1 = input(av_file_input)
  469. 2 output(m_file_output, medium => medium_params[medium]) do
  470. 2 roll in1.send(medium).cut(from: 3, to: 5)
  471. 2 roll in1.send(medium).cut(from: 3, to: 5)
  472. end
  473. end,
  474. lambda do |av_file_input, m_file_input, m_file_output| ##3
  475. 2 in1 = input(av_file_input)
  476. 2 output(m_file_output, medium => medium_params[medium]) do
  477. 2 roll in1.cut(from: 3, to: 5)
  478. 2 roll in1.cut(from: 3, to: 5)
  479. end
  480. end,
  481. lambda do |av_file_input, m_file_input, m_file_output| ##4
  482. 2 in1 = input(m_file_input)
  483. 2 output(m_file_output, medium => medium_params[medium], not_medium => false) do
  484. 2 roll in1.cut(from: 3, to: 5)
  485. 2 roll in1.cut(from: 3, to: 5)
  486. end
  487. end,
  488. lambda do |av_file_input, m_file_input, m_file_output| ##5
  489. 2 in1 = input(m_file_input)
  490. 2 output(m_file_output, medium => medium_params[medium]) do
  491. 2 roll in1.send(medium).cut(from: 3, to: 5)
  492. 2 roll in1.send(medium).cut(from: 3, to: 5)
  493. end
  494. end,
  495. lambda do |av_file_input, m_file_input, m_file_output| ##6
  496. 2 in1 = input(m_file_input)
  497. 2 output(m_file_output, medium => medium_params[medium]) do
  498. 2 roll in1.cut(from: 3, to: 5)
  499. 2 roll in1.cut(from: 3, to: 5)
  500. end
  501. end
  502. ].each_with_index do |script, i|
  503. 12 it "should work with video only and audio only, as input and as output (#{medium}##{i+1})" do
  504. 12 Ffmprb::File.temp(m_output_extname[medium]) do |m_output|
  505. 12 Ffmprb.process(@av_file_c_gor_9, m_input[medium], m_output, &script)
  506. 12 m_output.sample at: 2.5, medium => true, not_medium => false do |sample|
  507. 12 case medium
  508. when :video
  509. 6 check_greenish! pixel_data(sample, 100, 100)
  510. when :audio
  511. 12 expect{wave_data(m_output)}.not_to raise_error # NOTE audio format compat. check
  512. 6 check_note! (i < 3 ? :C6 : :G6), wave_data(sample)
  513. end
  514. end
  515. 12 expect(m_output.length).to be_approximately 4
  516. 12 expect{
  517. 12 m_output.sample at: 3, not_medium => true, medium => false
  518. }.to raise_error Ffmprb::Error
  519. end
  520. end
  521. end
  522. end
  523. end
  524. 1 context "stitching" do
  525. 1 it "should transition between two reels" do
  526. 1 Ffmprb.process(@av_file_c_gor_9, @av_file_e_bow_9, @av_out_file) do |input1, input2, output1|
  527. 1 in1, in2 = input(input1), input(input2)
  528. 1 output(output1) do
  529. 1 lay in1.crop(0.25), transition: {blend: 3}
  530. 1 lay in2.crop(left: 0, top: 0, width: 0.1, height: 0.1).cut(to: 8), after: 6, transition: {blend: 2}
  531. # TODO (see below): fin transition: {blend: 4}
  532. end
  533. end
  534. 1 last_green = nil
  535. 1 last_volume = nil
  536. # NOTE should transition from black+silent to green+C6 in 3 secs
  537. 1 times = [0.1, 1.1, 2.1, 3.1, 4.1]
  538. 1 times.each do |at|
  539. 5 @av_out_file.sample at: at do |snap, sound|
  540. 5 pixel = pixel_data(snap, 100, 100)
  541. 5 check_greenish! pixel
  542. 5 if last_green
  543. 4 if at == times[-1]
  544. 1 expect(pixel.green).to eq last_green
  545. else
  546. 3 expect(pixel.green).to be > last_green
  547. end
  548. end
  549. 5 last_green = pixel.green
  550. 5 wave = wave_data(sound)
  551. 5 check_note! :C6, wave
  552. 5 if last_volume
  553. 4 if at == times[-1]
  554. 1 expect(wave.volume).to be_approximately last_volume
  555. else
  556. 3 expect(wave.volume).to be > last_volume
  557. end
  558. end
  559. 5 last_volume = wave.volume
  560. end
  561. end
  562. 1 last_red = nil
  563. 1 last_frequency = nil
  564. # NOTE should transition from green+C6 to white+E6 in 2 secs
  565. 1 times = [4.5, 5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5]
  566. 1 times.each do |at|
  567. 9 @av_out_file.sample at: at do |snap, sound|
  568. 9 pixel = pixel_data(snap, 100, 100)
  569. 9 check_greenish! pixel unless times[-2..-1].include? at
  570. 9 expect(0xFFFF - pixel.red).to be_approximately (0xFFFF - pixel.blue)
  571. 9 if last_red
  572. 8 if times.values_at(0..2, -1).include? at
  573. 3 expect(pixel.red).to eq last_red
  574. else
  575. 5 expect(pixel.red).to be > last_red
  576. end
  577. end
  578. 9 last_red = pixel.red
  579. 9 wave = wave_data(sound)
  580. 9 if times[0..1].include? at
  581. 2 check_note! :C6, wave
  582. 7 elsif times[-2..-1].include? at
  583. 2 check_note! :E6, wave
  584. else
  585. 5 expect(wave.frequency).to be > last_frequency
  586. end
  587. 9 last_frequency = wave.frequency
  588. end
  589. end
  590. # NOTE should transition from white+E6 to black+silent in 4 secs
  591. # TODO times = [10.9, 11.9, 12.9]
  592. 1 expect(@av_out_file.length).to be_approximately 14
  593. end
  594. 1 it "should run an external effect tool for a transition"
  595. end
  596. 1 context :audio_overlay do
  597. 1 it "should overlay sound with volume" do
  598. 1 Ffmprb.process(@av_file_btn_wtb_16, @a_file_g_16, @av_out_file) do |input1, input2, output1|
  599. 1 in1 = input(input1)
  600. 1 in2 = input(input2)
  601. 1 output(output1) do
  602. 1 lay in1.volume(0 => 0.5, 4 => 0.5, 5 => 1)
  603. 1 overlay in2.cut(to: 5).volume(2.0 => 0, 4.0 => 1)
  604. end
  605. end
  606. volume_first =
  607. 1 wave_data(@av_out_file.sample at: 0, video: false) do |sound|
  608. expect(sound.frequency).to be_between NOTES.G6, NOTES.B6
  609. sound.volume
  610. end
  611. 1 check_av_btn_wtb_at! 2
  612. 1 wave_data(@av_out_file.sample at: 2, video: false) do |sound|
  613. expect(sound.frequency).to be_approximately NOTES.B6
  614. expect(sound.volume).to be < volume_first
  615. end
  616. 1 wave_data(@av_out_file.sample at: 4, video: false) do |sound|
  617. expect(sound.frequency).to be_between NOTES.G6, NOTES.B6
  618. expect(sound.volume).to be_approximately volume_first
  619. end
  620. 1 expect(
  621. wave_data(@av_out_file.sample at: 9, video: false).frequency
  622. ).to be_approximately NOTES.B6
  623. end
  624. 1 it "should loop and duck the overlay sound wrt the main sound" do
  625. 1 Ffmprb.process(@av_file_btn_wtb_16, @a_file_g_16, @av_out_file) do |input1, input2, output1|
  626. 1 in1 = input(input1)
  627. 1 in2 = input(input2)
  628. 1 output(output1, video: {resolution: '800x600'}) do
  629. 1 lay in1.loop(2), transition: {blend: 1}
  630. 1 overlay in2.loop, duck: :audio
  631. end
  632. end
  633. 1 @av_out_file.sample at: 2 do |snap, sound|
  634. 1 check_white! pixel_data(snap, 100, 100)
  635. 1 expect(wave_data(sound).frequency).to be_between(NOTES.G6, NOTES.B6)
  636. end
  637. 1 @av_out_file.sample at: 6 do |snap, sound|
  638. 1 check_black! pixel_data(snap, 100, 100)
  639. 1 expect(wave_data(sound).frequency).to be_within(10).of NOTES.G6
  640. end
  641. 1 expect(@av_out_file.resolution).to eq '800x600'
  642. 1 expect(@av_out_file.length).to be_approximately 32
  643. end
  644. 1 it "should duck some overlay sound wrt some main sound" do
  645. 1 Ffmprb::Util::ThreadedIoBuffer.block_size.tap do |default|
  646. begin
  647. 1 Ffmprb::Util::ThreadedIoBuffer.block_size = 1024 # NOTE to check for excessive memory consumption during looping etc
  648. 1 Ffmprb.process @av_file_btn_wtb_16, @a_file_g_16, @av_out_file do |input1, input2, output1|
  649. 1 in1 = input(input1)
  650. 1 in2 = input(input2)
  651. 1 output(output1) do
  652. 1 lay in1.cut(to: 10), transition: {blend: 1}
  653. 1 overlay in2.cut(from: 4).loop, duck: :audio
  654. end
  655. end
  656. 1 @av_out_file.sample at: 2 do |snap, sound|
  657. 1 check_white! pixel_data(snap, 100, 100)
  658. 1 expect(wave_data(sound).frequency).to be_between(NOTES.G6, NOTES.B6)
  659. end
  660. 1 @av_out_file.sample at: 6 do |snap, sound|
  661. 1 check_black! pixel_data(snap, 100, 100)
  662. 1 expect(wave_data(sound).frequency).to be_within(10).of NOTES.G6
  663. end
  664. 1 expect(@av_out_file.length).to be_approximately 10
  665. ensure
  666. 1 Ffmprb::Util::ThreadedIoBuffer.block_size = default
  667. end
  668. end
  669. end
  670. 1 it "should duck some overlay sound wrt some main sound" do
  671. 1 Ffmprb.process(@a_file_g_16, @a_out_file) do |input1, output1|
  672. 1 in1 = input(input1)
  673. 1 output(output1) do
  674. 1 roll in1.cut(from: 4, to: 12), transition: {blend: 1}
  675. 1 overlay in1, duck: :audio
  676. end
  677. end
  678. 1 expect(@a_out_file.length).to be_approximately(8)
  679. 1 [2, 6].each do |at|
  680. 2 check_note! :G6, wave_data(@a_out_file.sample_audio at: at)
  681. end
  682. end
  683. end
  684. 1 context :samples do
  685. 1 it "should shoot snaps"
  686. end
  687. end
  688. 1 context :info do
  689. 1 it "should return the length of a clip" do
  690. 1 expect(@av_file_c_gor_9.length).to be_approximately 9
  691. end
  692. end
  693. 1 def pixel_data(snap, x, y)
  694. 79 Magick::Image.read(snap.path)[0].pixel_color(x, y)
  695. end
  696. 1 def wave_data(sound)
  697. 82 sox_info = Ffmprb::Util.sh(Sox::SOX_COMMAND, sound.path, '-n', 'stat', output: :stderr)
  698. 82 OpenStruct.new.tap do |data|
  699. 82 data.frequency = $1.to_f if sox_info =~ /Rough\W+frequency:\W*([\d.]+)/
  700. 82 data.frequency = 0 unless data.frequency && data.frequency > 0
  701. 82 data.volume = -$1.to_f if sox_info =~ /Volume\W+adjustment:\W*([\d.]+)/
  702. 82 data.volume ||= MIN_VOLUME
  703. end
  704. end
  705. 1 def channel_min(pixel)
  706. 13 [pixel.red, pixel.green, pixel.blue].min
  707. end
  708. 1 def channel_max(pixel)
  709. 9 [pixel.red, pixel.green, pixel.blue].max
  710. end
  711. end

spec/file_spec.rb

100.0% lines covered

143 relevant lines. 143 lines covered and 0 lines missed.
    
  1. 1 require 'mkfifo'
  2. 1 TST_STR_6K = 'Roger?' * 1024
  3. 1 def diff_index(s1, s2)
  4. return [s1.length, s2.length].min unless
  5. 1029 s1.length == s2.length
  6. 22420485 s1.chars.each_with_index.find_index { |c, i| s2[i] != c }
  7. end
  8. 1 describe Ffmprb::File do
  9. 1 around do |example|
  10. 9 Ffmprb::Util::ThreadedIoBuffer.block_size.tap do |default|
  11. begin
  12. 9 Ffmprb::Util::ThreadedIoBuffer.block_size = 1024
  13. 9 example.run
  14. ensure
  15. 9 Ffmprb::Util::ThreadedIoBuffer.block_size = default
  16. end
  17. end
  18. end
  19. 1 it "should wrap ruby Files" do
  20. 1 described_class.is_a? ::File
  21. end
  22. 1 context "simple buffered fifos" do
  23. 1 around do |example|
  24. 5 Ffmprb::Util::Thread.new "test", main: true do
  25. 5 @fifo = described_class.threaded_buffered_fifo '.ext'
  26. 5 example.run
  27. 5 Ffmprb::Util::Thread.join_children!
  28. end.join
  29. end
  30. 1 it "should have the destination readable (while writing to)" do
  31. # piggy-backing another test
  32. 1 expect(@fifo[0].extname).to eq '.ext'
  33. 1 expect(@fifo[1].extname).to eq '.ext'
  34. 1 Timeout.timeout 6 do
  35. 1 file_out = File.open(@fifo[0].path, 'w')
  36. 1 file_in = File.open(@fifo[1].path, 'r')
  37. 1 writer = Thread.new do
  38. 1 512.times do
  39. 512 file_out.write TST_STR_6K
  40. end
  41. 1 file_out.close
  42. end
  43. 1 reader = Thread.new do
  44. 1 512.times do
  45. 512 expect(diff_index file_in.read(6*1024), TST_STR_6K).to eq nil
  46. end
  47. 1 expect(file_in.read 1).to eq nil # EOF
  48. 1 file_in.close
  49. end
  50. 1 writer.join
  51. 1 reader.join
  52. end
  53. end
  54. 1 it "should not timeout if the reader is a bit slow" do
  55. 1 Ffmprb::Util::ThreadedIoBuffer.timeout_limit.tap do |default|
  56. begin
  57. 1 Ffmprb::Util::ThreadedIoBuffer.timeout_limit = 2
  58. 1 File.open(@fifo[0].path, 'w') do |file_out|
  59. 1 File.open(@fifo[1].path, 'r') do |file_in|
  60. 1 Timeout.timeout(8) do
  61. 1 thr = Thread.new do
  62. 1 file_out.write(TST_STR_6K * 512)
  63. 1 file_out.close
  64. end
  65. 1 sleep 1
  66. 1 expect(diff_index file_in.read, TST_STR_6K * 512).to eq nil
  67. 1 thr.join
  68. 1 Ffmprb::Util::Thread.join_children!
  69. end
  70. end
  71. end
  72. ensure
  73. 1 Ffmprb::Util::ThreadedIoBuffer.timeout_limit = default
  74. end
  75. end
  76. end
  77. 1 it "should timeout if the reader is very slow" do
  78. 1 Ffmprb::Util::ThreadedIoBuffer.timeout_limit.tap do |default|
  79. begin
  80. 1 Ffmprb::Util::ThreadedIoBuffer.timeout_limit = 2
  81. 1 File.open(@fifo[0].path, 'w') do |file_out|
  82. 1 File.open(@fifo[1].path, 'r') do |file_in|
  83. 1 Timeout.timeout(8) do
  84. 1 expect{
  85. 1 file_out.write(TST_STR_6K * 1024)
  86. }.to raise_error Errno::EPIPE
  87. end
  88. end
  89. end
  90. 1 expect{
  91. 1 Ffmprb::Util::Thread.join_children!
  92. }.to raise_error Ffmprb::Error
  93. ensure
  94. 1 Ffmprb::Util::ThreadedIoBuffer.timeout_limit = default
  95. end
  96. end
  97. end
  98. 1 it "should be writable (before the destination is ever read), up to the buffer size(1024*1024)" do
  99. 1 Timeout.timeout(2) do
  100. 1 file_out = File.open(@fifo[0].path, 'w')
  101. 1 file_in = File.open(@fifo[1].path, 'r')
  102. 1 file_out.write(TST_STR_6K * 64)
  103. 1 file_out.close
  104. 1 expect(diff_index file_in.read, TST_STR_6K * 64).to eq nil
  105. 1 file_in.close
  106. end
  107. end
  108. 1 it "should break the writer if the reader is broken" do
  109. 1 Timeout.timeout(2) do
  110. 1 file_out = File.open(@fifo[0].path, 'w')
  111. 1 file_in = File.open(@fifo[1].path, 'r')
  112. 1 thr = Thread.new do
  113. begin
  114. 1 file_in.read(64)
  115. ensure
  116. 1 file_in.close
  117. end
  118. end
  119. 1 expect {
  120. begin
  121. 1 file_out.write(TST_STR_6K * 1024)
  122. ensure
  123. 1 file_out.close
  124. end
  125. }.to raise_error Errno::EPIPE
  126. 1 thr.join
  127. end
  128. end
  129. end
  130. 1 context "N-Tee buffering" do
  131. 1 around do |example|
  132. 3 temp_fifos = []
  133. 3 temp_fifos << @master_fifo = described_class.temp_fifo
  134. 3 temp_fifos << @copy_fifo1 = described_class.temp_fifo
  135. 3 temp_fifos << @copy_fifo2 = described_class.temp_fifo
  136. 3 temp_fifos << @copy_fifo3 = described_class.temp_fifo
  137. begin
  138. 3 example.run
  139. ensure
  140. 3 temp_fifos.each &:unlink
  141. end
  142. end
  143. 1 it "should feed readers everything the writer has written" do
  144. 1 Timeout.timeout(15) do
  145. 1 thrs = []
  146. 1 thrs << Thread.new do
  147. 1 File.open @copy_fifo1.path, 'r' do |file|
  148. 1 expect(diff_index file.read(6*1024), TST_STR_6K).to eq nil
  149. end
  150. end
  151. 1 thrs << Thread.new do
  152. 1 File.open @copy_fifo2.path, 'r' do |file|
  153. 1 512.times {
  154. 512 expect(diff_index file.read(6*1024), TST_STR_6K).to eq nil
  155. 512 sleep 0.001
  156. }
  157. end
  158. end
  159. 1 thrs << Thread.new do
  160. 1 sleep 1
  161. 1 File.open @copy_fifo3.path, 'r' do |file|
  162. 1 expect(diff_index file.read, TST_STR_6K * 1024).to eq nil
  163. end
  164. end
  165. 1 @master_fifo.threaded_buffered_copy_to @copy_fifo1, @copy_fifo2, @copy_fifo3
  166. 1 File.open @master_fifo.path, 'w' do |file|
  167. 1 file.write TST_STR_6K * 1024
  168. end
  169. 1 thrs.each &:join
  170. end
  171. end
  172. 1 it "should pass on closed readers" do
  173. 1 Timeout.timeout(15) do
  174. 1 thrs = []
  175. 1 thrs << Thread.new do
  176. 1 File.open @copy_fifo1.path, 'r' do |file|
  177. 1 file.read 64
  178. end
  179. end
  180. 1 thrs << Thread.new do
  181. 1 File.open @copy_fifo2.path, 'r' do |file|
  182. 1 1024.times {
  183. 1024 file.read 1024
  184. 1024 sleep 0.001
  185. }
  186. end
  187. 1 File.open @copy_fifo3.path, 'r' do |file|
  188. 1 expect(diff_index file.read, TST_STR_6K * 1024).to eq nil
  189. end
  190. end
  191. 1 @master_fifo.threaded_buffered_copy_to @copy_fifo1, @copy_fifo2, @copy_fifo3
  192. 1 File.open @master_fifo.path, 'w' do |file|
  193. 1 file.write(TST_STR_6K * 1024)
  194. end
  195. 1 thrs.each &:join
  196. end
  197. end
  198. 1 it "should terminate once all readers are done or broken" do
  199. 1 Timeout.timeout(15) do
  200. 1 thrs = []
  201. 1 thrs << Thread.new do
  202. 1 File.open @copy_fifo1.path, 'r' do |file|
  203. 1 file.read 64
  204. end
  205. end
  206. 1 thrs << Thread.new do
  207. 1 File.open @copy_fifo2.path, 'r' do |file|
  208. 1 1024.times { |i|
  209. 1024 expect(file.read(1024).length).to eq 1024
  210. 1024 sleep 0.001
  211. }
  212. end
  213. 1 File.open @copy_fifo3.path, 'r' do |file|
  214. 1 file.read 64
  215. end
  216. end
  217. 1 @master_fifo.threaded_buffered_copy_to @copy_fifo1, @copy_fifo2, @copy_fifo3
  218. 1 expect {
  219. 1 File.open @master_fifo.path, 'w' do |file|
  220. 1 i = file.write(TST_STR_6K * 1024)
  221. end
  222. }.to raise_error Errno::EPIPE
  223. 1 thrs.each &:join
  224. end
  225. end
  226. end
  227. end

spec/util/thread_spec.rb

96.97% lines covered

66 relevant lines. 64 lines covered and 2 lines missed.
    
  1. 1 class StamError < RuntimeError; end
  2. 1 describe Ffmprb::Util::Thread do
  3. 1 describe 'timeout_or_live' do
  4. 1 it "should act normal under normal circumstances" do
  5. 1 q = Queue.new
  6. 1 Thread.new do
  7. 1 sleep 0.5
  8. 1 q.enq "OK"
  9. end
  10. 1 thr = Ffmprb::Util::Thread.new main: true do
  11. 1 Ffmprb::Util::Thread.timeout_or_live(1, timeout: 0.25) do
  12. 2 q.deq
  13. end
  14. end
  15. 1 Timeout.timeout(0.9) do
  16. 1 expect(thr.value).to eq "OK"
  17. end
  18. end
  19. 1 it "should supply a thread with means to time out" do
  20. 1 q = Queue.new
  21. 1 Thread.new do
  22. 1 sleep 1.5
  23. 1 q.enq "OK"
  24. end
  25. 1 thr = Ffmprb::Util::Thread.new main: true do
  26. 1 Ffmprb::Util::Thread.timeout_or_live(1, timeout: 0.25) do
  27. 4 q.deq
  28. end
  29. end
  30. 1 Timeout.timeout 1.1 do
  31. 2 expect{thr.value}.to raise_error Ffmprb::Util::TimeLimitError
  32. end
  33. end
  34. 1 it "should supply a thread with means to bail out" do
  35. 1 q = Queue.new
  36. 1 Thread.new do
  37. 1 sleep 1.5
  38. 1 q.enq "OK"
  39. end
  40. 1 thr = Ffmprb::Util::Thread.new main: true do
  41. 1 Ffmprb::Util::Thread.timeout_or_live(timeout: 0.25) do |time|
  42. 5 fail StamError if time > 1
  43. 4 q.deq
  44. end
  45. end
  46. 1 Timeout.timeout 1.1 do
  47. 2 expect{thr.value}.to raise_error StamError
  48. end
  49. end
  50. 1 it "should fail a thread when its (any) parent dies (tragically)" do
  51. 1 in_thr = nil
  52. 1 thr = Thread.new do
  53. 1 in_thr = Ffmprb::Util::Thread.new "inner", main: true do
  54. 1 Ffmprb::Util::Thread.timeout_or_live(timeout: 0.5) do
  55. 1 sleep 1
  56. end
  57. "OK"
  58. end
  59. 1 fail StamError
  60. end
  61. 1 Timeout.timeout 0.1 do # just to be sure thr is ended
  62. 2 expect{thr.join}.to raise_error StamError
  63. end
  64. 1 expect(in_thr).to be_alive
  65. 1 Timeout.timeout 0.9 do
  66. 2 expect{in_thr.value}.to raise_error Ffmprb::Util::Thread::ParentError
  67. end
  68. end
  69. end
  70. 1 describe 'join_children!' do
  71. 1 it "should 'release' a thread when its sibling dies (tragically)" do
  72. 1 Ffmprb::Util::Thread.timeout.tap do |timeout|
  73. 1 Ffmprb::Util::Thread.timeout = 0.5
  74. 1 in_thr = nil
  75. 1 thr = Ffmprb::Util::Thread.new "main", main: true do
  76. 1 in_thr = Ffmprb::Util::Thread.new "sib1" do
  77. 1 Ffmprb::Util::Thread.timeout_or_live(timeout: 0.5) do |time|
  78. 1 sleep 1
  79. end
  80. "OK"
  81. end
  82. 1 Ffmprb::Util::Thread.new "sib2" do
  83. 1 fail StamError
  84. end
  85. 1 Ffmprb::Util::Thread.join_children!
  86. end
  87. 1 Timeout.timeout 0.9 do # just to be sure thr is ended
  88. 2 expect{thr.join}.to raise_error StamError
  89. end
  90. 1 expect(in_thr).to be_alive
  91. 1 Timeout.timeout 1.1 do
  92. 2 expect{in_thr.join}.to raise_error Ffmprb::Util::Thread::ParentError
  93. end
  94. 1 Ffmprb::Util::Thread.timeout = timeout
  95. end
  96. end
  97. end
  98. end