Skip to content

Instantly share code, notes, and snippets.

@punkrats
Last active February 15, 2017 15:32
Show Gist options
  • Select an option

  • Save punkrats/9c86708585e4614a04aa to your computer and use it in GitHub Desktop.

Select an option

Save punkrats/9c86708585e4614a04aa to your computer and use it in GitHub Desktop.
#!/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