Cleanup: Removed unrelated video optimizer files

This commit is contained in:
2026-02-03 14:40:31 -05:00
parent ec7625d4d9
commit 8e279ad82e
4 changed files with 0 additions and 961 deletions

View File

@@ -1,74 +0,0 @@
require 'optparse'
require 'open3'
require 'json'
options = {}
OptionParser.new do |opts|
opts.banner = "Usage: ruby delete_av1.rb [directory]"
end.parse!
directory = ARGV[0]
if directory.nil? || !Dir.exist?(directory)
puts "Usage: ruby delete_av1.rb [directory]"
exit 1
end
def is_av1?(file_path)
# Check only video stream
cmd = ['ffprobe', '-v', 'quiet', '-print_format', 'json', '-show_streams', '-select_streams', 'v:0', file_path]
stdout, _, status = Open3.capture3(*cmd)
return false unless status.success?
begin
data = JSON.parse(stdout)
stream = data['streams'][0]
return stream && stream['codec_name'] == 'av1'
rescue
false
end
end
puts "Scanning #{directory} for AV1 files..."
puts "This might take a while..."
av1_files = []
Dir.glob("#{directory}/**/*").each do |file|
next if File.directory?(file)
next unless ['.mp4', '.mkv', '.avi', '.mov', '.m4v'].include?(File.extname(file).downcase)
if is_av1?(file)
puts "Found: #{file}"
av1_files << file
end
end
if av1_files.empty?
puts "\nNo AV1 files found."
exit
end
puts "\n" + "="*40
puts "Found #{av1_files.length} AV1 files:"
av1_files.each { |f| puts " - #{f}" }
puts "="*40
puts "\nFound #{av1_files.length} files encoded with AV1."
print "Do you want to DELETE these files? [y/N]: "
STDOUT.flush
confirm = STDIN.gets.chomp.strip.downcase
if confirm == 'y'
deleted_count = 0
av1_files.each do |file|
begin
File.delete(file)
puts "Deleted: #{file}"
deleted_count += 1
rescue => e
puts "Failed to delete #{file}: #{e.message}"
end
end
puts "\nDeletion complete. #{deleted_count} files removed."
else
puts "\nOperation cancelled. No files were deleted."
end

598
nvenc.py
View File

