Skip to content

Instantly share code, notes, and snippets.

@Beyarz
Last active June 29, 2024 17:17
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

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
# Run: ruby optimize-video.rb --help
# For AMD GPUs, you need to build ffmpeg with AMF support
# Only optimizes .mp4 and .mov files, but can easily be adjusted in collect_videos() function
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', '>= 1.7.2'
end
PREFIX = 'optimized'
@converted_folder = 'converted'
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 init_thread_pool(threads)
Concurrent::FixedThreadPool.new(
threads,
idletime: 10,
name: 'optimizer',
fallback_policy: :discard,
auto_terminate: true
)
end
def queue_and_transcode(video, queue_id, argv)
@thread_pool.post do
@logger.info("#{queue_id}: Processing #{video}")
bar = @bars.register(
"Thread (#{queue_id} of #{@total_jobs}): [:bar] :percent ETA :eta",
bar_format: :block,
total: 10
)
movie = FFMPEG::Movie.new(video)
creation_meta_data = movie.metadata[:streams][0][:tags][:creation_time]
creation_meta_data = movie.metadata[:format][:tags][:creation_time] if creation_meta_data.nil?
@logger.warn("#{queue_id}: #{movie} metadata creation_time had value nil") if creation_meta_data.nil?
creation_time = ['-metadata', "creation_time=#{creation_meta_data}"]
options = {
custom: [
'-map_metadata', '0',
'-movflags', 'use_metadata_tags',
*creation_time,
'-c:a', 'copy',
'-b:v', '1M',
'-maxrate', '2M',
'-bufsize', '1M',
'-vf', 'scale=-2:720',
'-b:a', '128k'
]
}
options[:custom] += ['-c:v', 'h264_nvenc'] if argv[:enable_gpu_nvidia]
options[:custom] += ['-c:v', 'h264_amf'] if argv[:enable_gpu_amd]
options[:threads] = argv[:ffmpeg_threads] if argv[:ffmpeg_threads]
new_file_format = argv[:output] ? ".#{argv[:output]}" : File.extname(video)
new_file = "#{PREFIX}/#{PREFIX}-#{File.basename(video, '.*')}#{new_file_format}"
movie.transcode(new_file, options) { |progress| bar.current = progress * 10 }
# Preserve file system timestamps
stat = File.stat(video)
File.utime(stat.atime, stat.mtime, new_file)
FileUtils.move video, "#{@converted_folder}/#{video}"
bar.finish
@completed_jobs += 1
@logger.info("#{queue_id}: Completed processing #{video}")
rescue StandardError => e
@logger.error("Error processing #{video}: #{e.message}")
@logger.error(e.backtrace.join("\n"))
end
end
argv = {}
gpu_enabled = false
opt = OptionParser.new do |option|
option.on('--enable-gpu-nvidia', 'Enable GPU acceleration (NVIDIA card)') do
argv[:enable_gpu_nvidia] = true
gpu_enabled = true
end
option.on('--enable-gpu-amd', 'Enable GPU acceleration (AMD card)') do
argv[:enable_gpu_amd] = true
gpu_enabled = true
end
option.on('--threads=NUMBER', 'Set the number of worker threads') do |amount|
argv[:worker_threads] = amount.to_i
end
option.on('--ffmpeg-threads=NUMBER', 'Set the number of threads each ffmpeg process should use') do |amount|
argv[:ffmpeg_threads] = amount.to_i
end
option.on('--output-format=STRING', 'Sets the preferred output format') do |out_format|
argv[:output] = out_format
end
option.on('-h', '--help', 'See available options') do
puts option
return 0
end
end
opt.parse!
Dir.mkdir(@converted_folder) unless Dir.exist?(@converted_folder)
Dir.mkdir(PREFIX) unless Dir.exist?(PREFIX)
FFMPEG.logger = @logger = Logger.new('transcode_log.txt')
WORKER_THREADS = argv[:worker_threads].nil? ? Concurrent.processor_count : argv[:worker_threads]
@thread_pool = init_thread_pool(WORKER_THREADS)
@bars = TTY::ProgressBar::Multi.new(
"Main (running #{WORKER_THREADS} #{WORKER_THREADS > 1 ? 'threads' : 'thread'}) #{if gpu_enabled
'with GPU acceleration'
end}",
frequency: 1.0
)
@bars.start
videos = collect_videos
@completed_jobs = 0
@total_jobs = videos.length
videos.each_with_index do |video, index|
@logger.info("Queueing video: #{video}")
queue_and_transcode(video, index + 1, argv)
end
# Only shutdown thread_pool when all jobs completed, until then, sleep and check later
@logger.info('Waiting for all jobs to complete')
while @completed_jobs < @total_jobs
sleep 30
@logger.info("Completed #{@completed_jobs}/#{@total_jobs} jobs")
end
@logger.info('All jobs completed')
@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
# frozen_string_literal: true
# This script verifies that the metadata about the files "creation date" is preserved on new file
# The folders "optimized" and "converted" has to exist, they are automatically created by the optimize_video.rb script
# Run: ruby compare_dates.rb
require 'json'
return unless Dir.exist?('converted')
return unless Dir.exist?('optimized')
puts 'Checking for diffs in dates between old and new file'
threads = []
diff_count = 0
files = Dir.glob('converted/*.*')
files.each do |file|
threads << Thread.new do
conv_metadata = `ffprobe -v quiet -print_format json -show_format "#{file}"`.strip
conv_metadata = JSON.parse(conv_metadata)
converted_file_metadata = conv_metadata['format']['tags']['creation_time']
optim_metadata = `ffprobe -v quiet -print_format json -show_format "optimized/optimized-#{file.delete_prefix('converted/')}"`.strip
optim_metadata = JSON.parse(optim_metadata)
optimized_file_metadata = optim_metadata['format']['tags']['creation_time']
unless (converted_file_metadata <=> optimized_file_metadata).zero?
puts "Diff: #{converted_file_metadata <=> optimized_file_metadata} - #{converted_file_metadata} - #{optimized_file_metadata} - #{file.delete_prefix('converted/')}"
diff_count += 1
end
end
end
threads.each(&:join)
if diff_count.zero?
puts 'Done, no diff found'
else
puts 'Done.'
end
# frozen_string_literal: true
# I added the "p" as prefix because I didn't want this file to be at the top.
#
# Count total file size in folder (in order to diff)
# Run: "ruby count_size.rb"
def count_size_of(folder_path)
sum = 0.0
Dir.glob("#{folder_path}/*.*").each do |file|
sum += File.size(file)
end
sum /= 2**30
formatted_value = format('%1.1f', sum)
puts "Total size: #{formatted_value} GBs in #{folder_path}"
end
Dir
.glob('*')
.select { |file| File.directory? file }
.each { |directory| count_size_of directory }
# frozen_string_literal: true
# Check for any missing files
# Run: ruby validate.rb
missing_counter = 0
files = Dir
.glob('converted/*.*')
.select { |file| file.match?(/mp4|mov/) }
files.each do |each_file|
relative_file_path = each_file.delete_prefix 'converted/'
next if File.exist?("optimized/optimized-#{relative_file_path}")
puts "#{each_file} not converted"
missing_counter += 1
end
puts "#{missing_counter} files missing"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment