conversion_project/core/encode_engine.py
TylerCG 8d3aa03d72 v1.02a
added message cleanup and some better logic
2026-02-22 22:24:07 -05:00

336 lines
15 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, src_bit_depth: int = None, unforce_subs: bool = False, no_encode: bool = False):
"""
Execute FFmpeg encoding/re-muxing with structured console output.
Args:
input_file: Path to source video file
output_file: Path for encoded output file
cq: Quality value (0-63, lower=better) for CQ mode
scale_width/height: Target resolution dimensions
src_width/height: Source resolution dimensions
filter_flags: Scaling filter algorithm (lanczos, bicubic, etc)
audio_config: Audio bitrate configuration dict
method: Encoding method - "CQ" or "Bitrate"
bitrate_config: Bitrate/maxrate/bufsize configuration dict
encoder: Video codec - "hevc", "av1", or "nvenc"
subtitle_files: List of external subtitle file paths (if any)
audio_language: ISO 639-2 language code to tag audio (e.g., "eng", "spa")
audio_filter_config: Audio filtering/selection configuration
test_mode: If True, only encode first 15 minutes, don't move files
strip_all_titles: If True, strip title metadata from all audio tracks
src_bit_depth: Source bit depth (8/10/12) for encoder auto-selection
unforce_subs: If True, remove forced flag from subtitle tracks
no_encode: If True, copy video/audio (re-mux only, skip encoding)
Returns:
tuple: (orig_size_bytes, output_size_bytes, 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]
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.")
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)
# Determine encoder display name and settings
if encoder == "av1":
encoder_name = "AV1 NVENC"
encoder_codec = "av1_nvenc"
encoder_preset = "p7" # p7 = fastest/lower quality (0-7 scale)
encoder_pix_fmt = "yuv420p"
encoder_bit_depth = "8-bit"
else: # default hevc = HEVC NVENC
encoder_name = "HEVC NVENC"
encoder_codec = "hevc_nvenc"
encoder_preset = "p7" # p7 = fastest/lower quality (0-7 scale)
encoder_pix_fmt = "p010le"
encoder_bit_depth = "10-bit"
# Auto-select encoder based on detected source bit depth if provided
if src_bit_depth is not None:
if src_bit_depth >= 10:
# Source is 10-bit or higher - use HEVC NVENC
encoder_name = "HEVC NVENC"
encoder_codec = "hevc_nvenc"
encoder_preset = "p7"
encoder_pix_fmt = "p010le"
encoder_bit_depth = "10-bit"
logger.info(f"Auto-selected HEVC NVENC for detected {src_bit_depth}-bit source")
else:
# Source is 8-bit - use AV1 NVENC
encoder_name = "AV1 NVENC"
encoder_codec = "av1_nvenc"
encoder_preset = "p7"
encoder_pix_fmt = "yuv420p"
encoder_bit_depth = "8-bit"
logger.info(f"Auto-selected AV1 NVENC for detected {src_bit_depth}-bit source")
# Debug: log audio_language received
logger.debug(f"audio_language parameter: {audio_language}")
# Build simple console summary
audio_summary_lines = []
for (index, channels, avg_bitrate, src_lang, meta_bitrate, title, codec_name) 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"
output_codec = codec_name
output_bitrate = f"{avg_bitrate}kbps"
else:
action = "ENC"
# Determine output codec based on encode choice
output_codec = "EAC3" if codec == "eac3" else "AAC"
output_bitrate = f"{br/1000:.0f}kbps"
# Show language change if audio_language is set
lang_info = f"{src_lang}{audio_language}" if audio_language else src_lang
# Include title in display if present
title_info = f" [{title}]" if title else ""
line = f" - Stream #{index}: {channels}ch→{output_channels}ch | {lang_info} | Detected: {codec_name} {avg_bitrate}kbps | Output: {output_codec} {output_bitrate} ({action}){title_info}"
audio_summary_lines.append(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
# Only add scale filter if encoding (not copying)
if not no_encode:
cmd.extend([
"-vf",f"scale={scale_width}:{scale_height}:flags={filter_flags}:force_original_aspect_ratio=decrease"])
cmd.extend(["-map","0:v:0"]) # Map only first actual video stream (skips attached pictures)
# 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?"])
# Video codec: copy if no_encode, otherwise use specified encoder
if no_encode:
cmd.extend(["-c:v", "copy"])
else:
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, codec_name) 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
# If no_encode is True, always copy audio
if no_encode:
codec, br = "copy", avg_bitrate
else:
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
# Check if title should be stripped (for this stream or globally)
# Preserve any stream with "commentary" in the title, regardless of strip_all_titles
is_commentary = title and "commentary" in title.lower()
should_strip = strip_all_titles and not is_commentary
# Log title stripping decisions for debugging (debug level, not info)
logger.debug(f"Stream {index}: title='{title}', is_commentary={is_commentary}, strip_all_titles={strip_all_titles}, should_strip={should_strip}")
if strip_all_titles and is_commentary:
logger.debug(f"Stream {index}: ✓ Preserving title '{title}' (contains 'commentary')")
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)
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)
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"]
if unforce_subs:
cmd += ["-disposition:s:" + str(i), "-forced"]
else:
cmd += ["-c:s", "copy"]
# For embedded subtitles, still apply -disposition if unforce_subs is enabled
if unforce_subs:
# Apply to all embedded subtitle streams
cmd += ["-disposition:s", "-forced"]
cmd += [str(output_file)]
# Print detailed console output with VIDEO and AUDIO sections
print(f"\n🎬 Encoding: {output_file.name}")
# VIDEO SECTION
print(f"📹 VIDEO")
# Build resolution and bit depth info
detected_bit = f" {src_bit_depth}-bit" if src_bit_depth else ""
output_bit = f" {encoder_bit_depth}"
if scale_width != src_width or scale_height != src_height:
res_info = f"Detected: {src_width}x{src_height}{detected_bit} | Output: {scale_width}x{scale_height}{output_bit}"
else:
res_info = f"Detected: {src_width}x{src_height}{detected_bit} | Output: {scale_width}x{scale_height}{output_bit}"
cq_info = f"CQ {cq}" if method == "CQ" else f"VBR {bitrate_config.get('bitrate_1080', '900k')}"
test_str = " [TEST 15min]" if test_mode else ""
print(f" {res_info} | {encoder_name} preset {encoder_preset} | {cq_info}{test_str}")
# AUDIO SECTION
print(f"🔊 AUDIO")
for line in audio_summary_lines:
print(line)
logger.debug(f"Running {method} encode: {output_file.name}")
# Run FFmpeg with stderr/stdout captured (hide version/config info)
process = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# Print progress section header
print(f"\n⏳ PROGRESS")
# Read output line by line but only print progress-related lines
ffmpeg_log = []
import re
for line in process.stdout:
ffmpeg_log.append(line.rstrip())
# Only print progress lines (frame= indicates encoding progress)
if "frame=" in line:
# Extract key metrics: time, bitrate, and elapsed
time_match = re.search(r'time=(\S+)', line)
bitrate_match = re.search(r'bitrate=(\S+)', line)
elapsed_match = re.search(r'elapsed=(\S+)', line)
time_str = time_match.group(1) if time_match else "00:00:00"
bitrate_str = bitrate_match.group(1) if bitrate_match else "0kbps"
elapsed_str = elapsed_match.group(1) if elapsed_match else "0:00:00"
# Print with carriage return to update same line (no newline, use \r to go back to start)
print(f"\r {time_str} | {bitrate_str} | elapsed={elapsed_str}", end='', flush=True)
print() # Newline after encoding completes
returncode = process.wait()
if returncode != 0:
# Log full FFmpeg output if there was an error
logger.error("FFmpeg output (full):")
for line in ffmpeg_log:
logger.error(line)
raise subprocess.CalledProcessError(returncode, cmd)
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