189 lines
7.3 KiB
Python
189 lines
7.3 KiB
Python
#!/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()
|