├── lib ├── rvideo │ ├── float.rb │ ├── version.rb │ ├── tools │ │ ├── mp4box.rb │ │ ├── mplayer.rb │ │ ├── mp4creator.rb │ │ ├── ffmpeg2theora.rb │ │ ├── yamdi.rb │ │ ├── flvtool2.rb │ │ ├── mencoder.rb │ │ ├── ffmpeg.rb │ │ └── abstract_tool.rb │ ├── errors.rb │ ├── transcoder.rb │ └── inspector.rb └── rvideo.rb ├── RULES ├── License.txt ├── History.txt ├── README.txt └── Rakefile /lib/rvideo/float.rb: -------------------------------------------------------------------------------- 1 | # Add a rounding method to the Float class. 2 | class Float 3 | def round_to(x) 4 | (self * 10**x).round.to_f / 10**x 5 | end 6 | end 7 | 8 | -------------------------------------------------------------------------------- /lib/rvideo/version.rb: -------------------------------------------------------------------------------- 1 | module Rvideo #:nodoc: 2 | module VERSION #:nodoc: 3 | MAJOR = 0 4 | MINOR = 9 5 | TINY = 5 6 | 7 | STRING = [MAJOR, MINOR, TINY].join('.') 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /RULES: -------------------------------------------------------------------------------- 1 | Collection of transcoding edge cases and rules 2 | ------------------------ 3 | 4 | * mpeg4 output errors out if frame rate is not supplied 5 | 6 | [mpeg4 @ 0x149e810]timebase not supported by mpeg 4 standard 7 | Error while opening codec for output stream #0.0 - maybe incorrect parameters such as bit_rate, rate, width or height 8 | 9 | Solution: provide a frame rate with -r (any frame rate will do) 10 | 11 | -------------------------------------------------------------------------------- /lib/rvideo/tools/mp4box.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Mp4box 4 | include AbstractTool::InstanceMethods 5 | attr_reader :raw_metadata 6 | 7 | def tool_command 8 | 'MP4Box' 9 | end 10 | 11 | private 12 | 13 | def parse_result(result) 14 | #currently, no useful info returned in result to determine if successful or not 15 | @raw_metadata = result.empty? ? "No Results" : result 16 | return true 17 | end 18 | 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rvideo/errors.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | class TranscoderError < RuntimeError 3 | class InvalidCommand < TranscoderError 4 | end 5 | 6 | class InvalidFile < TranscoderError 7 | end 8 | 9 | class InputFileNotFound < TranscoderError 10 | end 11 | 12 | class UnexpectedResult < TranscoderError 13 | end 14 | 15 | class ParameterError < TranscoderError 16 | end 17 | 18 | class UnknownError < TranscoderError 19 | end 20 | 21 | class UnknownTool < TranscoderError 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/rvideo.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.dirname(__FILE__) + '/rvideo' 2 | 3 | require 'inspector' 4 | require 'float' 5 | require 'tools/abstract_tool' 6 | require 'tools/ffmpeg' 7 | require 'tools/mencoder' 8 | require 'tools/flvtool2' 9 | require 'tools/mp4box' 10 | require 'tools/mplayer' 11 | require 'tools/mp4creator' 12 | require 'tools/ffmpeg2theora' 13 | require 'tools/yamdi' 14 | require 'errors' 15 | require 'transcoder' 16 | require 'active_support' 17 | 18 | TEMP_PATH = File.expand_path(File.dirname(__FILE__) + '/../tmp') 19 | FIXTURE_PATH = File.expand_path(File.dirname(__FILE__) + '/../spec/fixtures') 20 | TEST_FILE_PATH = File.expand_path(File.dirname(__FILE__) + '/../spec/files') 21 | REPORT_PATH = File.expand_path(File.dirname(__FILE__) + '/../report') 22 | 23 | -------------------------------------------------------------------------------- /lib/rvideo/tools/mplayer.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Mplayer 4 | include AbstractTool::InstanceMethods 5 | 6 | attr_reader :raw_metadata 7 | 8 | def tool_command 9 | 'mplayer' 10 | end 11 | 12 | def parse_result(result) 13 | if m = /This will likely crash/.match(result) 14 | raise TranscoderError::InvalidFile, "unknown format" 15 | end 16 | 17 | if m = /Failed to open/.match(result) 18 | raise TranscoderError::InvalidFile, "I/O error" 19 | end 20 | 21 | if m = /File not found/.match(result) 22 | raise TranscoderError::InvalidFile, "I/O error" 23 | end 24 | 25 | @raw_metadata = result.empty? ? "No Results" : result 26 | return true 27 | end 28 | 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/rvideo/tools/mp4creator.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Mp4creator 4 | include AbstractTool::InstanceMethods 5 | 6 | attr_reader :raw_metadata 7 | 8 | def tool_command 9 | 'mp4creator' 10 | end 11 | 12 | def format_fps(params={}) 13 | " -rate=#{params[:fps]}" 14 | end 15 | 16 | def parse_result(result) 17 | if m = /can't open file/.match(result) 18 | raise TranscoderError::InvalidFile, "I/O error" 19 | end 20 | 21 | if m = /unknown file type/.match(result) 22 | raise TranscoderError::InvalidFile, "I/O error" 23 | end 24 | 25 | if @options['output_file'] && !File.exist?(@options['output_file']) 26 | raise TranscoderError::UnexpectedResult, "An unknown error has occured with mp4creator:#{result}" 27 | end 28 | 29 | @raw_metadata = result.empty? ? "No Results" : result 30 | return true 31 | end 32 | 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /License.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2007 Jonathan Dahl and Slantwise Design 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /lib/rvideo/tools/ffmpeg2theora.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Ffmpeg2theora 4 | include AbstractTool::InstanceMethods 5 | 6 | attr_reader :raw_metadata 7 | 8 | def tool_command 9 | 'ffmpeg2theora' 10 | end 11 | 12 | def format_video_quality(params={}) 13 | bitrate = params[:video_bit_rate].blank? ? nil : params[:video_bit_rate] 14 | factor = (params[:scale][:width].to_f * params[:scale][:height].to_f * params[:fps].to_f) 15 | case params[:video_quality] 16 | when 'low' 17 | " -v 1 " 18 | when 'medium' 19 | "-v 5 " 20 | when 'high' 21 | "-v 10 " 22 | else 23 | "" 24 | end 25 | end 26 | 27 | def parse_result(result) 28 | if m = /does not exist or has an unknown data format/.match(result) 29 | raise TranscoderError::InvalidFile, "I/O error" 30 | end 31 | 32 | if m = /General output options/.match(result) 33 | raise TranscoderError::InvalidCommand, "no command passed to ffmpeg2theora, or no output file specified" 34 | end 35 | 36 | @raw_metadata = result.empty? ? "No Results" : result 37 | return true 38 | end 39 | 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/rvideo/tools/yamdi.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Yamdi 4 | include AbstractTool::InstanceMethods 5 | 6 | attr_reader :raw_metadata 7 | 8 | def tool_command 9 | 'yamdi' 10 | end 11 | 12 | private 13 | 14 | def parse_result(result) 15 | if result.empty? 16 | return true 17 | end 18 | 19 | if m = /Couldn't stat on (.*)/.match(result) 20 | raise TranscoderError::InputFileNotFound, m[0] 21 | end 22 | 23 | if m = /The input file is not a FLV./.match(result) 24 | raise TranscoderError::InvalidFile, "input must be a valid FLV file" 25 | end 26 | 27 | if m = /\(c\) \d{4} Ingo Oppermann/i.match(result) 28 | raise TranscoderError::InvalidCommand, "command printed yamdi help text (and presumably didn't execute)" 29 | end 30 | 31 | if m = /Please provide at least one output file/i.match(result) 32 | raise TranscoderError::InvalidCommand, "command did not contain a valid output file. Yamdi expects a -o switch." 33 | end 34 | 35 | if m = /ERROR: undefined method .?timestamp.? for nil/.match(result) 36 | raise TranscoderError::InvalidFile, "Output file was empty (presumably)" 37 | end 38 | 39 | raise TranscoderError::UnexpectedResult, result 40 | end 41 | 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/rvideo/tools/flvtool2.rb: -------------------------------------------------------------------------------- 1 | # Warning: If you're dealing with large files, you should consider using yamdi instead. 2 | module RVideo 3 | module Tools 4 | class Flvtool2 5 | include AbstractTool::InstanceMethods 6 | 7 | attr_reader :raw_metadata 8 | 9 | #attr_reader :has_key_frames, :cue_points, :audiodatarate, :has_video, :stereo, :can_seek_to_end, :framerate, :audiosamplerate, :videocodecid, :datasize, :lasttimestamp, 10 | # :audiosamplesize, :audiosize, :has_audio, :audiodelay, :videosize, :metadatadate, :metadatacreator, :lastkeyframetimestamp, :height, :filesize, :has_metadata, :audiocodecid, 11 | # :duration, :videodatarate, :has_cue_points, :width 12 | 13 | def tool_command 14 | 'flvtool2' 15 | end 16 | 17 | private 18 | 19 | def parse_result(result) 20 | if result.empty? 21 | return true 22 | end 23 | 24 | if m = /ERROR: No such file or directory(.*)\n/.match(result) 25 | raise TranscoderError::InputFileNotFound, m[0] 26 | end 27 | 28 | if m = /ERROR: IO is not a FLV stream/.match(result) 29 | raise TranscoderError::InvalidFile, "input must be a valid FLV file" 30 | end 31 | 32 | if m = /Copyright.*Norman Timmler/i.match(result) 33 | raise TranscoderError::InvalidCommand, "command printed flvtool2 help text (and presumably didn't execute)" 34 | end 35 | 36 | if m = /ERROR: undefined method .?timestamp.? for nil/.match(result) 37 | raise TranscoderError::InvalidFile, "Output file was empty (presumably)" 38 | end 39 | 40 | if m = /\A---(.*)...\Z/m.match(result) 41 | @raw_metadata = m[0] 42 | return true 43 | end 44 | 45 | raise TranscoderError::UnexpectedResult, result 46 | end 47 | 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | == 0.9.5 2008-09-09 2 | Note: 3 | * Moving hosting to GitHub. 4 | http://github.com/zencoder/rvideo/tree 5 | 6 | 2 major enhancements: 7 | * Large files, or some tools, would dump out so much output that it could run a server out of memory. 8 | Changed the way that output is parsed. Instead of pulling it all into memory, the log file is sent to 9 | disk, and then post-processed. 10 | * Added support for yamdi (http://yamdi.sourceforge.net/) for FLV metadata injection. This is much faster 11 | than flvtool2 on huge files, and uses less memory as well. 12 | 13 | 14 | == 0.9.4 2007-12-12 15 | 16 | 3 major enhancements: 17 | * Changed transcoder interface. The preferred interface is now to pass the input file to RVideo::Transcoder.new. This allows you to create multiple output files from the same Transcoder object, and inspects the input file metadata before the job is transcoded. 18 | * Added screengrab functionality to the Inspector class. Fire up an inspector instance on a file, and you can take one or more screenshots through inspected_file.capture_frame("50%"). 19 | * Added resolution/aspect support. Pass :width and :height parameters to a Transcoder instance, and include $resolution$ or $aspect_ratio$ in the recipe. 20 | 21 | 4 minor enhancements: 22 | * Remove old/unnecessary files and features 23 | * Three additional ffmpeg results: unsupported codec and no output streams 24 | * One additional flvtool2 result: empty input file 25 | * Check that input file exists earlier in the transcoding process 26 | 27 | == 0.9.3 2007-10-30 28 | 29 | One minor enhancement: 30 | * Reraise all unhandled RVideo exceptions as TranscoderErrors 31 | 32 | == 0.9.2 2007-10-30 33 | 34 | One minor bug fix: 35 | * Correctly parse invalid files, where duration and bitrate are N/A, but start: 0.000 is included 36 | 37 | == 0.9.1 2007-10-11 38 | 39 | One major enhancement: 40 | * Added Mencoder support. (Andre Medeiros) 41 | 42 | Two minor enhancements: 43 | * Added total_time method to RVideo::Transcoder instances. 44 | * Added another error condition for ffmpeg - codec not found. 45 | 46 | Two notes: 47 | * Tried and tested using open3 and open4 for command execution, but mencoder would unexpectedly hang with certain files when using these. Reverted these changes. 48 | * Mencoder has basic unit tests, but needs more tests. In particular, example output should be added for a variety of cases (especially failures and errors). 49 | 50 | == 0.9.0 2007-09-27 51 | 52 | * Public RVideo release. 53 | 54 | == 0.8.0 2007-09-27 55 | 56 | * RVideo rewrite. 57 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | RVideo 2 | 3 | RVideo allows you to inspect and process video files. 4 | 5 | 6 | Installation is a little involved. First, install the gem: 7 | 8 | sudo gem install rvideo 9 | 10 | Next, install ffmpeg and (possibly) other related libraries. This is 11 | documented elsewhere on the web, and can be a headache. If you are on OS X, 12 | the Darwinports build is reasonably good (though not perfect). Install with: 13 | 14 | sudo port install ffmpeg 15 | 16 | Or, for a better build (recommended), add additional video- and audio-related 17 | libraries, like this: 18 | 19 | sudo port install ffmpeg +lame +libogg +vorbis +faac +faad +xvid +x264 +a52 20 | 21 | Most package management systems include a build of ffmpeg, but many include a 22 | poor build. So you may need to compile from scratch. 23 | 24 | If you want to create Flash Video files, also install flvtool2: 25 | 26 | sudo gem install flvtool2 27 | 28 | Once ffmpeg and RVideo are installed, you're set. 29 | 30 | To inspect a file, initialize an RVideo file inspector object. See the 31 | documentation for details. 32 | 33 | A few examples: 34 | 35 | file = RVideo::Inspector.new(:file => "#{APP_ROOT}/files/input.mp4") 36 | 37 | file = RVideo::Inspector.new(:raw_response => @existing_response) 38 | 39 | file = RVideo::Inspector.new(:file => "#{APP_ROOT}/files/input.mp4", 40 | :ffmpeg_binary => "#{APP_ROOT}/bin/ffmpeg") 41 | 42 | file.fps # "29.97" 43 | file.duration # "00:05:23.4" 44 | 45 | To transcode a video, initialize a Transcoder object. 46 | 47 | transcoder = RVideo::Transcoder.new 48 | 49 | Then pass a command and valid options to the execute method 50 | 51 | recipe = "ffmpeg -i $input_file$ -ar 22050 -ab 64 -f flv -r 29.97 -s" 52 | recipe += " $resolution$ -y $output_file$" 53 | recipe += "\nflvtool2 -U $output_file$" 54 | begin 55 | transcoder.execute(recipe, {:input_file => "/path/to/input.mp4", 56 | :output_file => "/path/to/output.flv", :resolution => "640x360"}) 57 | rescue TranscoderError => e 58 | puts "Unable to transcode file: #{e.class} - #{e.message}" 59 | end 60 | 61 | If the job succeeds, you can access the metadata of the input and output 62 | files with: 63 | 64 | transcoder.original # RVideo::Inspector object 65 | transcoder.processed # RVideo::Inspector object 66 | 67 | If the transcoding succeeds, the file may still have problems. RVideo 68 | will populate an errors array if the duration of the processed video 69 | differs from the duration of the original video, or if the processed 70 | file is unreadable. 71 | 72 | Thanks to Peter Boling for early work on RVideo. 73 | 74 | Contribute to RVideo! If you want to help out, there are a few things you can 75 | do. 76 | 77 | - Use, test, and submit bugs/patches 78 | - We need a RVideo::Tools::Mencoder class to add mencoder support. 79 | - Other tool classes would be great - On2, mp4box, Quicktime (?), etc. 80 | - Submit other fixes, features, optimizations, and refactorings 81 | 82 | If RVideo is useful to you, you may also be interested in RMovie, another Ruby 83 | video library. See http://rmovie.rubyforge.org/ for more. 84 | 85 | Finally, watch for Zencoder, a commercial video transcoder built by Slantwise 86 | Design. Zencoder uses RVideo for its video processing, but adds file queuing, 87 | distributed transcoding, a web-based transcoder dashboard, and more. See 88 | http://zencoder.tv or http://slantwisedesign.com for more. 89 | 90 | Copyright (c) 2007 Jonathan Dahl and Slantwise Design. Released under the MIT 91 | license. -------------------------------------------------------------------------------- /lib/rvideo/tools/mencoder.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Mencoder 4 | include AbstractTool::InstanceMethods 5 | 6 | attr_reader :frame, :size, :time, :bitrate, :video_size, :audio_size, :output_fps 7 | 8 | def tool_command 9 | 'mencoder' 10 | end 11 | 12 | def format_fps(params={}) 13 | " -ofps #{params[:fps]}" 14 | end 15 | 16 | def format_resolution(params={}) 17 | p = " -vf scale=#{params[:scale][:width]}:#{params[:scale][:height]}" 18 | if params[:letterbox] 19 | p += ",expand=#{params[:letterbox][:width]}:#{params[:letterbox][:height]}" 20 | end 21 | p += ",harddup" 22 | end 23 | 24 | def format_audio_channels(params={}) 25 | " -channels #{params[:channels]}" 26 | end 27 | 28 | def format_audio_bit_rate(params={}) 29 | " br=#{params[:bit_rate]}:" 30 | end 31 | 32 | def format_audio_sample_rate(params={}) 33 | " -srate #{params[:sample_rate]}" 34 | end 35 | 36 | def format_video_quality(params={}) 37 | bitrate = params[:video_bit_rate].blank? ? nil : params[:video_bit_rate] 38 | factor = (params[:scale][:width].to_f * params[:scale][:height].to_f * params[:fps].to_f) 39 | case params[:video_quality] 40 | when 'low' 41 | bitrate ||= (factor / 12000).to_i 42 | " -x264encopts threads=auto:subq=1:me=dia:frameref=1:crf=30:bitrate=#{bitrate} " 43 | when 'medium' 44 | bitrate ||= (factor / 9000).to_i 45 | " -x264encopts threads=auto:subq=3:me=hex:frameref=2:crf=22:bitrate=#{bitrate} " 46 | when 'high' 47 | bitrate ||= (factor / 3600).to_i 48 | " -x264encopts threads=auto:subq=6:me=dia:frameref=3:crf=18:bitrate=#{bitrate} " 49 | else 50 | "" 51 | end 52 | end 53 | 54 | 55 | 56 | private 57 | 58 | def parse_result(result) 59 | if m = /Exiting.*No output file specified/.match(result) 60 | raise TranscoderError::InvalidCommand, "no command passed to mencoder, or no output file specified" 61 | end 62 | 63 | if m = /counldn't set specified parameters, exiting/.match(result) 64 | raise TranscoderError::InvalidCommand, "a combination of the recipe parameters is invalid: #{result}" 65 | end 66 | 67 | if m = /Sorry, this file format is not recognized\/supported/.match(result) 68 | raise TranscoderError::InvalidFile, "unknown format" 69 | end 70 | 71 | if m = /Cannot open file\/device./.match(result) 72 | raise TranscoderError::InvalidFile, "I/O error" 73 | end 74 | 75 | if m = /File not found:$/.match(result) 76 | raise TranscoderError::InvalidFile, "I/O error" 77 | end 78 | 79 | video_details = result.match /Video stream:(.*)$/ 80 | if video_details 81 | @bitrate = sanitary_match(/Video stream:\s*([0-9.]*)/, video_details[0]) 82 | @video_size = sanitary_match(/size:\s*(\d*)\s*(\S*)/, video_details[0]) 83 | @time = sanitary_match(/bytes\s*([0-9.]*)/, video_details[0]) 84 | @frame = sanitary_match(/secs\s*(\d*)/, video_details[0]) 85 | @output_fps = (@frame.to_f / @time.to_f).round_to(3) 86 | elsif result =~ /Video stream is mandatory/ 87 | raise TranscoderError::InvalidFile, "Video stream required, and no video stream found" 88 | end 89 | 90 | audio_details = result.match /Audio stream:(.*)$/ 91 | if audio_details 92 | @audio_size = sanitary_match(/size:\s*(\d*)\s*(\S*)/, audio_details[0]) 93 | else 94 | @audio_size = 0 95 | end 96 | @size = (@video_size.to_i + @audio_size.to_i).to_s 97 | end 98 | 99 | def sanitary_match(regexp, string) 100 | match = regexp.match(string) 101 | return match[1] if match 102 | end 103 | 104 | end 105 | end 106 | end 107 | -------------------------------------------------------------------------------- /lib/rvideo/transcoder.rb: -------------------------------------------------------------------------------- 1 | module RVideo # :nodoc: 2 | class Transcoder 3 | 4 | attr_reader :executed_commands, :processed, :errors, :warnings, :total_time 5 | 6 | # 7 | # To transcode a video, initialize a Transcoder object: 8 | # 9 | # transcoder = RVideo::Transcoder.new("/path/to/input.mov") 10 | # 11 | # Then pass a recipe and valid options to the execute method 12 | # 13 | # recipe = "ffmpeg -i $input_file$ -ar 22050 -ab 64 -f flv -r 29.97 -s" 14 | # recipe += " $resolution$ -y $output_file$" 15 | # recipe += "\nflvtool2 -U $output_file$" 16 | # begin 17 | # transcoder.execute(recipe, {:output_file => "/path/to/output.flv", 18 | # :resolution => "640x360"}) 19 | # rescue TranscoderError => e 20 | # puts "Unable to transcode file: #{e.class} - #{e.message}" 21 | # end 22 | # 23 | # If the job succeeds, you can access the metadata of the input and output 24 | # files with: 25 | # 26 | # transcoder.original # RVideo::Inspector object 27 | # transcoder.processed # RVideo::Inspector object 28 | # 29 | # If the transcoding succeeds, the file may still have problems. RVideo 30 | # will populate an errors array if the duration of the processed video 31 | # differs from the duration of the original video, or if the processed 32 | # file is unreadable. 33 | # 34 | 35 | def initialize(input_file = nil) 36 | # Allow a nil input_file for backwards compatibility. (Change at 1.0?) 37 | check_input_file(input_file) 38 | 39 | @input_file = input_file 40 | @executed_commands = [] 41 | @errors = [] 42 | @warnings = [] 43 | end 44 | 45 | def original 46 | @original ||= Inspector.new(:file => @input_file) 47 | end 48 | 49 | # 50 | # Configure logging. Pass a valid Ruby logger object. 51 | # 52 | # logger = Logger.new(STDOUT) 53 | # RVideo::Transcoder.logger = logger 54 | # 55 | 56 | def self.logger=(l) 57 | @logger = l 58 | end 59 | 60 | def self.logger 61 | if @logger.nil? 62 | @logger = Logger.new('/dev/null') 63 | end 64 | 65 | @logger 66 | end 67 | 68 | # 69 | # Requires a command and a hash of various interpolated options. The 70 | # command should be one or more lines of transcoder tool commands (e.g. 71 | # ffmpeg, flvtool2). Interpolate options by adding $option_key$ to the 72 | # recipe, and passing :option_key => "value" in the options hash. 73 | # 74 | # recipe = "ffmpeg -i $input_file$ -ar 22050 -ab 64 -f flv -r 29.97 75 | # recipe += "-s $resolution$ -y $output_file$" 76 | # recipe += "\nflvtool2 -U $output_file$" 77 | # 78 | # transcoder = RVideo::Transcoder.new("/path/to/input.mov") 79 | # begin 80 | # transcoder.execute(recipe, {:output_file => "/path/to/output.flv", :resolution => "320x240"}) 81 | # rescue TranscoderError => e 82 | # puts "Unable to transcode file: #{e.class} - #{e.message}" 83 | # end 84 | # 85 | 86 | def execute(task, options = {}) 87 | t1 = Time.now 88 | 89 | if @input_file.nil? 90 | @input_file = options[:input_file] 91 | end 92 | 93 | Transcoder.logger.info("\nNew transcoder job\n================\nTask: #{task}\nOptions: #{options.inspect}") 94 | parse_and_execute(task, options) 95 | @processed = Inspector.new(:file => options[:output_file]) 96 | result = check_integrity 97 | Transcoder.logger.info("\nFinished task. Total errors: #{@errors.size}\n") 98 | @total_time = Time.now - t1 99 | result 100 | rescue TranscoderError => e 101 | raise e 102 | rescue Exception => e 103 | Transcoder.logger.error("[ERROR] Unhandled RVideo exception: #{e.class} - #{e.message}\n#{e.backtrace}") 104 | raise TranscoderError::UnknownError, "Unexpected RVideo error: #{e.message} (#{e.class})" 105 | end 106 | 107 | private 108 | 109 | def check_input_file(input_file) 110 | if input_file and !FileTest.exist?(input_file.gsub("\"","")) 111 | raise TranscoderError::InputFileNotFound, "File not found (#{input_file})" 112 | end 113 | end 114 | 115 | def check_integrity 116 | precision = 1.1 117 | if @processed.invalid? 118 | @errors << "Output file invalid" 119 | elsif (@processed.duration >= (original.duration * precision) or @processed.duration <= (original.duration / precision)) 120 | @errors << "Original file has a duration of #{original.duration}, but processed file has a duration of #{@processed.duration}" 121 | end 122 | return @errors.size == 0 123 | end 124 | 125 | def parse_and_execute(task, options = {}) 126 | raise TranscoderError::ParameterError, "Expected a recipe class (as a string), but got a #{task.class.to_s} (#{task})" unless task.is_a? String 127 | options = options.merge(:input_file => @input_file) 128 | 129 | commands = task.split("\n").compact 130 | commands.each do |c| 131 | tool = Tools::AbstractTool.assign(c, options) 132 | tool.original = @original 133 | tool.execute 134 | executed_commands << tool 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | require 'rake/clean' 4 | require 'rake/testtask' 5 | require 'rake/packagetask' 6 | require 'rake/gempackagetask' 7 | require 'rake/rdoctask' 8 | require 'rake/contrib/rubyforgepublisher' 9 | require 'fileutils' 10 | require 'hoe' 11 | begin 12 | require 'spec/rake/spectask' 13 | rescue LoadError 14 | puts 'To use rspec for testing you must install rspec gem:' 15 | puts '$ sudo gem install rspec' 16 | exit 17 | end 18 | 19 | include FileUtils 20 | require File.join(File.dirname(__FILE__), 'lib', 'rvideo', 'version') 21 | 22 | AUTHOR = 'Jonathan Dahl (Slantwise Design)' # can also be an array of Authors 23 | EMAIL = "jon@slantwisedesign.com" 24 | DESCRIPTION = "Inspect and process video or audio files" 25 | GEM_NAME = 'rvideo' # what ppl will type to install your gem 26 | 27 | @config_file = "~/.rubyforge/user-config.yml" 28 | @config = nil 29 | def rubyforge_username 30 | unless @config 31 | begin 32 | @config = YAML.load(File.read(File.expand_path(@config_file))) 33 | rescue 34 | puts <<-EOS 35 | ERROR: No rubyforge config file found: #{@config_file}" 36 | Run 'rubyforge setup' to prepare your env for access to Rubyforge 37 | - See http://newgem.rubyforge.org/rubyforge.html for more details 38 | EOS 39 | exit 40 | end 41 | end 42 | @rubyforge_username ||= @config["username"] 43 | end 44 | 45 | RUBYFORGE_PROJECT = 'rvideo' # The unix name for your project 46 | HOMEPATH = "http://#{RUBYFORGE_PROJECT}.rubyforge.org" 47 | DOWNLOAD_PATH = "http://rubyforge.org/projects/#{RUBYFORGE_PROJECT}" 48 | 49 | NAME = "rvideo" 50 | REV = nil 51 | # UNCOMMENT IF REQUIRED: 52 | # REV = `svn info`.each {|line| if line =~ /^Revision:/ then k,v = line.split(': '); break v.chomp; else next; end} rescue nil 53 | VERS = Rvideo::VERSION::STRING + (REV ? ".#{REV}" : "") 54 | CLEAN.include ['**/.*.sw?', '*.gem', '.config', '**/.DS_Store'] 55 | RDOC_OPTS = ['--quiet', '--title', 'rvideo documentation', 56 | "--opname", "index.html", 57 | "--line-numbers", 58 | "--main", "README", 59 | "--inline-source"] 60 | 61 | class Hoe 62 | def extra_deps 63 | @extra_deps.reject { |x| Array(x).first == 'hoe' } 64 | end 65 | end 66 | 67 | # Generate all the Rake tasks 68 | # Run 'rake -T' to see list of generated tasks (from gem root directory) 69 | hoe = Hoe.new(GEM_NAME, VERS) do |p| 70 | p.author = AUTHOR 71 | p.description = DESCRIPTION 72 | p.email = EMAIL 73 | p.summary = DESCRIPTION 74 | p.url = HOMEPATH 75 | p.rubyforge_name = RUBYFORGE_PROJECT if RUBYFORGE_PROJECT 76 | p.test_globs = ["test/**/test_*.rb"] 77 | p.clean_globs |= CLEAN #An array of file patterns to delete on clean. 78 | 79 | # == Optional 80 | p.changes = p.paragraphs_of("History.txt", 0..1).join("\n\n") 81 | #p.extra_deps = [] # An array of rubygem dependencies [name, version], e.g. [ ['active_support', '>= 1.3.1'] ] 82 | #p.spec_extras = {} # A hash of extra values to set in the gemspec. 83 | end 84 | 85 | CHANGES = hoe.paragraphs_of('History.txt', 0..1).join("\n\n") 86 | PATH = (RUBYFORGE_PROJECT == GEM_NAME) ? RUBYFORGE_PROJECT : "#{RUBYFORGE_PROJECT}/#{GEM_NAME}" 87 | hoe.remote_rdoc_dir = File.join(PATH.gsub(/^#{RUBYFORGE_PROJECT}\/?/,''), 'rdoc') 88 | 89 | desc 'Generate website files' 90 | task :website_generate do 91 | Dir['website/**/*.txt'].each do |txt| 92 | sh %{ ruby scripts/txt2html #{txt} > #{txt.gsub(/txt$/,'html')} } 93 | end 94 | end 95 | 96 | desc 'Upload website files to rubyforge' 97 | task :website_upload do 98 | host = "#{rubyforge_username}@rubyforge.org" 99 | remote_dir = "/var/www/gforge-projects/#{PATH}/" 100 | local_dir = 'website' 101 | sh %{rsync -aCv #{local_dir}/ #{host}:#{remote_dir}} 102 | end 103 | 104 | desc 'Generate and upload website files' 105 | task :website => [:website_generate, :website_upload, :publish_docs] 106 | 107 | desc 'Release the website and new gem version' 108 | task :deploy => [:check_version, :website, :release] do 109 | puts "Remember to create SVN tag:" 110 | puts "svn copy svn+ssh://#{rubyforge_username}@rubyforge.org/var/svn/#{PATH}/trunk " + 111 | "svn+ssh://#{rubyforge_username}@rubyforge.org/var/svn/#{PATH}/tags/REL-#{VERS} " 112 | puts "Suggested comment:" 113 | puts "Tagging release #{CHANGES}" 114 | end 115 | 116 | desc 'Runs tasks website_generate and install_gem as a local deployment of the gem' 117 | task :local_deploy => [:website_generate, :install_gem] 118 | 119 | task :check_version do 120 | unless ENV['VERSION'] 121 | puts 'Must pass a VERSION=x.y.z release version' 122 | exit 123 | end 124 | unless ENV['VERSION'] == VERS 125 | puts "Please update your version.rb to match the release version, currently #{VERS}" 126 | exit 127 | end 128 | end 129 | 130 | #require 'rake' 131 | #require 'spec/rake/spectask' 132 | require File.dirname(__FILE__) + '/lib/rvideo' 133 | 134 | namespace :spec do 135 | desc "Run Unit Specs" 136 | Spec::Rake::SpecTask.new("units") do |t| 137 | t.spec_files = FileList['spec/units/**/*.rb'] 138 | end 139 | 140 | desc "Run Integration Specs" 141 | Spec::Rake::SpecTask.new("integrations") do |t| 142 | t.spec_files = FileList['spec/integrations/**/*.rb'] 143 | end 144 | end 145 | 146 | desc "Process a file" 147 | task(:transcode) do 148 | RVideo::Transcoder.logger = Logger.new(STDOUT) 149 | transcode_single_job(ENV['RECIPE'], ENV['FILE']) 150 | end 151 | 152 | desc "Batch transcode files" 153 | task(:batch_transcode) do 154 | RVideo::Transcoder.logger = Logger.new(File.dirname(__FILE__) + '/test/output.log') 155 | f = YAML::load(File.open(File.dirname(__FILE__) + '/test/batch_transcode.yml')) 156 | recipes = f['recipes'] 157 | files = f['files'] 158 | files.each do |f| 159 | file = "#{File.dirname(__FILE__)}/test/files/#{f}" 160 | recipes.each do |recipe| 161 | transcode_single_job(recipe, file) 162 | end 163 | end 164 | end 165 | 166 | def transcode_single_job(recipe, input_file) 167 | puts "Transcoding #{File.basename(input_file)} to #{recipe}" 168 | r = YAML::load(File.open(File.dirname(__FILE__) + '/test/recipes.yml'))[recipe] 169 | transcoder = RVideo::Transcoder.new(input_file) 170 | output_file = "#{TEMP_PATH}/#{File.basename(input_file, ".*")}-#{recipe}.#{r['extension']}" 171 | FileUtils.mkdir_p(File.dirname(output_file)) 172 | begin 173 | transcoder.execute(r['command'], {:output_file => output_file}.merge(r)) 174 | puts "Finished #{File.basename(output_file)} in #{transcoder.total_time}" 175 | rescue StandardError => e 176 | puts "Error transcoding #{File.basename(output_file)} - #{e.class} (#{e.message}\n#{e.backtrace})" 177 | end 178 | end 179 | -------------------------------------------------------------------------------- /lib/rvideo/tools/ffmpeg.rb: -------------------------------------------------------------------------------- 1 | module RVideo 2 | module Tools 3 | class Ffmpeg 4 | include AbstractTool::InstanceMethods 5 | 6 | attr_reader :frame, :q, :size, :time, :output_bitrate, :video_size, :audio_size, :header_size, :overhead, :psnr, :output_fps 7 | 8 | # Not sure if this is needed anymore... 9 | def tool_command 10 | 'ffmpeg' 11 | end 12 | 13 | def format_fps(params={}) 14 | " -r #{params[:fps]}" 15 | end 16 | def format_video_quality(params={}) 17 | bitrate = params[:video_bit_rate].blank? ? nil : params[:video_bit_rate] 18 | factor = (params[:scale][:width].to_f * params[:scale][:height].to_f * params[:fps].to_f) 19 | case params[:video_quality] 20 | when 'low' 21 | bitrate ||= (factor / 12000).to_i 22 | " -v #{bitrate}k -crf 30 -me zero -subq 1 -refs 1 -threads auto " 23 | when 'medium' 24 | bitrate ||= (factor / 9000).to_i 25 | " -v #{bitrate}k -crf 22 -flags +loop -cmp +sad -partitions +parti4x4+partp8x8+partb8x8 -flags2 +mixed_refs -me hex -subq 3 -trellis 1 -refs 2 -bf 3 -b_strategy 1 -coder 1 -me_range 16 -g 250" 26 | when 'high' 27 | bitrate ||= (factor / 3600).to_i 28 | " -v #{bitrate}k -crf 18 -flags +loop -cmp +sad -partitions +parti4x4+partp8x8+partb8x8 -flags2 +mixed_refs -me full -subq 6 -trellis 1 -refs 3 -bf 3 -b_strategy 1 -coder 1 -me_range 16 -g 250 -keyint_min 25 -sc_threshold 40 -i_qfactor 0.71" 29 | else 30 | "" 31 | end 32 | end 33 | 34 | 35 | def format_resolution(params={}) 36 | p = " -s #{params[:scale][:width]}x#{params[:scale][:height]} " 37 | if params[:letterbox] 38 | plr = ((params[:letterbox][:width] - params[:scale][:width]) / 2).to_i 39 | ptb = ((params[:letterbox][:height] - params[:scale][:height]) / 2).to_i 40 | p += " -padtop #{ptb} -padbottom #{ptb} -padleft #{plr} -padright #{plr} " 41 | end 42 | p 43 | end 44 | 45 | def format_audio_channels(params={}) 46 | " -ac #{params[:channels]}" 47 | end 48 | 49 | def format_audio_bit_rate(params={}) 50 | " -ab #{params[:bit_rate]}k" 51 | end 52 | 53 | def format_audio_sample_rate(params={}) 54 | " -ar #{params[:sample_rate]}" 55 | end 56 | 57 | 58 | private 59 | 60 | # Turns the temp log file into a useful string, from which we can parse the 61 | # transcoding results. 62 | # These log files can be enormous, so pulling the whole thing into memory is not an 63 | # option. 64 | def populate_raw_result(temp_file_name) 65 | @raw_result = "" 66 | 67 | # Is the log file exceptionally long? It's really not a big deal to pull in a thousand lines or so 68 | # into memory. It's the gigantic files that cause problems. If the files isn't too large, 69 | # just pull it in. 70 | line_count = 0 71 | if m = /^\s*(\d+)/.match(`wc -l #{temp_file_name}`) 72 | line_count = m[1].to_i 73 | end 74 | 75 | if line_count > 500 76 | # Find the message indicating that the command is actually running. 77 | running_string = "Press .* to stop encoding" 78 | @raw_result << `grep "#{running_string}" #{temp_file_name}` 79 | end 80 | 81 | # Append the bottom of the log file, where the interesting bits live. 82 | @raw_result << `tail -n 500 #{temp_file_name}` 83 | end 84 | 85 | def parse_result(result) 86 | 87 | if m = /Unable for find a suitable output format for.*$/.match(result) 88 | raise TranscoderError::InvalidCommand, m[0] 89 | end 90 | 91 | if m = /Unknown codec \'(.*)\'/.match(result) 92 | raise TranscoderError::InvalidFile, "Codec #{m[1]} not supported by this build of ffmpeg" 93 | end 94 | 95 | if m = /could not find codec parameters/.match(result) 96 | raise TranscoderError::InvalidFile, "Codec not supported by this build of ffmpeg" 97 | end 98 | 99 | if m = /I\/O error occured\n(.*)$/.match(result) 100 | raise TranscoderError::InvalidFile, "I/O error: #{m[1].strip}" 101 | end 102 | 103 | if m = /\n(.*)Unknown Format$/.match(result) 104 | raise TranscoderError::InvalidFile, "unknown format (#{m[1]})" 105 | end 106 | 107 | if m = /\nERROR.*/m.match(result) 108 | raise TranscoderError::InvalidFile, m[0] 109 | end 110 | 111 | if result =~ /usage: ffmpeg/ 112 | raise TranscoderError::InvalidCommand, "must pass a command to ffmpeg" 113 | end 114 | 115 | if result =~ /Output file does not contain.*stream/ 116 | raise TranscoderError, "Output file does not contain any video or audio streams." 117 | end 118 | 119 | if m = /Unsupported codec.*id=(.*)\).*for input stream\s*(.*)\s*/.match(result) 120 | inspect_original if @original.nil? 121 | case m[2] 122 | when @original.audio_stream_id 123 | codec_type = "audio" 124 | codec = @original.audio_codec 125 | when @original.video_stream_id 126 | codec_type = "video" 127 | codec = @original.video_codec 128 | else 129 | codec_type = "video or audio" 130 | codec = "unknown" 131 | end 132 | 133 | raise TranscoderError::InvalidFile, "Unsupported #{codec_type} codec: #{codec} (id=#{m[1]}, stream=#{m[2]})" 134 | #raise TranscoderError, "Codec #{m[1]} not supported (in stream #{m[2]})" 135 | end 136 | 137 | # Could not open './spec/../config/../tmp/processed/1/kites-1.avi' 138 | if result =~ /Could not open .#{@output_file}.\Z/ 139 | raise TranscoderError, "Could not write output file to #{@output_file}" 140 | end 141 | 142 | full_details = /Press .* to stop encoding\n(.*)/m.match(result) 143 | raise TranscoderError, "Unexpected result details (#{result})" if full_details.nil? 144 | details = full_details[1].strip.gsub(/\s*\n\s*/," - ") 145 | 146 | if details =~ /Could not write header/ 147 | raise TranscoderError, details 148 | end 149 | 150 | #frame= 584 q=6.0 Lsize= 708kB time=19.5 bitrate= 297.8kbits/s 151 | #video:49kB audio:153kB global headers:0kB muxing overhead 250.444444% 152 | 153 | #frame= 4126 q=31.0 Lsize= 5917kB time=69.1 bitrate= 702.0kbits/s 154 | #video:2417kB audio:540kB global headers:0kB muxing overhead 100.140277% 155 | 156 | #frame= 273 fps= 31 q=10.0 Lsize= 398kB time=5.9 bitrate= 551.8kbits/s 157 | #video:284kB audio:92kB global headers:0kB muxing overhead 5.723981% 158 | 159 | #mdb:94, lastbuf:0 skipping granule 0 160 | #size= 1080kB time=69.1 bitrate= 128.0kbits /s 161 | #video:0kB audio:1080kB global headers:0kB muxing overhead 0.002893% 162 | 163 | #size= 80kB time=5.1 bitrate= 128.0kbits/s ^Msize= 162kB time=10.3 bitrate= 128.0kbits/s ^Msize= 241kB time=15.4 bitrate= 128.0kbits/s ^Msize= 329kB time=21.1 bitrate= 128.0kbits/s ^Msize= 413kB time=26.4 bitrate= 128.0kbits/s ^Msize= 506kB time=32.4 bitrate= 128.0kbits/s ^Msize= 591kB time=37.8 bitrate= 128.0kbits/s ^Msize= 674kB time=43.2 bitrate= 128.0kbits/s ^Msize= 771kB time=49.4 bitrate= 128.0kbits/s ^Msize= 851kB time=54.5 bitrate= 128.0kbits/s ^Msize= 932kB time=59.6 bitrate= 128.0kbits/s ^Msize= 1015kB time=64.9 bitrate= 128.0kbits/s ^Msize= 1094kB time=70.0 bitrate= 128.0kbits/s ^Msize= 1175kB time=75.2 bitrate= 128.0kbits/s ^Msize= 1244kB time=79.6 bitrate= 128.0kbits/s ^Msize= 1335kB time=85.4 bitrate= 128.0kbits/s ^Msize= 1417kB time=90.7 bitrate= 128.0kbits/s ^Msize= 1508kB time=96.5 bitrate= 128.0kbits/s ^Msize= 1589kB time=101.7 bitrate= 128.0kbits/s ^Msize= 1671kB time=106.9 bitrate= 128.0kbits/s ^Msize= 1711kB time=109.5 bitrate= 128.0kbits/s - video:0kB audio:1711kB global headers:0kB muxing overhead 0.001826% 164 | 165 | #mdb:14, lastbuf:0 skipping granule 0 - overread, skip -5 enddists: -2 -2 - overread, skip -5 enddists: -2 -2 - size= 90kB time=5.7 bitrate= 128.0kbits/s \nsize= 189kB time=12.1 bitrate= 128.0kbits/s 166 | 167 | #size= 59kB time=20.2 bitrate= 24.0kbits/s \nsize= 139kB time=47.4 bitrate= 24.0kbits/s \nsize= 224kB time=76.5 bitrate= 24.0kbits/s \nsize= 304kB time=103.7 bitrate= 24.0kbits/s \nsi 168 | 169 | #mdb:14, lastbuf:0 skipping granule 0 - overread, skip -5 enddists: -2 -2 - overread, skip -5 enddists: -2 -2 - size= 81kB time=10.3 bitrate= 64.0kbits/s \nsize= 153kB time=19.6 bitrate= 64.0kbits/s 170 | 171 | #size= 65kB time=4.1 bitrate= 128.1kbits/s \nsize= 119kB time=7.6 bitrate= 128.0kbits/s \nsize= 188kB time=12.0 bitrate= 128.0kbits/s \nsize= 268kB time=17.1 bitrate= 128.0kbits/s \nsize= 172 | 173 | #Error while decoding stream #0.1 [mpeg4aac @ 0xb7d089f0]faac: frame decoding failed: Gain control not yet implementedError while decoding stream #0.1frame= 2143 fps= 83 q=4.0 size= 4476kB time=71.3 bitrate= 514.5kbits/s ^M[mpeg4aac @ 0xb7d089f0]faac: frame decoding failed: Gain control not yet implementedError while decoding stream #0.1 174 | 175 | # NOTE: had to remove "\s" from "\s.*L.*size=" from this regexp below. 176 | # Not sure why. Unit tests were succeeding, but hand tests weren't. 177 | if details =~ /video:/ 178 | #success = /^frame=\s*(\S*)\s*q=(\S*).*L.*size=\s*(\S*)\s*time=\s*(\S*)\s*bitrate=\s*(\S*)\s*/m.match(details) 179 | @frame = sanitary_match(/frame=\s*(\S*)/, details) 180 | @output_fps = sanitary_match(/fps=\s*(\S*)/, details) 181 | @q = sanitary_match(/\s+q=\s*(\S*)/, details) 182 | @size = sanitary_match(/size=\s*(\S*)/, details) 183 | @time = sanitary_match(/time=\s*(\S*)/, details) 184 | @output_bitrate = sanitary_match(/bitrate=\s*(\S*)/, details) 185 | 186 | @video_size = /video:\s*(\S*)/.match(details)[1] 187 | @audio_size = /audio:\s*(\S*)/.match(details)[1] 188 | @header_size = /headers:\s*(\S*)/.match(details)[1] 189 | @overhead = /overhead[:]*\s*(\S*)/.match(details)[1] 190 | psnr_match = /PSNR=(.*)\s*size=/.match(details) 191 | @psnr = psnr_match[1].strip if psnr_match 192 | return true 193 | end 194 | 195 | #[mp3 @ 0x54340c]flv doesnt support that sample rate, choose from (44100, 22050, 11025) 196 | #Could not write header for output file #0 (incorrect codec parameters ?) 197 | 198 | raise TranscoderError::UnexpectedResult, details 199 | end 200 | 201 | def sanitary_match(regexp, string) 202 | match = regexp.match(string) 203 | return match[1] if match 204 | end 205 | 206 | end 207 | end 208 | end 209 | -------------------------------------------------------------------------------- /lib/rvideo/tools/abstract_tool.rb: -------------------------------------------------------------------------------- 1 | module RVideo # :nodoc: 2 | module Tools # :nodoc: 3 | class AbstractTool 4 | 5 | # 6 | # AbstractTool is an interface to every transcoder tool class (e.g. 7 | # ffmpeg, flvtool2). Called by the Transcoder class. 8 | # 9 | 10 | def self.assign(cmd, options = {}) 11 | tool_name = cmd.split(" ").first 12 | begin 13 | tool = "RVideo::Tools::#{tool_name.classify}".constantize.send(:new, cmd, options) 14 | # rescue NameError, /uninitialized constant/ 15 | # raise TranscoderError::UnknownTool, "The recipe tried to use the '#{tool_name}' tool, which does not exist." 16 | rescue => e 17 | LOGGER.info $! 18 | LOGGER.info e.backtrace.join("\n") 19 | end 20 | end 21 | 22 | 23 | module InstanceMethods 24 | attr_reader :options, :command, :raw_result 25 | attr_writer :original 26 | 27 | def initialize(raw_command, options = {}) 28 | @raw_command = raw_command 29 | @options = HashWithIndifferentAccess.new(options) 30 | @command = interpolate_variables(raw_command) 31 | end 32 | 33 | # 34 | # Execute the command and parse the result. 35 | # 36 | # def execute 37 | # @output_params = {} 38 | # final_command = "#{@command} 2>&1" 39 | # Transcoder.logger.info("\nExecuting Command: #{final_command}\n") 40 | # @raw_result = `#{final_command}` 41 | # Transcoder.logger.info("Result: \n#{@raw_result}") 42 | # parse_result(@raw_result) 43 | # end 44 | 45 | def execute 46 | @output_params = {} 47 | 48 | # Dump the log output into a temp file 49 | log_temp_file_name = "/tmp/transcode_output_#{Time.now.to_i}.txt" 50 | 51 | final_command = "#{@command} 2>#{log_temp_file_name}" 52 | Transcoder.logger.info("\nExecuting Command: #{final_command}\n") 53 | `#{final_command}` 54 | 55 | populate_raw_result(log_temp_file_name) 56 | 57 | Transcoder.logger.info("Result: \n#{@raw_result}") 58 | parse_result(@raw_result) 59 | 60 | # Cleanup log file 61 | begin 62 | File.delete(log_temp_file_name) 63 | rescue Exception => e 64 | Transcoder.logger.error("Failed to delete output log file: #{log_temp_file_name}, e=#{e}") 65 | end 66 | end 67 | 68 | # 69 | # Magic parameters 70 | # 71 | def temp_dir 72 | if @options['output_file'] 73 | "#{File.dirname(@options['output_file'])}/" 74 | else 75 | "" 76 | end 77 | end 78 | 79 | 80 | def fps 81 | format_fps(get_fps) 82 | end 83 | 84 | def get_fps 85 | inspect_original if @original.nil? 86 | fps = @options['fps'] || "" 87 | case fps 88 | when "copy" 89 | get_original_fps 90 | else 91 | get_specific_fps 92 | end 93 | end 94 | 95 | 96 | def resolution 97 | format_resolution(get_resolution) 98 | end 99 | 100 | def get_resolution 101 | inspect_original if @original.nil? 102 | resolution_setting = @options['resolution'] || "" 103 | case resolution_setting 104 | when "copy" 105 | get_original_resolution 106 | when "width" 107 | get_fit_to_width_resolution 108 | when "height" 109 | get_fit_to_height_resolution 110 | when "letterbox" 111 | get_letterbox_resolution 112 | else 113 | get_specific_resolution 114 | end 115 | end 116 | 117 | 118 | def audio_channels 119 | format_audio_channels(get_audio_channels) 120 | end 121 | 122 | def get_audio_channels 123 | channels = @options['audio_channels'] || "" 124 | case channels 125 | when "stereo" 126 | get_stereo_audio 127 | when "mono" 128 | get_mono_audio 129 | else 130 | {} 131 | end 132 | end 133 | 134 | def audio_bit_rate 135 | format_audio_bit_rate(get_audio_bit_rate) 136 | end 137 | 138 | def get_audio_bit_rate 139 | bit_rate = @options['audio_bit_rate'] || "" 140 | case bit_rate 141 | when "" 142 | {} 143 | else 144 | get_specific_audio_bit_rate 145 | end 146 | end 147 | 148 | def audio_sample_rate 149 | format_audio_sample_rate(get_audio_sample_rate) 150 | end 151 | 152 | def get_audio_sample_rate 153 | sample_rate = @options['audio_sample_rate'] || "" 154 | case sample_rate 155 | when "" 156 | {} 157 | else 158 | get_specific_audio_sample_rate 159 | end 160 | end 161 | 162 | def video_quality 163 | format_video_quality(get_video_quality) 164 | end 165 | 166 | def get_video_quality 167 | inspect_original if @original.nil? 168 | quality = @options['video_quality'] || 'medium' 169 | video_bit_rate = @options['video_bit_rate'] || nil 170 | h = {:video_quality => quality, :video_bit_rate => video_bit_rate} 171 | h.merge!(get_fps).merge!(get_resolution) 172 | end 173 | 174 | 175 | 176 | def get_fit_to_width_resolution 177 | w = @options['width'] 178 | raise TranscoderError::ParameterError, "invalid width of '#{w}' for fit to width" unless valid_dimension?(w) 179 | h = calculate_height(@original.width, @original.height, w) 180 | {:scale => {:width => w, :height => h}} 181 | end 182 | 183 | def get_fit_to_height_resolution 184 | h = @options['height'] 185 | raise TranscoderError::ParameterError, "invalid height of '#{h}' for fit to height" unless valid_dimension?(h) 186 | w = calculate_width(@original.width, @original.height, h) 187 | {:scale => {:width => w, :height => h}} 188 | end 189 | 190 | def get_letterbox_resolution 191 | lw = @options['width'].to_i 192 | lh = @options['height'].to_i 193 | raise TranscoderError::ParameterError, "invalid width of '#{lw}' for letterbox" unless valid_dimension?(lw) 194 | raise TranscoderError::ParameterError, "invalid height of '#{lh}' for letterbox" unless valid_dimension?(lh) 195 | w = calculate_width(@original.width, @original.height, lh) 196 | h = calculate_height(@original.width, @original.height, lw) 197 | if w > lw 198 | w = lw 199 | h = calculate_height(@original.width, @original.height, lw) 200 | else 201 | h = lh 202 | w = calculate_width(@original.width, @original.height, lh) 203 | end 204 | {:scale => {:width => w, :height => h}, :letterbox => {:width => lw, :height => lh}} 205 | end 206 | 207 | def get_original_resolution 208 | {:scale => {:width => @original.width, :height => @original.height}} 209 | end 210 | 211 | def get_specific_resolution 212 | w = @options['width'] 213 | h = @options['height'] 214 | raise TranscoderError::ParameterError, "invalid width of '#{w}' for specific resolution" unless valid_dimension?(w) 215 | raise TranscoderError::ParameterError, "invalid height of '#{h}' for specific resolution" unless valid_dimension?(h) 216 | {:scale => {:width => w, :height => h}} 217 | end 218 | 219 | def get_original_fps 220 | return {} if @original.fps.nil? 221 | {:fps => @original.fps} 222 | end 223 | 224 | def get_specific_fps 225 | {:fps => @options['fps']} 226 | end 227 | 228 | # def get_video_quality 229 | # fps = @options['fps'] || @original.fps 230 | # raise TranscoderError::ParameterError, "could not find fps in order to determine video quality" if fps.nil? 231 | # width = @original.width 232 | # height = @ 233 | # format_video_quality({:quality => @options['video_quality'], :bit_rate => @options['video_bit_rate']}) 234 | # end 235 | 236 | def get_stereo_audio 237 | {:channels => "2"} 238 | end 239 | 240 | def get_mono_audio 241 | {:channels => "1"} 242 | end 243 | 244 | def get_specific_audio_bit_rate 245 | {:bit_rate => @options['audio_bit_rate']} 246 | end 247 | 248 | def get_specific_audio_sample_rate 249 | {:sample_rate => @options['audio_sample_rate']} 250 | end 251 | 252 | def calculate_width(ow, oh, h) 253 | w = ((ow.to_f / oh.to_f) * h.to_f).to_i 254 | (w.to_f / 16).round * 16 255 | end 256 | 257 | def calculate_height(ow, oh, w) 258 | h = (w.to_f / (ow.to_f / oh.to_f)).to_i 259 | (h.to_f / 16).round * 16 260 | end 261 | 262 | 263 | def valid_dimension?(dim) 264 | return false if dim.to_i <= 0 265 | return true 266 | end 267 | 268 | def format_resolution(params={}) 269 | raise ParameterError, "The #{self.class} tool has not implemented the format_resolution method." 270 | end 271 | 272 | def format_fps(params={}) 273 | raise ParameterError, "The #{self.class} tool has not implemented the format_fps method." 274 | end 275 | 276 | def format_audio_channels(params={}) 277 | raise ParameterError, "The #{self.class} tool has not implemented the format_audio_channels method." 278 | end 279 | 280 | def format_audio_bit_rate(params={}) 281 | raise ParameterError, "The #{self.class} tool has not implemented the format_audio_bit_rate method." 282 | end 283 | 284 | def format_audio_sample_rate(params={}) 285 | raise ParameterError, "The #{self.class} tool has not implemented the format_audio_sample_rate method." 286 | end 287 | 288 | private 289 | 290 | 291 | # 292 | # Look for variables surrounded by $, and interpolate with either 293 | # variables passed in the options hash, or special methods provided by 294 | # the tool class (e.g. "$original_fps$" with ffmpeg). 295 | # 296 | # $foo$ should match 297 | # \$foo or $foo\$ or \$foo\$ should not 298 | 299 | def interpolate_variables(raw_command) 300 | raw_command.scan(/[^\\]\$[-_a-zA-Z]+\$/).each do |match| 301 | match = match[0..0] == "$" ? match : match[1..(match.size - 1)] 302 | match.strip! 303 | raw_command.gsub!(match, matched_variable(match)) 304 | end 305 | raw_command.gsub("\\$", "$") 306 | end 307 | 308 | # 309 | # Strip the $s. First, look for a supplied option that matches the 310 | # variable name. If one is not found, look for a method that matches. 311 | # If not found, raise ParameterError exception. 312 | # 313 | 314 | def matched_variable(match) 315 | variable_name = match.gsub("$","") 316 | if self.respond_to? variable_name 317 | self.send(variable_name) 318 | elsif @options.key?(variable_name) 319 | @options[variable_name] || "" 320 | else 321 | raise TranscoderError::ParameterError, "command is looking for the #{variable_name} parameter, but it was not provided. (Command: #{@raw_command})" 322 | end 323 | end 324 | 325 | def inspect_original 326 | @original = Inspector.new(:file => options[:input_file]) 327 | end 328 | 329 | # Pulls the interesting bits of the temp log file into memory. This is fairly tool-specific, so 330 | # it's doubtful that this default version is going to work without being overridded. 331 | def populate_raw_result(temp_file_name) 332 | @raw_result = `tail -n 500 #{temp_file_name}` 333 | end 334 | 335 | end 336 | 337 | end 338 | end 339 | end 340 | -------------------------------------------------------------------------------- /lib/rvideo/inspector.rb: -------------------------------------------------------------------------------- 1 | module RVideo # :nodoc: 2 | class Inspector 3 | 4 | attr_reader :filename, :path, :full_filename, :raw_response, :raw_metadata 5 | 6 | attr_accessor :ffmpeg_binary 7 | 8 | # 9 | # To inspect a video or audio file, initialize an Inspector object. 10 | # 11 | # file = RVideo::Inspector.new(options_hash) 12 | # 13 | # Inspector accepts three options: file, raw_response, and ffmpeg_binary. 14 | # Either raw_response or file is required; ffmpeg binary is optional. 15 | # 16 | # :file is a path to a file to be inspected. 17 | # 18 | # :raw_response is the full output of "ffmpeg -i [file]". If the 19 | # :raw_response option is used, RVideo will not actually inspect a file; 20 | # it will simply parse the provided response. This is useful if your 21 | # application has already collected the ffmpeg -i response, and you don't 22 | # want to call it again. 23 | # 24 | # :ffmpeg_binary is an optional argument that specifies the path to the 25 | # ffmpeg binary to be used. If a path is not explicitly declared, RVideo 26 | # will assume that ffmpeg exists in the Unix path. Type "which ffmpeg" to 27 | # check if ffmpeg is installed and exists in your operating system's path. 28 | # 29 | 30 | def initialize(options = {}) 31 | if options[:raw_response] 32 | @raw_response = options[:raw_response] 33 | elsif options[:file] 34 | if options[:ffmpeg_binary] 35 | @ffmpeg_binary = options[:ffmpeg_binary] 36 | raise RuntimeError, "ffmpeg could not be found (trying #{@ffmpeg_binary})" unless FileTest.exist?(@ffmpeg_binary) 37 | else 38 | # assume it is in the unix path 39 | raise RuntimeError, 'ffmpeg could not be found (expected ffmpeg to be found in the Unix path)' unless FileTest.exist?(`which ffmpeg`.chomp) 40 | @ffmpeg_binary = "ffmpeg" 41 | end 42 | 43 | file = options[:file] 44 | @filename = File.basename(file) 45 | @path = File.dirname(file) 46 | @full_filename = file 47 | raise TranscoderError::InputFileNotFound, "File not found (#{file})" unless FileTest.exist?(file.gsub("\"","")) 48 | @raw_response = `#{@ffmpeg_binary} -i #{@full_filename} 2>&1` 49 | else 50 | raise ArgumentError, "Must supply either an input file or a pregenerated response" if options[:raw_response].nil? and file.nil? 51 | end 52 | 53 | metadata = /(Input \#.*)\n.+\n\Z/m.match(@raw_response) 54 | 55 | if /Unknown format/i.match(@raw_response) || metadata.nil? 56 | @unknown_format = true 57 | elsif /Duration: N\/A/im.match(@raw_response) 58 | # elsif /Duration: N\/A|bitrate: N\/A/im.match(@raw_response) 59 | @unreadable_file = true 60 | @raw_metadata = metadata[1] # in this case, we can at least still get the container type 61 | else 62 | @raw_metadata = metadata[1] 63 | end 64 | end 65 | 66 | # 67 | # Returns true if the file can be read successfully. Returns false otherwise. 68 | # 69 | 70 | def valid? 71 | if @unknown_format or @unreadable_file 72 | false 73 | else 74 | true 75 | end 76 | end 77 | 78 | # 79 | # Returns false if the file can be read successfully. Returns false otherwise. 80 | # 81 | 82 | def invalid? 83 | !valid? 84 | end 85 | 86 | # 87 | # True if the format is not understood ("Unknown Format") 88 | # 89 | 90 | def unknown_format? 91 | if @unknown_format 92 | true 93 | else 94 | false 95 | end 96 | end 97 | 98 | # 99 | # True if the file is not readable ("Duration: N/A, bitrate: N/A") 100 | # 101 | 102 | def unreadable_file? 103 | if @unreadable_file 104 | true 105 | else 106 | false 107 | end 108 | end 109 | 110 | # 111 | # Does the file have an audio stream? 112 | # 113 | 114 | def audio? 115 | if audio_match.nil? 116 | false 117 | else 118 | true 119 | end 120 | end 121 | 122 | # 123 | # Does the file have a video stream? 124 | # 125 | 126 | def video? 127 | if video_match.nil? 128 | false 129 | else 130 | true 131 | end 132 | end 133 | 134 | # 135 | # Take a screengrab of a movie. Requires an input file and a time parameter, and optionally takes an output filename. If no output filename is specfied, constructs one. 136 | # 137 | # Three types of time parameters are accepted - percentage (e.g. 3%), time in seconds (e.g. 60 seconds), and raw frame (e.g. 37). Will raise an exception if the time in seconds or the frame are out of the bounds of the input file. 138 | # 139 | # Types: 140 | # 37s (37 seconds) 141 | # 37f (frame 37) 142 | # 37% (37 percent) 143 | # 37 (default to seconds) 144 | # 145 | # If a time is outside of the duration of the file, it will choose a frame at the 99% mark. 146 | # 147 | # Example: 148 | # 149 | # t = RVideo::Transcoder.new('path/to/input_file.mp4') 150 | # t.capture_frame('10%') # => '/path/to/screenshot/input-10p.jpg' 151 | # 152 | 153 | def capture_frame(timecode, output_file = nil) 154 | t = calculate_time(timecode) 155 | unless output_file 156 | output_file = "#{TEMP_PATH}/#{File.basename(@full_filename, ".*")}-#{timecode.gsub("%","p")}.jpg" 157 | end 158 | # do the work 159 | # mplayer $input_file$ -ss $start_time$ -frames 1 -vo jpeg -o $output_file$ 160 | # ffmpeg -i $input_file$ -v nopb -ss $start_time$ -b $bitrate$ -an -vframes 1 -y $output_file$ 161 | command = "ffmpeg -i #{@full_filename} -ss #{t} -t 00:00:01 -r 1 -vframes 1 -f image2 #{output_file}" 162 | Transcoder.logger.info("\nCreating Screenshot: #{command}\n") 163 | frame_result = `#{command} 2>&1` 164 | Transcoder.logger.info("\nScreenshot results: #{frame_result}") 165 | output_file 166 | end 167 | 168 | def calculate_time(timecode) 169 | m = /\A([0-9\.\,]*)(s|f|%)?\Z/.match(timecode) 170 | if m.nil? or m[1].nil? or m[1].empty? 171 | raise TranscoderError::ParameterError, "Invalid timecode for frame capture: #{timecode}. Must be a number, optionally followed by s, f, or %." 172 | end 173 | 174 | case m[2] 175 | when "s", nil 176 | t = m[1].to_f 177 | when "f" 178 | t = m[1].to_f / fps.to_f 179 | when "%" 180 | # milliseconds / 1000 * percent / 100 181 | t = (duration.to_i / 1000.0) * (m[1].to_f / 100.0) 182 | else 183 | raise TranscoderError::ParameterError, "Invalid timecode for frame capture: #{timecode}. Must be a number, optionally followed by s, f, or p." 184 | end 185 | 186 | if (t * 1000) > duration 187 | calculate_time("99%") 188 | else 189 | t 190 | end 191 | end 192 | 193 | # 194 | # Returns the version of ffmpeg used, In practice, this may or may not be 195 | # useful. 196 | # 197 | # Examples: 198 | # 199 | # SVN-r6399 200 | # CVS 201 | # 202 | 203 | def ffmpeg_version 204 | @ffmpeg_version = @raw_response.split("\n").first.split("version").last.split(",").first.strip 205 | end 206 | 207 | # 208 | # Returns the configuration options used to build ffmpeg. 209 | # 210 | # Example: 211 | # 212 | # --enable-mp3lame --enable-gpl --disable-ffplay --disable-ffserver 213 | # --enable-a52 --enable-xvid 214 | # 215 | 216 | def ffmpeg_configuration 217 | /(\s*configuration:)(.*)\n/.match(@raw_response)[2].strip 218 | end 219 | 220 | # 221 | # Returns the versions of libavutil, libavcodec, and libavformat used by 222 | # ffmpeg. 223 | # 224 | # Example: 225 | # 226 | # libavutil version: 49.0.0 227 | # libavcodec version: 51.9.0 228 | # libavformat version: 50.4.0 229 | # 230 | 231 | def ffmpeg_libav 232 | /^(\s*lib.*\n)+/.match(@raw_response)[0].split("\n").each {|l| l.strip! } 233 | end 234 | 235 | # 236 | # Returns the build description for ffmpeg. 237 | # 238 | # Example: 239 | # 240 | # built on Apr 15 2006 04:58:19, gcc: 4.0.1 (Apple Computer, Inc. build 241 | # 5250) 242 | # 243 | 244 | def ffmpeg_build 245 | /(\n\s*)(built on.*)(\n)/.match(@raw_response)[2] 246 | end 247 | 248 | # 249 | # Returns the container format for the file. Instead of returning a single 250 | # format, this may return a string of related formats. 251 | # 252 | # Examples: 253 | # 254 | # "avi" 255 | # 256 | # "mov,mp4,m4a,3gp,3g2,mj2" 257 | # 258 | 259 | def container 260 | return nil if @unknown_format 261 | 262 | /Input \#\d+\,\s*(\S+),\s*from/.match(@raw_metadata)[1] 263 | end 264 | 265 | # 266 | # The duration of the movie, as a string. 267 | # 268 | # Example: 269 | # 270 | # "00:00:24.4" # 24.4 seconds 271 | # 272 | def raw_duration 273 | return nil unless valid? 274 | 275 | /Duration:\s*([0-9\:\.]+),/.match(@raw_metadata)[1] 276 | end 277 | 278 | # 279 | # The duration of the movie in milliseconds, as an integer. 280 | # 281 | # Example: 282 | # 283 | # 24400 # 24.4 seconds 284 | # 285 | # Note that the precision of the duration is in tenths of a second, not 286 | # thousandths, but milliseconds are a more standard unit of time than 287 | # deciseconds. 288 | # 289 | 290 | def duration 291 | return nil unless valid? 292 | 293 | units = raw_duration.split(":") 294 | (units[0].to_i * 60 * 60 * 1000) + (units[1].to_i * 60 * 1000) + (units[2].to_f * 1000).to_i 295 | end 296 | 297 | # 298 | # The bitrate of the movie. 299 | # 300 | # Example: 301 | # 302 | # 3132 303 | # 304 | 305 | def bitrate 306 | return nil unless valid? 307 | 308 | bitrate_match[1].to_i 309 | end 310 | 311 | # 312 | # The bitrate units used. In practice, this may always be kb/s. 313 | # 314 | # Example: 315 | # 316 | # "kb/s" 317 | # 318 | 319 | def bitrate_units 320 | return nil unless valid? 321 | 322 | bitrate_match[2] 323 | end 324 | 325 | def audio_bit_rate # :nodoc: 326 | nil 327 | end 328 | 329 | def audio_stream 330 | return nil unless valid? 331 | 332 | #/\n\s*Stream.*Audio:.*\n/.match(@raw_response)[0].strip 333 | match = /\n\s*Stream.*Audio:.*\n/.match(@raw_response) 334 | return match[0].strip if match 335 | end 336 | 337 | # 338 | # The audio codec used. 339 | # 340 | # Example: 341 | # 342 | # "aac" 343 | # 344 | 345 | def audio_codec 346 | return nil unless audio? 347 | 348 | audio_match[2] 349 | end 350 | 351 | # 352 | # The sampling rate of the audio stream. 353 | # 354 | # Example: 355 | # 356 | # 44100 357 | # 358 | 359 | def audio_sample_rate 360 | return nil unless audio? 361 | 362 | audio_match[3].to_i 363 | end 364 | 365 | # 366 | # The units used for the sampling rate. May always be Hz. 367 | # 368 | # Example: 369 | # 370 | # "Hz" 371 | # 372 | 373 | def audio_sample_units 374 | return nil unless audio? 375 | 376 | audio_match[4] 377 | end 378 | 379 | # 380 | # The channels used in the audio stream. 381 | # 382 | # Examples: 383 | # "stereo" 384 | # "mono" 385 | # "5:1" 386 | # 387 | 388 | def audio_channels_string 389 | return nil unless audio? 390 | 391 | audio_match[5] 392 | end 393 | 394 | def audio_channels 395 | return nil unless audio? 396 | 397 | case audio_match[5] 398 | when "mono" 399 | 1 400 | when "stereo" 401 | 2 402 | else 403 | raise RuntimeError, "Unknown number of channels: #{audio_channels}" 404 | end 405 | end 406 | 407 | # 408 | # The ID of the audio stream (useful for troubleshooting). 409 | # 410 | # Example: 411 | # #0.1 412 | # 413 | 414 | def audio_stream_id 415 | return nil unless audio? 416 | 417 | audio_match[1] 418 | end 419 | 420 | def video_stream 421 | return nil unless valid? 422 | 423 | match = /\n\s*Stream.*Video:.*\n/.match(@raw_response) 424 | return match[0].strip unless match.nil? 425 | nil 426 | end 427 | 428 | # 429 | # The ID of the video stream (useful for troubleshooting). 430 | # 431 | # Example: 432 | # #0.0 433 | # 434 | 435 | def video_stream_id 436 | return nil unless video? 437 | 438 | video_match[1] 439 | end 440 | 441 | # 442 | # The video codec used. 443 | # 444 | # Example: 445 | # 446 | # "mpeg4" 447 | # 448 | 449 | def video_codec 450 | return nil unless video? 451 | 452 | video_match[2] 453 | end 454 | 455 | # 456 | # The colorspace of the video stream. 457 | # 458 | # Example: 459 | # 460 | # "yuv420p" 461 | # 462 | 463 | def video_colorspace 464 | return nil unless video? 465 | 466 | video_match[3] 467 | end 468 | 469 | # 470 | # The width of the video in pixels. 471 | # 472 | 473 | def width 474 | return nil unless video? 475 | 476 | video_match[4].to_i 477 | end 478 | 479 | # 480 | # The height of the video in pixels. 481 | # 482 | 483 | def height 484 | return nil unless video? 485 | 486 | video_match[5].to_i 487 | end 488 | 489 | # 490 | # width x height, as a string. 491 | # 492 | # Examples: 493 | # 320x240 494 | # 1280x720 495 | # 496 | 497 | def resolution 498 | return nil unless video? 499 | 500 | "#{width}x#{height}" 501 | end 502 | 503 | # 504 | # The frame rate of the video in frames per second 505 | # 506 | # Example: 507 | # 508 | # "29.97" 509 | # 510 | 511 | def fps 512 | return nil unless video? 513 | 514 | /([0-9\.]+) (fps|tb)/.match(video_stream)[1] 515 | end 516 | 517 | private 518 | 519 | def bitrate_match 520 | /bitrate: ([0-9\.]+)\s*(.*)\s+/.match(@raw_metadata) 521 | end 522 | 523 | def audio_match 524 | return nil unless valid? 525 | 526 | /Stream\s*(.*?)[,|:|\(|\[].*?\s*Audio:\s*(.*?),\s*([0-9\.]*) (\w*),\s*([a-zA-Z:]*)/.match(audio_stream) 527 | end 528 | 529 | def video_match 530 | return nil unless valid? 531 | 532 | match = /Stream\s*(.*?)[,|:|\(|\[].*?\s*Video:\s*(.*?),\s*(.*?),\s*(\d*)x(\d*)/.match(video_stream) 533 | 534 | # work-around for Apple Intermediate format, which does not have a color space 535 | # I fake up a match data object (yea, duck typing!) with an empty spot where 536 | # the color space would be. 537 | if match.nil? 538 | match = /Stream\s*(.*?)[,|:|\(|\[].*?\s*Video:\s*(.*?),\s*(\d*)x(\d*)/.match(video_stream) 539 | match = [nil, match[1], match[2], nil, match[3], match[4]] unless match.nil? 540 | end 541 | 542 | match 543 | end 544 | end 545 | end --------------------------------------------------------------------------------