230 lines
10 KiB
Python
230 lines
10 KiB
Python
# core/encode_engine.py
|
|
"""FFmpeg encoding engine with comprehensive logging."""
|
|
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from core.audio_handler import get_audio_streams, choose_audio_bitrate, filter_audio_streams, prompt_user_audio_selection, prompt_for_title_stripping
|
|
from core.logger_helper import setup_logger
|
|
|
|
logger = setup_logger(Path(__file__).parent.parent / "logs")
|
|
|
|
|
|
def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int,
|
|
src_width: int, src_height: int, filter_flags: str, audio_config: dict,
|
|
method: str, bitrate_config: dict, encoder: str = "nvenc", subtitle_files: list = None, audio_language: str = None,
|
|
audio_filter_config: dict = None, test_mode: bool = False, strip_all_titles: bool = False):
|
|
"""
|
|
Run FFmpeg encode with comprehensive logging.
|
|
|
|
Args:
|
|
strip_all_titles: If True, strip all title metadata from all audio tracks
|
|
|
|
Returns tuple: (orig_size, out_size, reduction_ratio)
|
|
"""
|
|
streams = get_audio_streams(input_file)
|
|
|
|
# Apply audio filter if enabled
|
|
if audio_filter_config and audio_filter_config.get("enabled", False):
|
|
# Check if pre-selected streams provided
|
|
if audio_filter_config.get("preselected"):
|
|
# Use pre-selected streams (skip interactive)
|
|
preselected_str = audio_filter_config["preselected"]
|
|
try:
|
|
selected_indices = set()
|
|
for part in preselected_str.split(","):
|
|
idx = int(part.strip())
|
|
selected_indices.add(idx)
|
|
# Filter to only selected streams
|
|
streams = [s for s in streams if s[0] in selected_indices]
|
|
# Add strip_title field (False by default for pre-selected)
|
|
streams = [s + (False,) for s in streams]
|
|
logger.info(f"Pre-selected audio streams: {[s[0] for s in streams]}")
|
|
except ValueError:
|
|
logger.warning(f"Invalid audio_select format: {preselected_str}. Using all streams.")
|
|
streams = [s + (False,) for s in streams]
|
|
else:
|
|
# Check if interactive mode requested (via --filter-audio CLI flag)
|
|
# If audio_filter_config came from CLI, it has "interactive": True
|
|
if "interactive" in audio_filter_config and audio_filter_config.get("interactive", False):
|
|
# Interactive audio selection (show prompt to user)
|
|
streams = prompt_user_audio_selection(streams)
|
|
# Prompt for title stripping after stream selection
|
|
streams = prompt_for_title_stripping(streams)
|
|
else:
|
|
# Automatic filtering from config (keep best English + Commentary)
|
|
streams = filter_audio_streams(input_file, streams)
|
|
# Add strip_title field (False by default for automatic filtering)
|
|
streams = [s + (False,) for s in streams]
|
|
else:
|
|
# No filtering - add strip_title field as False
|
|
streams = [s + (False,) for s in streams]
|
|
|
|
# Log comprehensive encode settings
|
|
header = f"\n🧩 ENCODE SETTINGS"
|
|
logger.info(header)
|
|
print(" ")
|
|
|
|
# Determine encoder display name and settings
|
|
if encoder == "av1":
|
|
encoder_name = "AV1 NVENC"
|
|
encoder_codec = "av1_nvenc"
|
|
encoder_preset = "p7"
|
|
encoder_pix_fmt = "yuv420p"
|
|
encoder_bit_depth = "8-bit"
|
|
else: # default hevc = HEVC NVENC
|
|
encoder_name = "HEVC NVENC"
|
|
encoder_codec = "hevc_nvenc"
|
|
encoder_preset = "slow"
|
|
encoder_pix_fmt = "p010le"
|
|
encoder_bit_depth = "10-bit"
|
|
|
|
logger.info(f" Video:")
|
|
logger.info(f" • Source Resolution: {src_width}x{src_height}")
|
|
logger.info(f" • Target Resolution: {scale_width}x{scale_height}")
|
|
logger.info(f" • Encoder: {encoder_name} (preset {encoder_preset}, {encoder_bit_depth}, pix_fmt {encoder_pix_fmt})")
|
|
logger.info(f" • Scale Filter: {filter_flags}")
|
|
logger.info(f" • Encode Method: {method}")
|
|
if method == "CQ":
|
|
logger.info(f" • CQ Value: {cq}")
|
|
else:
|
|
res_key = "1080" if scale_height >= 1080 or scale_width >= 1920 else "720"
|
|
vb = bitrate_config.get(f"bitrate_{res_key}", "900k")
|
|
maxrate = bitrate_config.get(f"maxrate_{res_key}", "1250k")
|
|
logger.info(f" • Bitrate: {vb}, Max: {maxrate}")
|
|
|
|
logger.info(f" Audio Streams ({len(streams)} detected):")
|
|
print(" ")
|
|
|
|
for (index, channels, avg_bitrate, src_lang, meta_bitrate, title, strip_title) in streams:
|
|
# Normalize to 2ch or 6ch output
|
|
is_1080_class = scale_height >= 1080 or scale_width >= 1920
|
|
output_channels = 6 if is_1080_class and channels >= 6 else 2
|
|
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
|
|
|
if codec == "copy":
|
|
action = "COPY (preserve)"
|
|
bitrate_display = f"{avg_bitrate}kbps"
|
|
else:
|
|
action = "ENCODE"
|
|
bitrate_display = f"{br/1000:.0f}kbps"
|
|
|
|
# Include title in display if present
|
|
title_info = f" | Title: {title}" if title else ""
|
|
line = f" - Stream #{index}: {channels}ch→{output_channels}ch | Lang: {src_lang} | Detected: {avg_bitrate}kbps | Action: {action} | Target: {bitrate_display}{title_info}"
|
|
print(line)
|
|
logger.info(line)
|
|
|
|
cmd = ["ffmpeg","-y","-i",str(input_file)]
|
|
|
|
# Add subtitle inputs if present
|
|
if subtitle_files:
|
|
for sub_file in subtitle_files:
|
|
cmd.extend(["-i", str(sub_file)])
|
|
|
|
# In test mode, only encode first 15 minutes
|
|
if test_mode:
|
|
cmd.extend(["-t", "900"]) # 900 seconds = 15 minutes
|
|
|
|
cmd.extend([
|
|
"-vf",f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease",
|
|
"-map","0:v"])
|
|
|
|
# Map only selected audio streams
|
|
for index, _, _, _, _, _, _ in streams:
|
|
cmd.extend(["-map", f"0:{index}"])
|
|
|
|
# Add subtitle mapping if present
|
|
if subtitle_files:
|
|
for i, _ in enumerate(subtitle_files):
|
|
cmd.extend(["-map", f"{i+1}:s"])
|
|
else:
|
|
cmd.extend(["-map", "0:s?"])
|
|
|
|
cmd.extend([
|
|
"-c:v", encoder_codec, "-preset", encoder_preset, "-pix_fmt", encoder_pix_fmt])
|
|
|
|
if method=="CQ":
|
|
cmd += ["-cq", str(cq)]
|
|
else:
|
|
# Use bitrate config (fallback mode)
|
|
res_key = "1080" if scale_height >= 1080 or scale_width >= 1920 else "720"
|
|
vb = bitrate_config.get(f"bitrate_{res_key}", "900k")
|
|
maxrate = bitrate_config.get(f"maxrate_{res_key}", "1250k")
|
|
bufsize = bitrate_config.get(f"bufsize_{res_key}", "1800k")
|
|
cmd += ["-b:v", vb, "-maxrate", maxrate, "-bufsize", bufsize]
|
|
|
|
for i, (index, channels, avg_bitrate, src_lang, meta_bitrate, title, strip_title) in enumerate(streams):
|
|
# Normalize to 2ch or 6ch output
|
|
is_1080_class = scale_height >= 1080 or scale_width >= 1920
|
|
output_channels = 6 if is_1080_class and channels >= 6 else 2
|
|
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
|
|
|
if codec == "copy":
|
|
# Preserve original audio
|
|
cmd += [f"-c:a:{i}", "copy"]
|
|
# Only add language metadata if explicitly provided
|
|
if audio_language:
|
|
cmd += [f"-metadata:s:a:{i}", f"language={audio_language}"]
|
|
# Strip title metadata if requested (but preserve commentary tracks)
|
|
should_strip = strip_title or (strip_all_titles and not (title and "commentary" in title.lower()))
|
|
if should_strip:
|
|
cmd += [f"-metadata:s:a:{i}", "title="]
|
|
else:
|
|
# Re-encode with target bitrate
|
|
# EAC3 for multichannel, AAC for stereo
|
|
if codec == "eac3":
|
|
# Enhanced AC-3 (5.1 surround)
|
|
cmd += [
|
|
f"-c:a:{i}", "eac3",
|
|
f"-b:a:{i}", str(br),
|
|
f"-ac:{i}", str(output_channels),
|
|
f"-channel_layout:a:{i}", "5.1"
|
|
]
|
|
else:
|
|
# AAC (stereo)
|
|
cmd += [
|
|
f"-c:a:{i}", "aac",
|
|
f"-b:a:{i}", str(br),
|
|
f"-ac:{i}", str(output_channels),
|
|
f"-channel_layout:a:{i}", "stereo"
|
|
]
|
|
# Only add language metadata if explicitly provided
|
|
if audio_language:
|
|
cmd += [f"-metadata:s:a:{i}", f"language={audio_language}"]
|
|
# Strip title metadata if requested (but preserve commentary tracks)
|
|
should_strip = strip_title or (strip_all_titles and not (title and "commentary" in title.lower()))
|
|
if should_strip:
|
|
cmd += [f"-metadata:s:a:{i}", "title="]
|
|
# Add subtitle codec and metadata if subtitles are present
|
|
if subtitle_files:
|
|
cmd += ["-c:s", "srt"]
|
|
for i in range(len(subtitle_files)):
|
|
cmd += ["-metadata:s:s:" + str(i), "language=eng"]
|
|
else:
|
|
cmd += ["-c:s", "copy"]
|
|
|
|
cmd += [str(output_file)]
|
|
|
|
print(f"\n🎬 Running {method} encode: {output_file.name}")
|
|
logger.info(f"Running {method} encode: {output_file.name}")
|
|
|
|
subprocess.run(cmd, check=True)
|
|
|
|
orig_size = input_file.stat().st_size
|
|
out_size = output_file.stat().st_size
|
|
reduction_ratio = out_size / orig_size
|
|
|
|
# Log comprehensive results
|
|
logger.info(f"\n📊 ENCODE RESULTS:")
|
|
logger.info(f" Original Size: {orig_size/1e6:.2f} MB")
|
|
logger.info(f" Encoded Size: {out_size/1e6:.2f} MB")
|
|
logger.info(f" Reduction: {reduction_ratio:.1%} of original ({(1-reduction_ratio):.1%} saved)")
|
|
logger.info(f" Resolution: {src_width}x{src_height} → {scale_width}x{scale_height}")
|
|
logger.info(f" Audio Streams: {len(streams)} streams processed")
|
|
|
|
msg = f"📦 Original: {orig_size/1e6:.2f} MB → Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)"
|
|
print(msg)
|
|
|
|
return orig_size, out_size, reduction_ratio
|