#!/usr/bin/env python3 import argparse import json import shutil import subprocess from pathlib import Path from core.config_helper import load_config_xml # ============================= # AUDIO BUCKET LOGIC # ============================= def choose_audio_bitrate(channels: int, bitrate_kbps: int, audio_config: dict) -> int: if channels == 2: if bitrate_kbps < 80: return audio_config["stereo"]["low"] elif bitrate_kbps < 112: return audio_config["stereo"]["medium"] else: return audio_config["stereo"]["high"] else: if bitrate_kbps < 176: return audio_config["multi_channel"]["low"] else: return audio_config["multi_channel"]["high"] # ============================= # AUDIO STREAM DETECTION # ============================= def get_audio_streams(input_file: Path): cmd = [ "ffprobe", "-v", "error", "-select_streams", "a", "-show_entries", "stream=index,channels,bit_rate", "-of", "json", str(input_file) ] result = subprocess.run(cmd, capture_output=True, text=True) data = json.loads(result.stdout) streams = [] for s in data.get("streams", []): index = s["index"] channels = s.get("channels", 2) bitrate = int(int(s.get("bit_rate", 128000)) / 1000) streams.append((index, channels, bitrate)) return streams # ============================= # FFmpeg ENCODE # ============================= def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int, filter_flags: str, audio_config: dict, encode_config: dict): streams = get_audio_streams(input_file) print("\n🧩 ENCODE SETTINGS") print(f" β€’ Resolution: {scale_width}x{scale_height}") print(f" β€’ Scale Filter: {filter_flags}") print(f" β€’ CQ: {cq}") print(f" β€’ Video Encoder: av1_nvenc (preset p1, pix_fmt p010le)") print(" β€’ Audio Streams:") for (index, channels, bitrate) in streams: br = choose_audio_bitrate(channels, bitrate, audio_config) print(f" - Stream #{index}: {channels}ch, origβ‰ˆ{bitrate}kbps β†’ target {br/1000:.1f}kbps") # Build CQ encode command cmd = [ "ffmpeg", "-y", "-i", str(input_file), "-vf", f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease", "-map", "0:v", "-map", "0:a", "-map", "0:s?", "-c:v", "av1_nvenc", "-preset", "p1", "-cq", str(cq), "-pix_fmt", "p010le" ] for i, (index, channels, bitrate) in enumerate(streams): br = choose_audio_bitrate(channels, bitrate, audio_config) cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(channels)] cmd += ["-c:s", "copy", str(output_file)] subprocess.run(cmd, check=True) # Check size reduction orig_size = input_file.stat().st_size out_size = output_file.stat().st_size reduction_ratio = out_size / orig_size print(f"πŸ“¦ Original: {orig_size/1e6:.2f} MB β†’ Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)") # Fallback if too large if reduction_ratio >= 0.5: print(f"⚠️ Size reduction insufficient ({reduction_ratio:.0%}). Retrying with bitrate-based encode...") output_file.unlink(missing_ok=True) if scale_height >= 1080: vb, maxrate, bufsize = encode_config["fallback"]["bitrate_1080"], encode_config["fallback"]["maxrate_1080"], encode_config["fallback"]["bufsize_1080"] else: vb, maxrate, bufsize = encode_config["fallback"]["bitrate_720"], encode_config["fallback"]["maxrate_720"], encode_config["fallback"]["bufsize_720"] cmd = [ "ffmpeg", "-y", "-i", str(input_file), "-vf", f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease", "-map", "0:v", "-map", "0:a", "-map", "0:s?", "-c:v", "av1_nvenc", "-preset", "p1", "-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize, "-pix_fmt", "p010le" ] for i, (index, channels, bitrate) in enumerate(streams): br = choose_audio_bitrate(channels, bitrate, audio_config) cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", str(br), f"-ac:{i}", str(channels)] cmd += ["-c:s", "copy", str(output_file)] subprocess.run(cmd, check=True) # ============================= # PROCESS FOLDER # ============================= def process_folder(folder: Path, cq: int, resolution: str, processing_folder: Path, config: dict): if not folder.exists(): print(f"❌ Folder not found: {folder}") return filter_flags_default = config["encode"]["filters"]["default"] filter_flags_tv = config["encode"]["filters"]["tv"] folder_lower = str(folder).lower() if "\\tv\\" in folder_lower or "/tv/" in folder_lower: filter_flags = filter_flags_tv cq_default_key = f"tv_{resolution}" else: filter_flags = filter_flags_default cq_default_key = f"movie_{resolution}" if cq is None: cq = config["encode"]["cq"][cq_default_key] res_height = 1080 if resolution == "1080" else 720 res_width = 1920 if resolution == "1080" else 1280 processing_folder.mkdir(parents=True, exist_ok=True) for file in folder.rglob("*"): if file.suffix.lower() not in config["extensions"]: continue if any(tag in file.name.lower() for tag in ["ehx", "megusta"]): print(f"⏭️ Skipping: {file.name}") continue print("="*60) print(f"πŸ“ Processing: {file.name}") temp_input = processing_folder / file.name shutil.copy2(file, temp_input) temp_output = processing_folder / f"{file.stem}{config['suffix']}{file.suffix}" run_ffmpeg(temp_input, temp_output, cq, res_width, res_height, filter_flags, config["audio"], config["encode"]) dest_file = file.parent / temp_output.name shutil.move(temp_output, dest_file) print(f"🚚 Moved {temp_output.name} β†’ {dest_file.name}") # Cleanup if dest_file.exists(): try: temp_input.unlink() file.unlink() print(f"🧹 Deleted original and processing copy") except Exception as e: print(f"⚠️ Could not delete files: {e}") # ============================= # MAIN # ============================= def main(): parser = argparse.ArgumentParser(description="Batch encode videos") parser.add_argument("folder", help="Path to folder containing videos") parser.add_argument("--cq", type=int, help="Override default CQ") parser.add_argument("--r", "--resolution", dest="resolution", choices=["720","1080"], help="Target resolution (720 or 1080)") parser.add_argument("--processing", type=str, help="Processing folder") args = parser.parse_args() config_path = Path(__file__).parent / "config.xml" config = load_config_xml(config_path) cq = args.cq if args.cq is not None else None resolution = args.resolution if args.resolution else "1080" processing_folder = Path(args.processing) if args.processing else Path(config["processing_folder"]) process_folder(Path(args.folder), cq, resolution, processing_folder, config) if __name__ == "__main__": main()