conversion_project/core/encode_engine.py
2026-08-22 21:01:42 -04:00

769 lines
36 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.video_handler import calculate_crop_dimensions, get_subtitle_stream_codecs
from core.logger_helper import setup_logger
from core.black_frame_detector import BlackFrameDetector, BlackFrameAnalyzer, BlackFrameDetectionException
logger = setup_logger(Path(__file__).parent.parent / "logs")
def check_encoder_available(encoder_codec: str) -> bool:
"""
Check if FFmpeg encoder is available and working.
Args:
encoder_codec: FFmpeg encoder name (e.g., 'hevc_nvenc', 'av1_nvenc', 'libx265')
Returns:
True if encoder is available and functional, False otherwise
"""
try:
# First check if encoder is listed in ffmpeg's available encoders
list_cmd = ["ffmpeg", "-hide_banner", "-encoders"]
list_result = subprocess.run(list_cmd, capture_output=True, text=True, timeout=5)
# Look for the encoder in the output (format: " encoder_name" with leading space)
if f" {encoder_codec} " in list_result.stdout or f" {encoder_codec}\n" in list_result.stdout:
logger.debug(f"Encoder {encoder_codec} found in available encoders list")
return True
else:
logger.debug(f"Encoder {encoder_codec} NOT found in available encoders list")
logger.debug(f"Available encoders output snippet: {list_result.stdout[:500]}")
return False
except Exception as e:
logger.debug(f"Encoder check failed for {encoder_codec}: {e}")
return False
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, color_bit: int = None, crop_height: int = None, audio_titles: dict = None, audio_channels: dict = None, is_hdr: bool = False, skip_audio_check: bool = False, use_remux_for_subs: 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)
color_bit: If specified (8 or 10), forces HEVC color bit depth. 8-bit uses yuv420p, 10-bit uses p010le.
use_remux_for_subs: If True, add subtitles in a separate remux pass (for PGS handling)
crop_height: If specified, crop video to this height (centered). E.g., 816 for 1920x816 from 1920x1080 source.
audio_titles: Dict mapping stream index to custom title. E.g., {1: "Commentary"} sets stream 1 title to "Commentary".
audio_channels: Dict mapping stream index to channel count. E.g., {0: 2, 1: 6} forces track 0 to stereo, track 1 to 5.1. Only 2 or 6 allowed.
is_hdr: If True, source is HDR content (applies HDR color profiles)
skip_audio_check: If True, skip audio bitrate calculation (uses metadata instead, speeds up testing)
Returns:
tuple: (orig_size_bytes, output_size_bytes, reduction_ratio)
"""
streams = get_audio_streams(input_file, skip_audio_check=skip_audio_check)
# 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"
print(f"📺 Video Encoder: {encoder_name} (NVIDIA AV1)")
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"
print(f"📺 Video Encoder: {encoder_name} (NVIDIA HEVC)")
# Handle --color-bit override if specified (only for HEVC)
if color_bit is not None and encoder == "hevc":
if color_bit == 8:
encoder_pix_fmt = "yuv420p"
encoder_bit_depth = "8-bit"
logger.info(f"Using --color-bit {color_bit}: HEVC NVENC 8-bit (yuv420p)")
elif color_bit == 10:
encoder_pix_fmt = "p010le"
encoder_bit_depth = "10-bit"
logger.info(f"Using --color-bit {color_bit}: HEVC NVENC 10-bit (p010le)")
# Auto-select encoder based on detected source bit depth only if user didn't explicitly specify one
# (this only runs if encoder is None, otherwise user selection takes precedence)
elif encoder is None and src_bit_depth is not None and color_bit is 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")
# Check if selected NVIDIA encoder is available, fallback to CPU if not
if not no_encode and encoder_codec.endswith("_nvenc"):
logger.info(f"Checking availability of {encoder_codec}...")
if not check_encoder_available(encoder_codec):
logger.warning(f"NVIDIA encoder {encoder_codec} not available, falling back to CPU encoder")
print(f"⚠️ NVIDIA {encoder_name} unavailable, using CPU encoder instead")
# Fallback to CPU encoders
if src_bit_depth and src_bit_depth >= 10:
# Use libx265 with 10-bit for 10-bit sources
encoder_name = "x265 (10-bit CPU)"
encoder_codec = "libx265"
encoder_preset = "medium" # CPU is slower, use medium preset
encoder_pix_fmt = "p010le"
encoder_bit_depth = "10-bit"
print(f"📺 Fallback: x265 10-bit CPU encoder")
else:
# Use libx264 for 8-bit sources (faster CPU encoder)
encoder_name = "x264 (8-bit CPU)"
encoder_codec = "libx264"
encoder_preset = "medium"
encoder_pix_fmt = "yuv420p"
encoder_bit_depth = "8-bit"
print(f"📺 Fallback: x264 8-bit CPU encoder")
logger.info(f"Fallback encoder selected: {encoder_codec}")
else:
logger.info(f"{encoder_name} ({encoder_codec}) is available")
print(f"{encoder_name} is available")
# 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:
# Determine final title (considering custom titles override)
final_title = audio_titles.get(index, title) if audio_titles else title
# Check if this is a commentary track (original or custom title)
is_commentary = final_title and "commentary" in final_title.lower()
# Determine resolution string for audio bitrate selection
# 4K is defined as width >= 3840 OR height >= 2160 (accounts for different 4K formats like 3840x1606)
if scale_width >= 3840 or scale_height >= 2160:
resolution_str = "2160"
elif scale_height >= 1080 or scale_width >= 1920:
resolution_str = "1080"
else:
resolution_str = "720"
# Determine output channels: audio_channels override takes precedence
is_1080_class = scale_height >= 1080 or scale_width >= 1920
if audio_channels and index in audio_channels:
# User explicitly specified channel count for this stream
output_channels = audio_channels[index]
channels_override = True
elif is_commentary:
output_channels = 2 # Commentary always stereo
channels_override = False
else:
# Apply resolution-based channel clamping (same as choose_audio_bitrate)
if resolution_str == "2160":
max_channels = 8
elif resolution_str == "1080":
max_channels = 6
else:
max_channels = 2
output_channels = min(channels, max_channels)
channels_override = False
codec, br, final_channels = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class, is_commentary, resolution_str, codec_name)
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 = "Opus" if codec == "opus" else ("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" [{final_title}]" if final_title else ""
# Add override note if channels were forced
override_note = " [FORCED]" if channels_override else ""
line = f" - Stream #{index}: {channels}ch->{final_channels}ch | {lang_info} | Detected: {codec_name} {avg_bitrate}kbps | Output: {output_codec} {output_bitrate} ({action}){title_info}{override_note}"
audio_summary_lines.append(line)
# In test mode, create a 15-minute copy first for accurate file size comparison
test_preview_file = None
actual_input_file = input_file
if test_mode:
test_preview_file = input_file.parent / f"{input_file.stem}-copy{input_file.suffix}"
logger.info(f"Test mode: Creating 15-minute preview copy at {test_preview_file}")
print(f"📋 Test mode: Creating 15-minute preview copy for size comparison...")
# Create the preview copy using ffmpeg -c copy (stream copy, no re-encoding)
copy_cmd = [
"ffmpeg", "-y", "-i", str(input_file),
"-t", "900", # 900 seconds = 15 minutes
"-c", "copy", # Copy all streams without re-encoding
"-map", "0", # Map all streams
str(test_preview_file)
]
copy_process = subprocess.Popen(
copy_cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1
)
# Silently read output (don't print it)
for line in copy_process.stdout:
pass
if copy_process.wait() != 0:
logger.error(f"Failed to create test preview copy")
raise subprocess.CalledProcessError(1, copy_cmd)
logger.info(f"Test preview copy created: {test_preview_file.stat().st_size / 1e6:.2f} MB")
actual_input_file = test_preview_file
cmd = ["ffmpeg","-y","-i",str(actual_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, already limited to 15 minutes from the preview copy
if test_mode:
# Preview file is already 15 minutes, so don't add -t flag again
pass
# Build video filters (crop and/or scale)
video_filters = []
# Adjust target dimensions if cropping is applied
actual_scale_height = scale_height
actual_scale_width = scale_width
if crop_height and not no_encode:
# When cropping for letterbox removal, keep width the same and use crop height
# The crop filter (crop=in_w:height...) already keeps the full width
actual_scale_height = crop_height
actual_scale_width = scale_width # Keep original width, don't recalculate
# Add crop filter first (if specified)
if crop_height and not no_encode:
crop_dims = calculate_crop_dimensions(src_height, crop_height)
if crop_dims["ffmpeg_filter"]:
video_filters.append(crop_dims["ffmpeg_filter"])
print(f"[INFO] Applying crop: {crop_dims['ffmpeg_filter']} ({src_height}p -> {crop_height}p)")
# Add scale filter (if encoding, not copying)
if not no_encode:
# After cropping, scale to target while maintaining 1:1 SAR
video_filters.append(f"scale={actual_scale_width}:{actual_scale_height}:flags={filter_flags},setsar=1:1")
# Note: Color space handling
# The setparams filter is not universally supported across all FFmpeg builds
# and can cause compatibility issues with certain encoder configurations.
# Modern FFmpeg/NVENC encoders preserve color space from source automatically,
# so explicit color space configuration is not required for proper output quality.
if is_hdr:
logger.info("HDR content detected: Source color space will be preserved by encoder")
else:
logger.info("SDR content: Source color space will be preserved by encoder")
# Combine all filters with commas (ffmpeg filter chain syntax)
if video_filters:
filter_chain = ",".join(video_filters)
cmd.extend(["-vf", filter_chain])
cmd.extend(["-map","0:v:0"]) # Map only first actual video stream (skips attached pictures)
# Build audio filters for streams that need re-encoding with channel layout conversion
# This is needed when downmixing (e.g., 7.1 TrueHD to 5.1 EAC3) to ensure proper channel remixing
audio_filters_list = []
for i, (index, channels, avg_bitrate, src_lang, meta_bitrate, title, codec_name) in enumerate(streams):
# Determine output channels
final_title = audio_titles.get(index, title) if audio_titles else title
is_commentary = final_title and "commentary" in final_title.lower()
is_1080_class = scale_height >= 1080 or scale_width >= 1920
# Determine resolution string for audio bitrate selection
if scale_width >= 3840 or scale_height >= 2160:
resolution_str = "2160"
elif scale_height >= 1080 or scale_width >= 1920:
resolution_str = "1080"
else:
resolution_str = "720"
if audio_channels and index in audio_channels:
output_channels = audio_channels[index]
elif is_commentary:
output_channels = 2
else:
# Apply resolution-based channel clamping (same as choose_audio_bitrate)
if resolution_str == "2160":
max_channels = 8
elif resolution_str == "1080":
max_channels = 6
else:
max_channels = 2
output_channels = min(channels, max_channels)
# Only add audio filter if re-encoding (not copying) and channels need to be remixed
if not no_encode:
codec, br, final_channels = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class, is_commentary, resolution_str, codec_name)
if codec != "copy" and channels != final_channels:
# Need to downmix/remix channels
# Use actual stream index, not enumeration index
if final_channels >= 6:
audio_filters_list.append(f"[0:a:{i}]aformat=channel_layouts=5.1[a{i}]")
else:
audio_filters_list.append(f"[0:a:{i}]aformat=channel_layouts=stereo[a{i}]")
# Map only selected audio streams
if streams:
for index, _, _, _, _, _, _ in streams:
cmd.extend(["-map", f"0:{index}"])
else:
# Fallback: if no audio streams detected, include all audio from source
logger.warning("No audio streams detected, including all audio from source")
cmd.extend(["-map", "0:a"])
# Add subtitle mapping if present
if subtitle_files:
if not use_remux_for_subs:
# Include subtitles in main encode (no PGS in source)
for i, _ in enumerate(subtitle_files):
cmd.extend(["-map", f"{i+1}:s"])
else:
# Skip subtitles in main encode (has PGS - will add via remux after)
# This avoids conflicts with bitmap subtitle codecs
pass
# 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 is_hdr:
print(f"🎬 HDR content: BT.2020 + SMPTE2084 (HDR10) applied in filter chain")
else:
print(f"🎨 SDR content: BT.709 color space applied in filter chain")
if method=="CQ":
# Different quality parameter for CPU vs GPU encoders
if encoder_codec in ("libx264", "libx265"):
# CPU encoders use -crf (CRF: Constant Rate Factor, 0-51, 28 is default)
# Convert NVENC CQ (0-63, lower=better) to libx26x CRF (0-51, lower=better)
# Approximate mapping: NVENC CQ / 1.23 ≈ CRF
crf_value = max(0, min(51, int(cq * 51 / 63)))
cmd += ["-crf", str(crf_value)]
logger.info(f"Using CPU encoder CRF {crf_value} (converted from CQ {cq})")
else:
# NVIDIA encoders use -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):
# Determine final title (considering custom titles override)
final_title = audio_titles.get(index, title) if audio_titles else title
# Debug: Log what we're working with
if i == 0: # Only log once per file
logger.debug(f"audio_titles dict received: {audio_titles}")
logger.debug(f"Stream {index}: original_title='{title}', final_title='{final_title}', audio_titles_present={audio_titles is not None}")
# Check if this is a commentary track (original or custom title)
is_commentary = final_title and "commentary" in final_title.lower()
# Determine resolution string for audio bitrate selection
# 4K is defined as width >= 3840 OR height >= 2160 (accounts for different 4K formats like 3840x1606)
if scale_width >= 3840 or scale_height >= 2160:
resolution_str = "2160"
elif scale_height >= 1080 or scale_width >= 1920:
resolution_str = "1080"
else:
resolution_str = "720"
# Determine output channels: audio_channels override takes precedence
# BUT: Commentary tracks ALWAYS max out at 2ch (stereo) unless explicitly overridden
is_1080_class = scale_height >= 1080 or scale_width >= 1920
if audio_channels and index in audio_channels:
# User explicitly specified channel count for this stream
output_channels = audio_channels[index]
logger.info(f"Stream #{index}: Audio channels override applied: {channels}ch -> {output_channels}ch")
elif is_commentary:
output_channels = 2 # Commentary always stereo
else:
# Apply resolution-based channel clamping (same as choose_audio_bitrate)
if resolution_str == "2160":
max_channels = 8
elif resolution_str == "1080":
max_channels = 6
else:
max_channels = 2
output_channels = min(channels, max_channels)
# If no_encode is True, always copy audio
if no_encode:
codec, br, final_channels = "copy", avg_bitrate, output_channels
else:
codec, br, final_channels = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class, is_commentary, resolution_str, codec_name)
# Check if title should be stripped (for this stream or globally)
# Preserve any stream with "commentary" or "descriptive" in the title, regardless of strip_all_titles
is_special_audio = title and ("commentary" in title.lower() or "descriptive" in title.lower())
should_strip = strip_all_titles and not is_special_audio
# Log title stripping decisions for debugging (debug level, not info)
logger.debug(f"Stream {index}: title='{final_title}', is_commentary={is_commentary}, is_special_audio={is_special_audio}, strip_all_titles={strip_all_titles}, should_strip={should_strip}")
if is_commentary:
logger.info(f"Stream #{index}: Commentary track detected (forcing 2ch stereo)")
if strip_all_titles and is_special_audio:
logger.debug(f"Stream {index}: ✓ Preserving title '{title}' (special audio track)")
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}"]
# Apply custom title if provided for this stream (takes precedence)
if audio_titles and index in audio_titles:
cmd += [f"-metadata:s:a:{i}", f"title={audio_titles[index]}"]
# Strip title metadata if requested (but preserve commentary tracks and custom titles)
elif should_strip:
cmd += [f"-metadata:s:a:{i}", "title="]
else:
# Re-encode with target bitrate
# Opus for 8-channel, EAC3 for multichannel, AAC for stereo
if codec == "opus":
# Opus (supports 7.1/8-channel surround)
cmd += [
f"-c:a:{i}", "libopus",
f"-b:a:{i}", str(br),
f"-ac:a:{i}", str(final_channels)
]
elif codec == "eac3":
# Enhanced AC-3 (5.1 surround)
cmd += [
f"-c:a:{i}", "eac3",
f"-b:a:{i}", str(br),
f"-ac:a:{i}", str(final_channels)
]
else:
# AAC (stereo)
cmd += [
f"-c:a:{i}", "aac",
f"-b:a:{i}", str(br),
f"-ac:a:{i}", str(final_channels)
]
# Only add language metadata if explicitly provided
if audio_language:
cmd += [f"-metadata:s:a:{i}", f"language={audio_language}"]
# Apply custom title if provided for this stream (takes precedence)
if audio_titles and index in audio_titles:
cmd += [f"-metadata:s:a:{i}", f"title={audio_titles[index]}"]
# Strip title metadata if requested (but preserve commentary tracks and custom titles)
elif should_strip:
cmd += [f"-metadata:s:a:{i}", "title="]
# Detect subtitle codecs in the input file
subtitle_codecs = get_subtitle_stream_codecs(input_file)
# 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:
# For embedded subtitles, intelligently handle codec based on type
# Bitmap subtitles (PGS, DVD, DVB, etc.) must be copied, not converted to text format
# Other formats can be converted to subrip for MKV compatibility
# List of bitmap subtitle codecs that cannot be converted to text
bitmap_subtitle_codecs = {
"hdmv_pgs_subtitle", # Blu-ray PGS subtitles
"dvd_subtitle", # DVD subtitles
"dvb_subtitle", # DVB subtitles
"xsub" # XSub bitmap subtitles
}
has_bitmap = any(codec in bitmap_subtitle_codecs for codec in subtitle_codecs.values())
if has_bitmap:
# If any bitmap subtitles detected, copy all subtitles to preserve them
bitmap_types = [codec for codec in subtitle_codecs.values() if codec in bitmap_subtitle_codecs]
logger.info(f"Bitmap subtitle streams detected ({', '.join(set(bitmap_types))}) - copying subtitles without conversion")
cmd += ["-c:s", "copy"]
else:
# Convert mov_text (MP4 subtitles) to subrip (MKV-compatible)
# Use "copy" for other formats like subrip, ass, ssa, webvtt that work in MKV
cmd += ["-c:s", "subrip"]
# For embedded subtitles, still apply -disposition if unforce_subs is enabled
if unforce_subs:
# Apply to all embedded subtitle streams
cmd += ["-disposition:s", "-forced"]
# Note: Color space metadata is already handled through the filter chain (setparams)
# FFmpeg output options like -color_space are not supported with NVENC encoders
# The setparams filter in the vf chain above handles all color space encoding
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}")
# DEBUG: Log the full FFmpeg command for troubleshooting
ffmpeg_cmd_str = " ".join(cmd)
logger.info(f"FFmpeg command: {ffmpeg_cmd_str}")
print(f"🔧 FFmpeg command (for debugging):")
print(f" {ffmpeg_cmd_str}")
# Initialize black frame detector
black_detector = BlackFrameDetector(duration_seconds=300) # Monitor first 5 minutes
# 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())
# Feed line to black frame detector
black_detector.process_ffmpeg_line(line)
# 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)
# Clean up test preview file if it was created
if test_preview_file and test_preview_file.exists():
test_preview_file.unlink()
logger.info(f"Cleaned up test preview copy: {test_preview_file}")
raise subprocess.CalledProcessError(returncode, cmd)
# Analyze output for persistent black frames
print(f"\n🔍 Analyzing output for black frame issues...")
if BlackFrameAnalyzer.analyze_output_for_black(output_file, sample_duration=300):
# Output contains black frames - delete it and raise exception
logger.warning(f"Output file {output_file.name} detected as having persistent black frames!")
print(f"[ERROR] Output contains persistent BLACK FRAMES - this encode is invalid!")
try:
output_file.unlink()
logger.info(f"Deleted corrupted output: {output_file}")
except Exception as e:
logger.warning(f"Could not delete output file: {e}")
# Clean up test preview file if it was created
if test_preview_file and test_preview_file.exists():
test_preview_file.unlink()
logger.info(f"Cleaned up test preview copy: {test_preview_file}")
raise BlackFrameDetectionException(f"Output video is entirely/mostly black - encode failed quality check")
# In test mode, use the preview copy size as "original"
# Otherwise use the full input file
if test_mode and test_preview_file:
orig_size = test_preview_file.stat().st_size
else:
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"[SIZE] Original: {orig_size/1e6:.2f} MB -> Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)"
print(msg)
# Clean up test preview file if it was created
if test_preview_file and test_preview_file.exists():
test_preview_file.unlink()
logger.info(f"Cleaned up test preview copy: {test_preview_file}")
# If using remux mode (PGS present), add subtitles (both embedded and external) via remux pass
if use_remux_for_subs:
logger.info(f"Adding subtitles via remux pass (PGS handling)...")
temp_remux_file = output_file.parent / f"{output_file.stem}_remux.mkv"
# Build remux command: input is encoded file, add subtitles from original + external
remux_cmd = [
"ffmpeg", "-y",
"-i", str(output_file), # Encoded video+audio
"-i", str(input_file), # Original file for embedded subtitles
]
# Add external subtitle files if present
if subtitle_files:
for sub_file in subtitle_files:
remux_cmd.extend(["-i", str(sub_file)])
# Build mapping: all from encoded, subtitles from original, then external subs
remux_cmd.extend([
"-map", "0", # All streams from encoded file
"-map", "1:s?", # All subtitle streams from original
])
# Add external subtitle mappings
if subtitle_files:
for i in range(len(subtitle_files)):
remux_cmd.extend(["-map", f"{i+2}:s"])
remux_cmd.extend([
"-c", "copy", # Copy all streams (no re-encoding)
str(temp_remux_file)
])
try:
logger.info(f"Remux command: {' '.join(remux_cmd)}")
remux_result = subprocess.run(remux_cmd, capture_output=True, text=True, check=False)
if remux_result.returncode == 0:
# Remux succeeded - replace output with remuxed version
output_file.unlink()
temp_remux_file.rename(output_file)
logger.info(f"Successfully added subtitles via remux")
else:
# Remux failed - keep original encoded file (without subtitles)
logger.warning(f"Remux failed, keeping encoded file without subtitles: {remux_result.stderr[:200]}")
if temp_remux_file.exists():
temp_remux_file.unlink()
except Exception as e:
logger.warning(f"Remux error: {e}")
if temp_remux_file.exists():
temp_remux_file.unlink()
return orig_size, out_size, reduction_ratio