Skip to content

Instantly share code, notes, and snippets.

@punkrats
Last active August 29, 2015 14:13
Show Gist options
  • Select an option

  • Save punkrats/58c958b70fa57fe0b576 to your computer and use it in GitHub Desktop.

Select an option

Save punkrats/58c958b70fa57fe0b576 to your computer and use it in GitHub Desktop.
#!/usr/bin/env ruby
# This is a quick cutting script for Eurucamp 2014 talks. It will take a list
# of talks with instructions on how to extract each talk from the recorded
# MTS files.
#
# Usage:
#
# ruby eurucamp_cutter.rb map.txt
#
# The mapping file must contain one line for each talk to process:
#
# talk_1 = path/to/file.MTS[from-to]
# talk_2 = ...
#
# `from` and `to` should be given as HH:mm:ss.SSS
#
# If a talk stretches across several files, concatenate additional parts with
# a pipe while omitting `to` and/or `from`:
#
# talk_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'
ENCODING_PRESET = {
audio: {
codec: 'libfdk_aac',
bit_rate: '128k'
},
video: {
codec: 'libx264',
bit_rate: '2400k',
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')
map = ARGV[0]
def fail(message)
puts message
exit(1)
end
def parse_map(map)
r_time = /(\d{2}:\d{2}:\d{2}(?:.\d+)?)?/
r_file = /([^\[|]+)(?:\[#{r_time}-?#{r_time}\])?\s*\|?\s*/
r_match = /^([^=]+)\s*=\s*((#{r_file})+)$/
instructions = []
lines = File.read(map).split("\n")
lines.each do |line|
if matches = line.match(r_match)
parts = matches[2].scan(r_file)
instructions << {
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
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)
puts "\b"*cursor + '100%' + ' '*cursor
unless $?.exitstatus == 0
fail("FFmpeg failed:\n#{stderr.read}")
end
end
def analyze(file)
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_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)
puts "\tConcatenating #{parts.length} source files (#{gigabytes} GB)"
%x(cat #{parts.join(' ')} > #{input})
else
input = parts[0]
end
# Transcode the final file
filters = ENCODING_PRESET[:video][:filters] || []
filters << "scale=#{ENCODING_PRESET[:video][:width]}:-1"
args = %(-ss #{final_from} -i #{input} -t #{final_duration} -dn -c:a #{ENCODING_PRESET[:audio][:codec]} -b:a #{ENCODING_PRESET[:audio][:bit_rate]} -ac 1 -filter:a "pan=1c|c0=c0" -c:v #{ENCODING_PRESET[:video][:codec]} -profile:v main -level:v 3.1 -b:v #{ENCODING_PRESET[:video][:bit_rate]} -preset #{ENCODING_PRESET[:video][:preset]} -filter:v "#{filters.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
fail('No map file given') unless map
fail("Can't open #{map.inspect}") unless File.exist?(map)
instructions = parse_map(map)
perform(instructions)
clean_up
puts 'Done'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment