Skip to content

Instantly share code, notes, and snippets.

@Beyarz
Last active July 6, 2024 13:46
Show Gist options
  • Save Beyarz/cabbc8e9747aa69907bf92b14670371f to your computer and use it in GitHub Desktop.
Save Beyarz/cabbc8e9747aa69907bf92b14670371f to your computer and use it in GitHub Desktop.
Automatically optimize every video in a folder via ffmpeg to save up storage. It's multithreaded and supports GPU acceleration, just put this script in the folder and execute it.

Optimize video

Run

  1. Download the script
  2. Put this file in the same folder as your videos
  3. Run: ruby optimize_video.rb, for help, use ruby optimize_video.rb --help

Watch log / debug

tail -f transcode_log.txt

Benchmark

ffmpeg -hwaccel auto -i some_video.MOV -c:a copy -preset medium -f null - -benchmark

# 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment