|
# frozen_string_literal: true |
|
|
|
require 'bundler/inline' |
|
|
|
gemfile do |
|
source 'https://rubygems.org' |
|
gem 'streamio-ffmpeg', '~> 3.0', '>= 3.0.2' |
|
gem 'concurrent-ruby', '~> 1.3', '>= 1.3.3' |
|
gem 'tty-progressbar', '~> 0.18.2' |
|
gem 'optparse', '~> 0.5.0' |
|
gem 'logger', '~> 1.6' |
|
gem 'fileutils', '~> 1.7' |
|
gem 'json' |
|
end |
|
|
|
# Module containing video processing tools and utilities |
|
module VideoTools |
|
PREFIX = 'optimized' |
|
CONVERTED_FOLDER = 'converted' |
|
|
|
# Module for comparing dates between original and processed video files |
|
module CompareDate |
|
module_function |
|
|
|
def run |
|
return unless Dir.exist?(CONVERTED_FOLDER) && Dir.exist?(PREFIX) |
|
|
|
puts 'Checking for diffs in dates between old and new file' |
|
diff_count = compare_files |
|
print_result(diff_count) |
|
end |
|
|
|
def compare_files |
|
threads = [] |
|
diff_count = 0 |
|
files = Dir.glob("#{CONVERTED_FOLDER}/*.*") |
|
|
|
files.each do |file| |
|
threads << Thread.new do |
|
diff_count += compare_file(file) |
|
end |
|
end |
|
|
|
threads.each(&:join) |
|
diff_count |
|
end |
|
|
|
def compare_file(file) |
|
conv_metadata = get_metadata(file) |
|
optim_metadata = get_metadata("#{PREFIX}/#{PREFIX}-#{File.basename(file)}") |
|
|
|
if conv_metadata != optim_metadata |
|
puts "Diff: #{conv_metadata <=> optim_metadata} - #{conv_metadata} - " \ |
|
"#{optim_metadata} - #{File.basename(file)}" |
|
1 |
|
else |
|
0 |
|
end |
|
end |
|
|
|
def get_metadata(file) |
|
metadata = `ffprobe -v quiet -print_format json -show_format "#{file}"`.strip |
|
JSON.parse(metadata)['format']['tags']['creation_time'] |
|
end |
|
|
|
def print_result(diff_count) |
|
if diff_count.zero? |
|
puts 'Done, no diff found' |
|
else |
|
puts 'Done.' |
|
end |
|
end |
|
end |
|
|
|
# Module for counting total file sizes in directories |
|
module CountSize |
|
module_function |
|
|
|
def run |
|
Dir.glob('*') |
|
.select { |file| File.directory?(file) } |
|
.each { |directory| count_size_of(directory) } |
|
end |
|
|
|
def count_size_of(folder_path) |
|
sum = Dir.glob("#{folder_path}/*.*").sum { |file| File.size(file) }.to_f |
|
|
|
if sum > 1_000_000_000 |
|
sum_suffix = sum / (2**30) |
|
sum_value = 'GB' |
|
elsif sum > 1_000_000 |
|
sum_suffix = sum / (2**20) |
|
sum_value = 'MB' |
|
elsif sum > 1_000 |
|
sum_suffix = sum / (2**10) |
|
sum_value = 'kB' |
|
else |
|
sum_suffix = sum |
|
sum_value = 'Byte' |
|
end |
|
|
|
puts "Total size: #{format('%1.1f', sum_suffix)} #{sum_value}s in #{folder_path}" |
|
end |
|
end |
|
|
|
# Module for optimizing video files |
|
module OptimizeVideo |
|
module_function |
|
|
|
def run(argv) |
|
setup_environment |
|
worker_threads = determine_worker_threads(argv) |
|
@thread_pool = init_thread_pool(worker_threads) |
|
@bars = setup_progress_bars(worker_threads, gpu_enabled?(argv)) |
|
|
|
process_videos(argv) |
|
|
|
finalize_processing |
|
end |
|
|
|
def setup_environment |
|
Dir.mkdir(CONVERTED_FOLDER) unless Dir.exist?(CONVERTED_FOLDER) |
|
Dir.mkdir(PREFIX) unless Dir.exist?(PREFIX) |
|
FFMPEG.logger = @logger = Logger.new('transcode_log.txt') |
|
end |
|
|
|
def determine_worker_threads(argv) |
|
argv[:worker_threads] || Concurrent.processor_count |
|
end |
|
|
|
def gpu_enabled?(argv) |
|
argv[:enable_gpu_nvidia] || argv[:enable_gpu_amd] |
|
end |
|
|
|
def setup_progress_bars(worker_threads, gpu_enabled) |
|
TTY::ProgressBar::Multi.new( |
|
"Main (running #{worker_threads} #{worker_threads > 1 ? 'threads' : 'thread'}) " \ |
|
"#{gpu_enabled ? 'with GPU acceleration' : ''}", |
|
frequency: 1.0 |
|
) |
|
end |
|
|
|
def process_videos(argv) |
|
videos = collect_videos |
|
@completed_jobs = 0 |
|
@total_jobs = videos.length |
|
|
|
@bars.start |
|
videos.each_with_index do |video, index| |
|
@logger.info("Queueing video: #{video}") |
|
queue_and_transcode(video, index + 1, argv) |
|
end |
|
|
|
wait_for_completion |
|
end |
|
|
|
def collect_videos |
|
Dir.glob('*.*') |
|
.select { |file| FFMPEG::Movie.new(file).valid? } |
|
.reject { |file| file.start_with?(PREFIX) } |
|
.select { |file| file.match?(/mp4|mov/) } |
|
end |
|
|
|
def queue_and_transcode(video, queue_id, argv) |
|
@thread_pool.post do |
|
process_video(video, queue_id, argv) |
|
end |
|
end |
|
|
|
def process_video(video, queue_id, argv) |
|
@logger.info("#{queue_id}: Processing #{video}") |
|
bar = create_progress_bar(queue_id) |
|
movie = FFMPEG::Movie.new(video) |
|
options = build_transcode_options(movie, argv) |
|
new_file = transcode_video(movie, options, argv, bar) |
|
move_and_update_file(video, new_file) |
|
finalize_video_processing(bar, queue_id, video) |
|
rescue StandardError => e |
|
log_error(e, video) |
|
end |
|
|
|
def create_progress_bar(queue_id) |
|
@bars.register( |
|
"Thread (#{queue_id} of #{@total_jobs}): [:bar] :percent ETA :eta", |
|
bar_format: :block, |
|
total: 10 |
|
) |
|
end |
|
|
|
def build_transcode_options(movie, argv) |
|
creation_meta_data = extract_creation_time(movie) |
|
options = base_transcode_options(creation_meta_data) |
|
add_gpu_options(options, argv) if gpu_enabled?(argv) |
|
options[:threads] = argv[:ffmpeg_threads] if argv[:ffmpeg_threads] |
|
options |
|
end |
|
|
|
def extract_creation_time(movie) |
|
creation_time = movie.metadata[:streams][0][:tags][:creation_time] |
|
creation_time ||= movie.metadata[:format][:tags][:creation_time] |
|
@logger.warn("#{movie} metadata creation_time had value nil") if creation_time.nil? |
|
creation_time |
|
end |
|
|
|
def base_transcode_options(creation_meta_data) |
|
{ |
|
custom: [ |
|
'-map_metadata', '0', |
|
'-movflags', 'use_metadata_tags', |
|
'-metadata', "creation_time=#{creation_meta_data}", |
|
'-c:a', 'copy', |
|
'-b:v', '1M', |
|
'-maxrate', '2M', |
|
'-bufsize', '1M', |
|
'-vf', 'scale=-2:720', |
|
'-b:a', '128k' |
|
] |
|
} |
|
end |
|
|
|
def add_gpu_options(options, argv) |
|
options[:custom] += ['-c:v', 'h264_nvenc'] if argv[:enable_gpu_nvidia] |
|
options[:custom] += ['-c:v', 'h264_amf'] if argv[:enable_gpu_amd] |
|
end |
|
|
|
def transcode_video(movie, options, argv, bar) |
|
new_file_format = argv[:output] ? ".#{argv[:output]}" : File.extname(movie.path) |
|
new_file = "#{PREFIX}/#{PREFIX}-#{File.basename(movie.path, '.*')}#{new_file_format}" |
|
movie.transcode(new_file, options) { |progress| bar.current = progress * 10 } |
|
new_file |
|
end |
|
|
|
def move_and_update_file(old_file, new_file) |
|
stat = File.stat(old_file) |
|
File.utime(stat.atime, stat.mtime, new_file) |
|
FileUtils.move old_file, "#{CONVERTED_FOLDER}/#{old_file}" |
|
end |
|
|
|
def finalize_video_processing(bar, queue_id, video) |
|
bar.finish |
|
@completed_jobs += 1 |
|
@logger.info("#{queue_id}: Completed processing #{video}") |
|
end |
|
|
|
def log_error(error, video) |
|
@logger.error("Error processing #{video}: #{error.message}") |
|
@logger.error(error.backtrace.join("\n")) |
|
end |
|
|
|
def wait_for_completion |
|
@logger.info('Waiting for all jobs to complete') |
|
until @completed_jobs == @total_jobs |
|
sleep 30 |
|
@logger.info("Completed #{@completed_jobs}/#{@total_jobs} jobs") |
|
end |
|
@logger.info('All jobs completed') |
|
end |
|
|
|
def finalize_processing |
|
@logger.info('Thread pool shutdown') |
|
@thread_pool.shutdown |
|
@logger.info('Thread pool wait_for_termination') |
|
@thread_pool.wait_for_termination |
|
@logger.info('Thread pool terminated') |
|
@bars.finish |
|
puts |
|
print 'Press any key to close' |
|
gets |
|
end |
|
|
|
def init_thread_pool(threads) |
|
Concurrent::FixedThreadPool.new( |
|
threads, |
|
idletime: 10, |
|
name: 'optimizer', |
|
fallback_policy: :discard, |
|
auto_terminate: true |
|
) |
|
end |
|
end |
|
|
|
# Module for validating video processing results |
|
module Validate |
|
module_function |
|
|
|
def run |
|
missing_files = find_missing_files |
|
print_results(missing_files) |
|
end |
|
|
|
def find_missing_files |
|
Dir.glob("#{CONVERTED_FOLDER}/*.*") |
|
.select { |file| file.match?(/mp4|mov/) } |
|
.reject { |file| File.exist?("#{PREFIX}/#{PREFIX}-#{File.basename(file)}") } |
|
end |
|
|
|
def print_results(missing_files) |
|
missing_files.each { |file| puts "#{file} not converted" } |
|
puts "#{missing_files.count} files missing" |
|
end |
|
end |
|
|
|
module_function |
|
|
|
def parse_arguments |
|
argv = {} |
|
OptionParser.new do |option| |
|
option.on('--enable-gpu-nvidia', 'Enable GPU acceleration (NVIDIA card)') do |
|
argv[:enable_gpu_nvidia] = true |
|
end |
|
option.on('--enable-gpu-amd', 'Enable GPU acceleration (AMD card)') do |
|
argv[:enable_gpu_amd] = true |
|
end |
|
option.on('--threads=NUMBER', Integer, 'Set the number of worker threads') do |n| |
|
argv[:worker_threads] = n |
|
end |
|
option.on('--ffmpeg-threads=NUMBER', Integer, |
|
'Set the number of threads each ffmpeg process should use') do |n| |
|
argv[:ffmpeg_threads] = n |
|
end |
|
option.on('--output-format=STRING', 'Sets the preferred output format') do |format| |
|
argv[:output] = format |
|
end |
|
option.on('--size', 'Count total file size on both folders') do |
|
argv[:count_size] = true |
|
end |
|
option.on('--dates', 'Compare the date on every file in their respective folder') do |
|
argv[:compare_dates] = true |
|
end |
|
option.on('--validate', |
|
'Validate the output from the optimized videos') do |
|
argv[:validate] = true |
|
end |
|
option.on('-h', '--help', 'See available options') do |
|
puts option |
|
exit |
|
end |
|
end.parse! |
|
argv |
|
end |
|
|
|
def main |
|
argv = parse_arguments |
|
|
|
if argv[:count_size] |
|
CountSize.run |
|
elsif argv[:compare_dates] |
|
CompareDate.run |
|
elsif argv[:validate] |
|
Validate.run |
|
else |
|
OptimizeVideo.run(argv) |
|
end |
|
rescue StandardError => e |
|
puts "An error occurred: #{e.message}" |
|
puts e.backtrace.join("\n") |
|
exit(1) |
|
end |
|
end |
|
|
|
VideoTools.main |