@@ -1,598 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
plugins.nvenc.py
Written by: Josh.5 <jsunnex@gmail.com>
Date: 27 Dec 2023, (11:21 AM)
Copyright:
Copyright (C) 2021 Josh Sunnex
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General
Public License as published by the Free Software Foundation, version 3.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the
implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
for more details.
You should have received a copy of the GNU General Public License along with this program.
If not, see <https://www.gnu.org/licenses/>.
"""
"""
Notes:
- Listing available encoder options:
ffmpeg -h encoder=h264_nvenc
ffmpeg -h encoder=hevc_nvenc
"""
import logging
import re
import subprocess
from video_transcoder.lib.encoders.base import Encoder
logger = logging.getLogger("Unmanic.Plugin.video_transcoder")
def list_available_cuda_devices():
"""
Return a list of available CUDA devices via nvidia-smi.
"""
gpu_dicts = []
try:
# Run the nvidia-smi command
result = subprocess.check_output(['nvidia-smi', '-L'], encoding='utf-8')
# Use regular expression to find device IDs, names, and UUIDs
gpu_info = re.findall(r'GPU (\d+): (.+) \(UUID: (.+)\)', result)
# Populate the list of dictionaries for each GPU
for gpu_id, gpu_name, gpu_uuid in gpu_info:
gpu_dicts.append({
'hwaccel_device': gpu_id,
'hwaccel_device_name': f"{gpu_name} (UUID: {gpu_uuid})",
})
except FileNotFoundError:
# nvidia-smi executable not found
return []
except subprocess.CalledProcessError:
# nvidia-smi command failed, likely no NVIDIA GPU present
return []
# Return the list of GPUs
return gpu_dicts
def get_configured_device(settings):
"""
Returns the currently configured device
Checks to ensure that the configured device exists and otherwise will return the first device available
:param settings:
:return:
"""
hardware_device = None
# Set the hardware device
hardware_devices = list_available_cuda_devices()
if not hardware_devices:
# Return no options. No hardware device was found
raise Exception("No NVIDIA device found")
# If we have configured a hardware device
if settings.get_setting('nvenc_device') not in ['none']:
# Attempt to match to that configured hardware device
for hw_device in hardware_devices:
if settings.get_setting('nvenc_device') == hw_device.get('hwaccel_device'):
hardware_device = hw_device
break
# If no matching hardware device is set, then select the first one
if not hardware_device:
hardware_device = hardware_devices[0]
return hardware_device
class NvencEncoder(Encoder):
def __init__(self, settings=None, probe=None):
super().__init__(settings=settings, probe=probe)
def _map_pix_fmt(self, is_h264: bool, is_10bit: bool) -> str:
if is_10bit and not is_h264:
return "p010le"
else:
return "nv12"
def provides(self):
return {
"h264_nvenc": {
"codec": "h264",
"label": "NVENC - h264_nvenc",
},
"hevc_nvenc": {
"codec": "hevc",
"label": "NVENC - hevc_nvenc",
},
"av1_nvenc": {
"codec": "av1",
"label": "NVENC - av1_nvenc",
},
}
def options(self):
return {
"nvenc_device": "none",
"nvenc_decoding_method": "cpu",
"nvenc_preset": "p4",
"nvenc_tune": "auto",
"nvenc_profile": "main",
"nvenc_encoder_ratecontrol_method": "auto",
"nvenc_encoder_ratecontrol_lookahead": 0,
"nvenc_enable_spatial_aq": False,
"nvenc_enable_temporal_aq": False,
"nvenc_aq_strength": 8,
}
def generate_default_args(self):
"""
Generate a list of args for using a NVENC decoder
REF: https://trac.ffmpeg.org/wiki/HWAccelIntro#NVDECCUVID
:return:
"""
hardware_device = get_configured_device(self.settings)
generic_kwargs = {}
advanced_kwargs = {}
# Check if we are using a HW accelerated decoder also
if self.settings.get_setting('nvenc_decoding_method') in ['cuda', 'nvdec', 'cuvid']:
generic_kwargs = {
"-hwaccel_device": hardware_device.get('hwaccel_device'),
"-hwaccel": self.settings.get_setting('nvenc_decoding_method'),
"-init_hw_device": "cuda=hw",
"-filter_hw_device": "hw",
}
if self.settings.get_setting('nvenc_decoding_method') in ['cuda', 'nvdec']:
generic_kwargs["-hwaccel_output_format"] = "cuda"
return generic_kwargs, advanced_kwargs
def generate_filtergraphs(self, current_filter_args, smart_filters, encoder_name):
"""
Generate the required filter for enabling NVENC/CUDA HW acceleration.
:return:
"""
generic_kwargs = {}
advanced_kwargs = {}
start_filter_args = []
end_filter_args = []
# Loop over any HW smart filters to be applied and add them as required.
hw_smart_filters = []
remaining_smart_filters = []
for sf in smart_filters:
if sf.get("scale"):
w = sf["scale"]["values"]["width"]
hw_smart_filters.append(f"scale_cuda={w}:-1")
else:
remaining_smart_filters.append(sf)
# Check for HW accelerated decode mode
# All decode methods ('cuda', 'nvdec', 'cuvid') are handled by the same
# filtergraph logic and output CUDA frames. The main FFmpeg command handles the specific decoder.
hw_decode = (self.settings.get_setting('nvenc_decoding_method') or '').lower() in ('cuda', 'nvdec', 'cuvid')
# Check software format to use
target_fmt = self._target_pix_fmt_for_encoder(encoder_name)
# Handle HDR
enc_supports_hdr = (encoder_name in ["hevc_nvenc", "av1_nvenc"])
target_color_config = self._target_color_config_for_encoder(encoder_name)
# If we have SW filters:
if remaining_smart_filters or current_filter_args:
# If we have SW filters and HW decode (CUDA/NVDEC) is enabled, make decoder produce SW frames
if hw_decode:
generic_kwargs['-hwaccel_output_format'] = target_fmt
# Add filter to upload software frames to CUDA for CUDA filters
# Note, format conversion (if any - eg yuv422p10le -> p010le) happens after the software filters.
# If a user applies a custom software filter that does not support the pix_fmt, then will need to prefix it with 'format=p010le'
chain = [f"format={target_fmt}"]
if enc_supports_hdr and target_color_config.get('apply_color_params'):
# Apply setparams filter if software filters exist (apply at the start of the filters list) to preserve HDR tags
chain.append(target_color_config['setparams_filter'])
chain += ["hwupload_cuda"]
end_filter_args.append(",".join(chain))
# If we have no software filters:
elif not hw_decode:
# CPU decode -> setparams (if HDR) -> upload to CUDA
chain = [f"format={target_fmt}"]
if enc_supports_hdr and target_color_config.get('apply_color_params'):
chain.append(target_color_config['setparams_filter'])
chain.append("hwupload_cuda")
start_filter_args.append(",".join(chain))
# Add the smart filters to the end
end_filter_args += hw_smart_filters
# Return built args
return {
"generic_kwargs": generic_kwargs,
"advanced_kwargs": advanced_kwargs,
"smart_filters": remaining_smart_filters,
"start_filter_args": start_filter_args,
"end_filter_args": end_filter_args,
}
def encoder_details(self, encoder):
hardware_devices = list_available_cuda_devices()
if not hardware_devices:
# Return no options. No hardware device was found
return {}
provides = self.provides()
return provides.get(encoder, {})
def stream_args(self, stream_info, stream_id, encoder_name):
generic_kwargs = {}
advanced_kwargs = {}
encoder_args = []
stream_args = []
# Specify the GPU to use for encoding
hardware_device = get_configured_device(self.settings)
stream_args += ['-gpu', str(hardware_device.get('hwaccel_device', '0'))]
# Handle HDR
enc_supports_hdr = (encoder_name in ["hevc_nvenc", "av1_nvenc"])
if enc_supports_hdr:
target_color_config = self._target_color_config_for_encoder(encoder_name)
else:
target_color_config = {
"apply_color_params": False
}
if enc_supports_hdr and target_color_config.get('apply_color_params'):
# Force Main10 profile
stream_args += [f'-profile:v:{stream_id}', 'main10']
# Use defaults for basic mode
if self.settings.get_setting('mode') in ['basic']:
# Read defaults
defaults = self.options()
if enc_supports_hdr and target_color_config.get('apply_color_params'):
# Add HDR color tags to the encoder output stream
for k, v in target_color_config.get('stream_color_params', {}).items():
stream_args += [k, v]
stream_args += ['-preset', str(defaults.get('nvenc_preset'))]
return {
"generic_kwargs": generic_kwargs,
"advanced_kwargs": advanced_kwargs,
"encoder_args": encoder_args,
"stream_args": stream_args,
}
# Add the preset and tune
if self.settings.get_setting('nvenc_preset'):
stream_args += ['-preset', str(self.settings.get_setting('nvenc_preset'))]
if self.settings.get_setting('nvenc_tune') and self.settings.get_setting('nvenc_tune') != 'auto':
stream_args += ['-tune', str(self.settings.get_setting('nvenc_tune'))]
if self.settings.get_setting('nvenc_profile') and self.settings.get_setting('nvenc_profile') != 'auto':
stream_args += [f'-profile:v:{stream_id}', str(self.settings.get_setting('nvenc_profile'))]
# Apply rate control config
if self.settings.get_setting('nvenc_encoder_ratecontrol_method') in ['constqp', 'vbr', 'cbr']:
# Set the rate control method
stream_args += [f'-rc:v:{stream_id}', str(self.settings.get_setting('nvenc_encoder_ratecontrol_method'))]
rc_la = int(self.settings.get_setting('nvenc_encoder_ratecontrol_lookahead') or 0)
if rc_la > 0:
stream_args += [f'-rc-lookahead:v:{stream_id}', str(rc_la)]
# Apply adaptive quantization
if self.settings.get_setting('nvenc_enable_spatial_aq'):
stream_args += ['-spatial-aq', '1']
if self.settings.get_setting('nvenc_enable_spatial_aq') or self.settings.get_setting('nvenc_enable_temporal_aq'):
stream_args += [f'-aq-strength:v:{stream_id}', str(self.settings.get_setting('nvenc_aq_strength'))]
if self.settings.get_setting('nvenc_enable_temporal_aq'):
stream_args += ['-temporal-aq', '1']
# If CUVID is enabled, return generic_kwargs
if (self.settings.get_setting('nvenc_decoding_method') or '').lower() in ['cuvid']:
in_codec = stream_info.get('codec_name', 'unknown_codec_name')
generic_kwargs = {f'-c:v:{stream_id}': f'{in_codec}_cuvid'}
# Add stream color args
if enc_supports_hdr and target_color_config.get('apply_color_params'):
# Add HDR color tags to the encoder output stream
for k, v in target_color_config.get('stream_color_params', {}).items():
stream_args += [k, v]
# Return built args
return {
"generic_kwargs": generic_kwargs,
"advanced_kwargs": advanced_kwargs,
"encoder_args": encoder_args,
"stream_args": stream_args,
}
def __set_default_option(self, select_options, key, default_option=None):
"""
Sets the default option if the currently set option is not available
:param select_options:
:param key:
:return:
"""
available_options = []
for option in select_options:
available_options.append(option.get('value'))
if not default_option:
default_option = option.get('value')
current_value = self.settings.get_setting(key)
if not getattr(self.settings, 'apply_default_fallbacks', True):
return current_value
if current_value not in available_options:
# Update in-memory setting for display only.
# IMPORTANT: do not persist settings from plugin.
# Only the Unmanic API calls should persist to JSON file.
self.settings.settings_configured[key] = default_option
return default_option
return current_value
def get_nvenc_device_form_settings(self):
values = {
"label": "NVIDIA Device",
"sub_setting": True,
"input_type": "select",
"select_options": [
{
"value": "none",
"label": "No NVIDIA devices available",
}
]
}
default_option = None
hardware_devices = list_available_cuda_devices()
if hardware_devices:
values['select_options'] = []
for hw_device in hardware_devices:
if not default_option:
default_option = hw_device.get('hwaccel_device', 'none')
values['select_options'].append({
"value": hw_device.get('hwaccel_device', 'none'),
"label": "NVIDIA device '{}'".format(hw_device.get('hwaccel_device_name', 'not found')),
})
if not default_option:
default_option = 'none'
self.__set_default_option(values['select_options'], 'nvenc_device', default_option=default_option)
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_decoding_method_form_settings(self):
values = {
"label": "Enable HW Decoding",
"description": "Warning: Ensure your device supports decoding the source video codec or it will fail.\n"
"This enables full hardware transcode with NVDEC and NVENC, using only GPU memory for the entire video transcode.\n"
"If filters are configured in the plugin, decoder will output NV12 software surfaces which are slightly slower.\n"
"Note: It is recommended that you disable this option for 10-bit encodes.",
"sub_setting": True,
"input_type": "select",
"select_options": [
{
"value": "cpu",
"label": "Disabled - Use CPU to decode of video source (provides best compatibility)",
},
{
"value": "cuda",
"label": "NVDEC/CUDA - Use the GPUs HW decoding the video source and upload surfaces to CUDA (recommended)",
},
{
"value": "cuvid",
"label": "CUVID - Older interface for HW video decoding. Kepler or older hardware may perform better with this option",
}
]
}
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_preset_form_settings(self):
values = {
"label": "Encoder quality preset",
"sub_setting": True,
"input_type": "select",
"select_options": [
{
"value": "p1",
"label": "Fastest (P1)",
},
{
"value": "p2",
"label": "Faster (P2)",
},
{
"value": "p3",
"label": "Fast (P3)",
},
{
"value": "p4",
"label": "Medium (P4) - Balanced performance and quality",
},
{
"value": "p5",
"label": "Slow (P5)",
},
{
"value": "p6",
"label": "Slower (P6)",
},
{
"value": "p7",
"label": "Slowest (P7)",
},
],
}
self.__set_default_option(values['select_options'], 'nvenc_preset', default_option='p4')
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_tune_form_settings(self):
values = {
"label": "Tune for a particular type of source or situation",
"sub_setting": True,
"input_type": "select",
"select_options": [
{
"value": "auto",
"label": "Disabled Do not apply any tune",
},
{
"value": "hq",
"label": "HQ High quality (ffmpeg default)",
},
{
"value": "ll",
"label": "LL Low latency",
},
{
"value": "ull",
"label": "ULL Ultra low latency",
},
{
"value": "lossless",
"label": "Lossless",
},
],
}
self.__set_default_option(values['select_options'], 'nvenc_tune', default_option='auto')
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_profile_form_settings(self):
values = {
"label": "Profile",
"description": "The profile determines which features of the codec are available and enabled,\n"
"while also affecting other restrictions.\n"
"Any of these profiles are capable of 4:2:0, 4:2:2 and 4:4:4, however the support\n"
"depends on the installed hardware.",
"sub_setting": True,
"input_type": "select",
"select_options": [
{
"value": "auto",
"label": "Auto Let ffmpeg automatically select the required profile (recommended)",
},
{
"value": "baseline",
"label": "Baseline",
},
{
"value": "main",
"label": "Main",
},
{
"value": "main10",
"label": "Main10",
},
{
"value": "high444p",
"label": "High444p",
},
],
}
self.__set_default_option(values['select_options'], 'nvenc_profile', default_option='main')
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_encoder_ratecontrol_method_form_settings(self):
values = {
"label": "Encoder ratecontrol method",
"description": "Note that the rate control is already defined in the Encoder Quality Preset option.\n"
"Selecting anything other than 'Disabled' will override the preset rate-control.",
"sub_setting": True,
"input_type": "select",
"select_options": [
{
"value": "auto",
"label": "Auto Use the rate control setting pre-defined in the preset option (recommended)",
},
{
"value": "constqp",
"label": "CQP - Quality based mode using constant quantizer scale",
},
{
"value": "vbr",
"label": "VBR - Bitrate based mode using variable bitrate",
},
{
"value": "cbr",
"label": "CBR - Bitrate based mode using constant bitrate",
},
]
}
self.__set_default_option(values['select_options'], 'nvenc_encoder_ratecontrol_method', default_option='auto')
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_encoder_ratecontrol_lookahead_form_settings(self):
# Lower is better
values = {
"label": "Configure the number of frames to look ahead for rate-control",
"sub_setting": True,
"input_type": "slider",
"slider_options": {
"min": 0,
"max": 30,
},
}
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
return values
def get_nvenc_enable_spatial_aq_form_settings(self):
values = {
"label": "Enable Spatial Adaptive Quantization",
"description": "This adjusts the quantization parameter within each frame based on spatial complexity.\n"
"This helps in improving the quality of areas within a frame that are more detailed or complex.",
"sub_setting": True,
}
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = 'hidden'
return values
def get_nvenc_enable_temporal_aq_form_settings(self):
values = {
"label": "Enable Temporal Adaptive Quantization",
"description": "This adjusts the quantization parameter across frames, based on the motion and temporal complexity.\n"
"This is particularly effective in scenes with varying levels of motion, enhancing quality where it's most needed.\n"
"This option requires Turing or newer hardware.",
"sub_setting": True,
}
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = 'hidden'
return values
def get_nvenc_aq_strength_form_settings(self):
# Lower is better
values = {
"label": "Strength of the adaptive quantization",
"description": "Controls the strength of the adaptive quantization (both spatial and temporal).\n"
"A higher value indicates stronger adaptation, which can lead to better preservation\n"
"of detail but might also increase the bitrate.",
"sub_setting": True,
"input_type": "slider",
"slider_options": {
"min": 0,
"max": 15,
},
}
if self.settings.get_setting('mode') not in ['standard']:
values["display"] = "hidden"
if not self.settings.get_setting('nvenc_enable_spatial_aq'):
values["display"] = "hidden"
return values

Submodule time_tracker deleted from 89e5e8a303

View File

@@ -1,288 +0,0 @@
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) }