289 lines
8.6 KiB
Ruby
289 lines
8.6 KiB
Ruby
require 'optparse'
|
|
require 'fileutils'
|
|
require 'open3'
|
|
require 'json'
|
|
|
|
# Configuration
|
|
# Default target is loosely based on user preference, but we use auto-density usually.
|
|
options = {
|
|
threshold: 5.0, # Default 5 GB/hr
|
|
dry_run: false,
|
|
quality: 29 # Default CQ
|
|
}
|
|
|
|
OptionParser.new do |opts|
|
|
opts.banner = "Usage: ruby video_optimizer.rb [directory] [options]"
|
|
|
|
opts.on("-t", "--threshold N", Float, "Minimum GB per hour to trigger processing (default: 5.0)") do |v|
|
|
options[:threshold] = v
|
|
end
|
|
|
|
opts.on("-n", "--dry-run", "Identify files but do not transcode") do
|
|
options[:dry_run] = true
|
|
end
|
|
|
|
opts.on("-q", "--quality N", Integer, "Set Constant Quality (CQ/CRF) value (Overrides auto-density)") do |v|
|
|
options[:cq] = v
|
|
options[:mode] = :cq
|
|
end
|
|
|
|
opts.on("-b", "--bitrate RATE", "Set fixed bitrate (e.g. '2M', '2000k')") do |v|
|
|
options[:bitrate] = v
|
|
options[:mode] = :bitrate
|
|
end
|
|
|
|
opts.on("-s", "--target-size GB", Float, "Target file size in GB") do |v|
|
|
options[:target_size] = v
|
|
options[:mode] = :size
|
|
end
|
|
|
|
opts.on("-h", "--help", "Prints this help") do
|
|
puts opts
|
|
exit
|
|
end
|
|
end.parse!
|
|
|
|
# Default mode
|
|
options[:mode] ||= :auto_density
|
|
|
|
directory = ARGV[0]
|
|
if directory.nil? || !Dir.exist?(directory)
|
|
puts "Error: Please provide a valid directory."
|
|
exit 1
|
|
end
|
|
|
|
# --- ENCODER LOGIC ---
|
|
|
|
def detect_encoders
|
|
stdout, _, _ = Open3.capture3('ffmpeg', '-v', 'quiet', '-encoders')
|
|
encoders = []
|
|
encoders << :nvenc if stdout.include?("hevc_nvenc")
|
|
encoders << :qsv if stdout.include?("hevc_qsv")
|
|
encoders << :cpu if stdout.include?("libx265")
|
|
encoders
|
|
end
|
|
|
|
def select_best_encoder(available_encoders)
|
|
if available_encoders.include?(:nvenc)
|
|
puts " [Encoder] Selected: NVIDIA (hevc_nvenc)"
|
|
return :nvenc
|
|
elsif available_encoders.include?(:qsv)
|
|
puts " [Encoder] Selected: Intel QSV (hevc_qsv)"
|
|
return :qsv
|
|
elsif available_encoders.include?(:cpu)
|
|
puts " [Encoder] Selected: CPU (libx265)"
|
|
return :cpu
|
|
else
|
|
puts " [Error] No suitable HEVC encoder found!"
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
def build_encoder_args(encoder, mode, target_val)
|
|
args = []
|
|
|
|
case encoder
|
|
when :nvenc
|
|
args << '-c:v' << 'hevc_nvenc' << '-preset' << 'p4'
|
|
case mode
|
|
when :cq
|
|
args << '-rc' << 'constqp' << '-qp' << target_val.to_s
|
|
when :bitrate, :size # Both use target bitrate
|
|
args << '-rc' << 'vbr' << '-b:v' << target_val << '-maxrate' << target_val << '-bufsize' << (target_val.to_i * 2).to_s
|
|
end
|
|
|
|
when :qsv
|
|
args << '-c:v' << 'hevc_qsv' << '-preset' << 'veryfast' << '-load_plugin' << 'hevc_hw'
|
|
case mode
|
|
when :cq
|
|
# QSV uses -global_quality or -q:v for ICQ. roughly maps to CRF.
|
|
args << '-global_quality' << target_val.to_s
|
|
when :bitrate, :size
|
|
# QSV VBR
|
|
args << '-b:v' << target_val << '-maxrate' << target_val
|
|
end
|
|
|
|
when :cpu
|
|
args << '-c:v' << 'libx265' << '-preset' << 'medium'
|
|
case mode
|
|
when :cq
|
|
args << '-crf' << target_val.to_s
|
|
when :bitrate, :size
|
|
args << '-b:v' << target_val << '-maxrate' << target_val << '-bufsize' << (target_val.to_i * 2).to_s
|
|
end
|
|
end
|
|
|
|
args
|
|
end
|
|
|
|
# --- METADATA & UTILS ---
|
|
|
|
def get_video_metadata(file_path)
|
|
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', '-select_streams', 'v:0', file_path]
|
|
stdout, stderr, status = Open3.capture3(*cmd)
|
|
return nil unless status.success?
|
|
|
|
begin
|
|
data = JSON.parse(stdout)
|
|
format_info = data['format']
|
|
stream_info = data['streams'][0]
|
|
return nil unless format_info && stream_info
|
|
|
|
size_bytes = format_info['size'].to_i
|
|
duration_sec = format_info['duration'].to_f
|
|
codec = stream_info['codec_name']
|
|
|
|
fps_str = stream_info['avg_frame_rate']
|
|
if fps_str && fps_str.include?('/')
|
|
num, den = fps_str.split('/').map(&:to_f)
|
|
fps = (den > 0) ? (num / den) : 30.0
|
|
else
|
|
fps = 30.0
|
|
end
|
|
|
|
return nil if size_bytes == 0 || duration_sec == 0
|
|
|
|
{ size_bytes: size_bytes, duration_sec: duration_sec, codec: codec, fps: fps }
|
|
rescue => e
|
|
puts "Failed to parse metadata: #{e.message}"
|
|
nil
|
|
end
|
|
end
|
|
|
|
def calculate_target_bitrate_from_size(duration_sec, target_gb)
|
|
target_bytes = (target_gb * 1024 * 1024 * 1024).to_i
|
|
audio_bitrate_bps = 192 * 1000
|
|
estimated_audio_size = duration_sec * (audio_bitrate_bps / 8.0)
|
|
target_video_size = target_bytes - estimated_audio_size
|
|
return 500_000 if target_video_size <= 0
|
|
(target_video_size * 8) / duration_sec
|
|
end
|
|
|
|
# --- PROCESS ---
|
|
|
|
AVAILABLE_ENCODERS = detect_encoders
|
|
BEST_ENCODER = select_best_encoder(AVAILABLE_ENCODERS)
|
|
|
|
def process_file(file_path, options)
|
|
metadata = get_video_metadata(file_path)
|
|
return unless metadata
|
|
|
|
accepted_codecs = ['h264', 'avc1']
|
|
return unless accepted_codecs.include?(metadata[:codec].to_s.downcase)
|
|
|
|
size_gb = metadata[:size_bytes].to_f / (1024**3)
|
|
hours = metadata[:duration_sec] / 3600.0
|
|
gb_per_hour = size_gb / hours
|
|
|
|
if gb_per_hour >= options[:threshold]
|
|
puts "\n[MATCH] #{file_path}"
|
|
puts " Size: #{size_gb.round(2)} GB, Density: #{gb_per_hour.round(2)} GB/hr"
|
|
|
|
if options[:dry_run]
|
|
puts " [Dry Run] Would transcode this file."
|
|
return
|
|
end
|
|
|
|
# Determine Encode Mode & Target Value
|
|
mode = :cq
|
|
target_val = 29 # Default CQ
|
|
|
|
if options[:mode] == :auto_density
|
|
target_gb_hr = options[:threshold] / 2.0
|
|
target_gb = hours * target_gb_hr
|
|
bitrate = calculate_target_bitrate_from_size(metadata[:duration_sec], target_gb)
|
|
|
|
mode = :bitrate # Use bitrate targeting for density match
|
|
target_val = bitrate.to_i.to_s # e.g. "2000000"
|
|
puts " Auto-Density Target: #{target_gb.round(2)} GB (#{target_gb_hr} GB/hr) -> #{target_val.to_i/1000} kbps"
|
|
|
|
elsif options[:mode] == :size
|
|
bitrate = calculate_target_bitrate_from_size(metadata[:duration_sec], options[:target_size])
|
|
mode = :bitrate
|
|
target_val = bitrate.to_i.to_s
|
|
puts " Target Size: #{options[:target_size]} GB -> #{target_val.to_i/1000} kbps"
|
|
|
|
elsif options[:mode] == :bitrate
|
|
mode = :bitrate
|
|
target_val = options[:bitrate] # User string e.g. "2M"
|
|
puts " Target Bitrate: #{target_val}"
|
|
|
|
else # :cq
|
|
mode = :cq
|
|
target_val = options[:cq] || 29
|
|
puts " Target Quality (CQ/CRF): #{target_val}"
|
|
end
|
|
|
|
# Build flags
|
|
encoder_args = build_encoder_args(BEST_ENCODER, mode, target_val)
|
|
|
|
# Common Plex/Optimization Args
|
|
gop_size = (metadata[:fps] * 2).round
|
|
common_args = [
|
|
'-pix_fmt', 'yuv420p', # 8-bit HEVC (Main Profile)
|
|
'-g', "#{gop_size}", # Fixed GOP
|
|
'-keyint_min', "#{gop_size}",
|
|
'-movflags', '+faststart', # Streaming optimization
|
|
'-c:a', 'copy'
|
|
]
|
|
|
|
original_ext = File.extname(file_path)
|
|
temp_file = file_path.sub(original_ext, ".tmp#{original_ext}")
|
|
|
|
cmd = ['ffmpeg', '-y', '-hwaccel', 'auto', '-i', file_path] + encoder_args + common_args + [temp_file]
|
|
|
|
puts " Starting transcode with #{BEST_ENCODER}..."
|
|
start_time = Time.now
|
|
|
|
stdout, stderr, status = Open3.capture3(*cmd)
|
|
|
|
if status.success?
|
|
duration = Time.now - start_time
|
|
puts " Done in #{duration.round(1)}s."
|
|
|
|
# Validation
|
|
if File.exist?(temp_file) && File.size(temp_file) > 1000
|
|
new_size_gb = File.size(temp_file).to_f / (1024**3)
|
|
reduction = ((size_gb - new_size_gb) / size_gb * 100.0).round(1)
|
|
|
|
puts " New Size: #{new_size_gb.round(2)} GB (#{reduction}% reduction)."
|
|
|
|
if reduction < 33.0
|
|
puts " [SKIP] Reduction too low (<33%). Keeping original."
|
|
File.delete(temp_file)
|
|
else
|
|
# Verification
|
|
new_meta = get_video_metadata(temp_file)
|
|
if new_meta && (new_meta[:duration_sec] - metadata[:duration_sec]).abs < 30
|
|
File.delete(file_path)
|
|
File.rename(temp_file, file_path)
|
|
puts " [SUCCESS] File replaced."
|
|
else
|
|
puts " [ERROR] Duration mismatch. Keeping original."
|
|
File.delete(temp_file)
|
|
end
|
|
end
|
|
else
|
|
puts " [ERROR] Output invalid."
|
|
end
|
|
else
|
|
puts " [ERROR] Transcode failed."
|
|
puts stderr # Debug
|
|
File.delete(temp_file) if File.exist?(temp_file)
|
|
end
|
|
end
|
|
end
|
|
|
|
puts "Scanning #{directory}..."
|
|
files = []
|
|
Dir.glob("#{directory}/**/*").each do |f|
|
|
next if File.directory?(f)
|
|
next unless ['.mp4', '.mkv', '.avi', '.mov', '.m4v'].include?(File.extname(f).downcase)
|
|
files << [f, File.size(f)] rescue nil
|
|
end
|
|
|
|
puts "Sorting #{files.count} files by size..."
|
|
files.sort_by! { |_, size| -size }
|
|
|
|
files.each { |f, _| process_file(f, options) }
|