diff --git a/delete_av1.rb b/delete_av1.rb deleted file mode 100644 index b5c9007..0000000 --- a/delete_av1.rb +++ /dev/null @@ -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 diff --git a/nvenc.py b/nvenc.py deleted file mode 100644 index 07eb37e..0000000 --- a/nvenc.py +++ /dev/null @@ -1,598 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- - -""" - plugins.nvenc.py - - Written by: Josh.5 - 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 . - -""" -""" -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 diff --git a/time_tracker b/time_tracker deleted file mode 160000 index 89e5e8a..0000000 --- a/time_tracker +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 89e5e8a30345080dd9586f97d7ff78498fb49346 diff --git a/video_optimizer.rb b/video_optimizer.rb deleted file mode 100644 index 24c3a63..0000000 --- a/video_optimizer.rb +++ /dev/null @@ -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) }