Last active
February 15, 2017 15:32
-
-
Save punkrats/9c86708585e4614a04aa to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # This is a quick cutting and concatenation script for recorded video files. | |
| # It will take a list of files with instructions on how to extract parts of | |
| # the recorded files. | |
| # | |
| # The script was built initially to work with MTS files recorded by a Sony | |
| # video camera but it should work with any MP4 video. | |
| # | |
| # Usage with mapping file: | |
| # | |
| # ruby cutter.rb mapping.txt | |
| # | |
| # The mapping file must contain one line for file to process: | |
| # | |
| # session_1: path/to/file.MTS[from-to] | |
| # session_2: ... | |
| # | |
| # `from` and `to` must be given as HH:mm:ss.SSS | |
| # | |
| # Usage with inline args: | |
| # | |
| # ruby cutter.rb "session_1: path/to/file.MTS[from-to]" | |
| # | |
| # If a session stretches across several files, concatenate additional | |
| # parts with a pipe while omitting `to` and/or `from`: | |
| # | |
| # session_1: file_1.MTS[from-] | file_2.MTS | file_3.MTS[-to] | |
| # | |
| # Note: `from` times might not be applied exact to gain speed. | |
| # | |
| # Requirements: | |
| # | |
| # FFmpeg >= version 1 | |
| # Ruby gem 'posix-spawn' | |
| # | |
| require 'rubygems' | |
| require 'time' | |
| require 'posix-spawn' | |
| # You may define an encoding preset. Otherwise codecs will be copied. | |
| ENCODING_PRESET = { | |
| # audio: { | |
| # codec: 'libfdk_aac', | |
| # bit_rate: '128k', | |
| # mono: true | |
| # }, | |
| # video: { | |
| # codec: 'libx264', | |
| # bit_rate: '3200k', | |
| # width: 1280, | |
| # preset: 'fast', | |
| # filters: [ | |
| # 'hqdn3d=4.0:3.0:6.0:4.5', # remove noise | |
| # 'unsharp=5:5:1.0:5:5:0.0', # sharpen | |
| # 'mp=eq2=1.3:1.2:0.05:1:0.95:1:1' # adjust colors | |
| # ] | |
| # } | |
| } | |
| TIME_OFFSET = Time.parse('00:00:00') | |
| REGEX_TIME = /(\d{2}:\d{2}:\d{2}(?:.\d+)?)?/ | |
| REGEX_FILE = /([^\[|]+)(?:\[#{REGEX_TIME}\-#{REGEX_TIME}\])?\s*\|?\s*/ | |
| REGEX_FULL = /^([^:]+)\s*:\s*((#{REGEX_FILE})+)$/ | |
| class Error < StandardError; end | |
| def fail(message) | |
| raise Error.new(message) | |
| end | |
| def parse_line(line) | |
| if matches = line.match(REGEX_FULL) | |
| parts = matches[2].scan(REGEX_FILE) | |
| { | |
| name: matches[1].strip, | |
| parts: parts.map {|i| {file: i[0].strip, from: i[1], to: i[2]}} | |
| } | |
| else | |
| fail("#{line.inspect} is not formatted as expected") | |
| end | |
| end | |
| def parse_map(file) | |
| unless File.exist?(file) | |
| fail("Can't open #{file.inspect}") | |
| end | |
| instructions = [] | |
| lines = File.read(file).split("\n") | |
| lines.each do |line| | |
| instructions << parse_line(line) | |
| end | |
| instructions | |
| end | |
| def timecode(time) | |
| unless time.is_a?(Time) | |
| time = Time.at(time).utc | |
| end | |
| time.strftime('%H:%M:%S.%3N') | |
| end | |
| def ffmpeg(label, args, duration = 0) | |
| print "\t#{label}" | |
| if duration > 0 | |
| string = '0%' | |
| cursor = string.length | |
| print ": #{string}" | |
| end | |
| cmd = "ffmpeg -y -threads 0 #{args}" | |
| pid, stdin, stdout, stderr = POSIX::Spawn::popen4(cmd) | |
| stderr.each("\r") do |line| | |
| if duration > 0 | |
| if line =~ /frame=\s*(\d+).+time=(\d{2}):(\d{2}):(\d{2})/i | |
| frame = $1 | |
| seconds = $2.to_i*3600 + $3.to_i*60 + $4.to_i | |
| progress = ((seconds/duration)*100).to_i | |
| string = "#{progress}% (#{frame} frames)" | |
| print "\b"*cursor + string | |
| cursor = string.length | |
| end | |
| end | |
| end | |
| Process.wait(pid) | |
| msg = cursor ? "\b"*cursor + '100%' + ' '*cursor : '' | |
| puts msg | |
| unless $?.exitstatus == 0 | |
| fail("FFmpeg failed:\n#{cmd}\n#{stderr.read}") | |
| end | |
| end | |
| def analyze(file) | |
| unless File.exist?(file) | |
| fail("Can't open #{file.inspect}") | |
| end | |
| cmd = %(ffmpeg -i #{file}) | |
| pid, stdin, stdout, stderr = POSIX::Spawn::popen4(cmd) | |
| Process.wait(pid) | |
| output = stderr.read | |
| output =~ /Duration: (\d{2}:\d{2}:\d{2}.\d+),.*start: (\d+.\d+),/ | |
| data = { | |
| duration: Time.parse($1) - TIME_OFFSET, | |
| start: $2.to_f | |
| } | |
| end | |
| def perform(instructions) | |
| instructions.each do |info| | |
| input = 'parts.mts' | |
| output = "#{info[:name]}.mp4" | |
| total_duration = 0 | |
| final_duration = 0 | |
| final_from = nil | |
| puts "Processing #{output}" | |
| if File.exist?(output) | |
| puts "\tFile exists" | |
| next | |
| end | |
| # Calculate cutting times | |
| parts = [] | |
| info[:parts].each_with_index do |part, i| | |
| if from = part[:from] | |
| from = Time.parse(part[:from]) - TIME_OFFSET | |
| end | |
| if to = part[:to] | |
| to = Time.parse(part[:to]) - TIME_OFFSET | |
| end | |
| source = part[:file] | |
| file_info = analyze(source) | |
| duration = file_info[:duration] | |
| total_duration += duration | |
| if from && to | |
| duration = to - from | |
| elsif from | |
| duration -= from | |
| elsif to | |
| duration = to | |
| end | |
| final_from ||= from || file_info[:start] | |
| final_from = final_from.to_f | |
| final_duration += duration | |
| parts << source | |
| end | |
| puts "\tIn: #{timecode(final_from)}" | |
| puts "\tOut: #{timecode(final_from + final_duration)}" | |
| # Concatenate source files | |
| if parts.count > 1 | |
| bytes = 0 | |
| parts.each { |f| bytes += File.size(f) } | |
| gigabytes = (bytes.to_f/1000000000).round(2) | |
| # Convert parts to MTS files first | |
| parts.each_with_index do |part, i| | |
| next if part[/\.mts$/i] | |
| mts = "part_#{i}.mts" | |
| parts[i] = mts | |
| cmd = %(-i #{part} -bsf h264_mp4toannexb -c copy #{mts}) | |
| if final_from > 0 | |
| cmd = %(-ss #{final_from} ) + cmd | |
| final_from = 0 | |
| end | |
| ffmpeg("Converting #{part}", cmd) | |
| end | |
| puts "\tConcatenating #{parts.length} source files (#{gigabytes} GB)" | |
| %x(cat #{parts.join(' ')} > #{input}) | |
| else | |
| input = parts[0] | |
| end | |
| # Transcode the final file | |
| codecs = [] | |
| if audio = ENCODING_PRESET[:audio] | |
| codecs << %(-c:a #{audio[:codec]} -b:a #{audio[:bit_rate]}) | |
| if audio[:mono] | |
| codecs << %(-ac 1 -filter:a "pan=1c|c0=c0") | |
| end | |
| else | |
| codecs << %(-bsf:a aac_adtstoasc -c:a copy) | |
| end | |
| if video = ENCODING_PRESET[:video] | |
| filters = video[:filters] || [] | |
| filters << "scale=#{video[:width]}:-1" | |
| codecs << %(-c:v #{video[:codec]} -profile:v main -level:v 3.1 -b:v #{video[:bit_rate]} -preset #{video[:preset]} -filter:v "#{filters.join(',')}") | |
| else | |
| codecs << %(-c:v copy) | |
| end | |
| args = %(-i #{input} -t #{final_duration} -dn #{codecs.join(' ')} "#{output}") | |
| ffmpeg('Transcoding', args, final_duration) | |
| end | |
| end | |
| def clean_up | |
| files = %x(ls) | |
| if files[/part/] | |
| puts 'Removing temporary files' | |
| %x(rm part*) | |
| end | |
| end | |
| input = ARGV[0] | |
| fail('No input given') unless input | |
| begin | |
| instructions = [parse_line(input)] | |
| rescue Error => e | |
| instructions = parse_map(input) | |
| end | |
| begin | |
| perform(instructions) | |
| clean_up | |
| rescue Error => e | |
| puts e.message | |
| exit(1) | |
| end | |
| puts 'Done' |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment