should be working
This commit is contained in:
parent
fcbe9194e0
commit
0d415c7e49
@ -83,6 +83,7 @@
|
|||||||
<high>192000</high>
|
<high>192000</high>
|
||||||
</stereo>
|
</stereo>
|
||||||
<multi_channel>
|
<multi_channel>
|
||||||
|
<low>384000</low>
|
||||||
<medium>448000</medium>
|
<medium>448000</medium>
|
||||||
</multi_channel>
|
</multi_channel>
|
||||||
</audio>
|
</audio>
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
# core/ffmpeg_helper.py
|
# core/ffmpeg_helper.py
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
@ -9,10 +11,66 @@ from core.logger_helper import setup_logger
|
|||||||
logger = setup_logger(Path(__file__).parent.parent / "logs")
|
logger = setup_logger(Path(__file__).parent.parent / "logs")
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# STREAM ANALYSIS
|
# ROBUST BITRATE CALCULATION
|
||||||
# =============================
|
# =============================
|
||||||
|
def calculate_stream_bitrate(input_file: Path, stream_index: int) -> int:
|
||||||
|
"""
|
||||||
|
Extract audio stream to temporary file using -c copy, calculate bitrate from file size and duration.
|
||||||
|
Returns bitrate in kbps.
|
||||||
|
|
||||||
|
Formula: bitrate_kbps = (file_size_bytes * 8) / duration_seconds / 1000
|
||||||
|
"""
|
||||||
|
temp_fd, temp_audio_path = tempfile.mkstemp(suffix=".aac", dir=None)
|
||||||
|
os.close(temp_fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Extract audio stream with -c copy (lossless extraction)
|
||||||
|
extract_cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", str(input_file),
|
||||||
|
"-map", f"0:a:{stream_index}",
|
||||||
|
"-c", "copy",
|
||||||
|
temp_audio_path
|
||||||
|
]
|
||||||
|
logger.debug(f"Extracting audio stream {stream_index} to temporary file...")
|
||||||
|
subprocess.run(extract_cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Step 2: Get duration using ffprobe
|
||||||
|
duration_cmd = [
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1:noprint_wrappers=1",
|
||||||
|
temp_audio_path
|
||||||
|
]
|
||||||
|
duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
|
||||||
|
duration_seconds = float(duration_result.stdout.strip())
|
||||||
|
|
||||||
|
# Step 3: Get file size and calculate bitrate
|
||||||
|
file_size_bytes = os.path.getsize(temp_audio_path)
|
||||||
|
bitrate_kbps = int((file_size_bytes * 8) / duration_seconds / 1000)
|
||||||
|
|
||||||
|
logger.debug(f"Stream {stream_index}: size={file_size_bytes} bytes, duration={duration_seconds:.2f}s, calculated_bitrate={bitrate_kbps} kbps")
|
||||||
|
return bitrate_kbps
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to calculate bitrate for stream {stream_index}: {e}. Falling back to metadata.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temporary audio file
|
||||||
|
try:
|
||||||
|
if os.path.exists(temp_audio_path):
|
||||||
|
os.remove(temp_audio_path)
|
||||||
|
logger.debug(f"Deleted temporary audio file: {temp_audio_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not delete temporary file {temp_audio_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_audio_streams(input_file: Path):
|
def get_audio_streams(input_file: Path):
|
||||||
"""Return a list of (index, channels, bitrate_kbps, lang)"""
|
"""Return a list of (index, channels, bitrate_kbps, lang)
|
||||||
|
|
||||||
|
Uses robust bitrate calculation by extracting each stream and computing
|
||||||
|
bitrate from file size and duration instead of relying on metadata.
|
||||||
|
"""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe", "-v", "error",
|
"ffprobe", "-v", "error",
|
||||||
"-select_streams", "a",
|
"-select_streams", "a",
|
||||||
@ -22,12 +80,25 @@ def get_audio_streams(input_file: Path):
|
|||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
data = json.loads(result.stdout or "{}")
|
data = json.loads(result.stdout or "{}")
|
||||||
streams = []
|
streams = []
|
||||||
for s in data.get("streams", []):
|
|
||||||
|
for i, s in enumerate(data.get("streams", [])):
|
||||||
index = s["index"]
|
index = s["index"]
|
||||||
channels = s.get("channels", 2)
|
channels = s.get("channels", 2)
|
||||||
bitrate = int(int(s.get("bit_rate", 128000)) / 1000)
|
|
||||||
lang = s.get("tags", {}).get("language", "und")
|
lang = s.get("tags", {}).get("language", "und")
|
||||||
|
|
||||||
|
# Calculate robust bitrate from extraction
|
||||||
|
calculated_bitrate = calculate_stream_bitrate(input_file, i)
|
||||||
|
|
||||||
|
# Fallback to metadata if calculation fails
|
||||||
|
if calculated_bitrate == 0:
|
||||||
|
bitrate = int(int(s.get("bit_rate", 128000)) / 1000)
|
||||||
|
logger.info(f"Stream {index}: Using metadata bitrate {bitrate} kbps (calculation failed)")
|
||||||
|
else:
|
||||||
|
bitrate = calculated_bitrate
|
||||||
|
logger.info(f"Stream {index}: Using calculated bitrate {bitrate} kbps")
|
||||||
|
|
||||||
streams.append((index, channels, bitrate, lang))
|
streams.append((index, channels, bitrate, lang))
|
||||||
|
|
||||||
return streams
|
return streams
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
|
|||||||
@ -4162,3 +4162,32 @@
|
|||||||
2025-12-29 22:06:32 [INFO] Moved 2025-12-29 21-53-23 -EHX.mp4 → 2025-12-29 21-53-23 -EHX.mp4
|
2025-12-29 22:06:32 [INFO] Moved 2025-12-29 21-53-23 -EHX.mp4 → 2025-12-29 21-53-23 -EHX.mp4
|
||||||
2025-12-29 22:06:32 [INFO] Tracked conversion: 2025-12-29 21-53-23 -EHX.mp4, 343.3MB → 47.9MB (14.0%), method=CQ
|
2025-12-29 22:06:32 [INFO] Tracked conversion: 2025-12-29 21-53-23 -EHX.mp4, 343.3MB → 47.9MB (14.0%), method=CQ
|
||||||
2025-12-29 22:06:32 [INFO] Deleted original and processing copy for 2025-12-29 21-53-23.mp4
|
2025-12-29 22:06:32 [INFO] Deleted original and processing copy for 2025-12-29 21-53-23.mp4
|
||||||
|
2025-12-31 11:28:51 [INFO] Processing: How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv
|
||||||
|
2025-12-31 11:29:39 [INFO] Copied How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv → How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv
|
||||||
|
2025-12-31 11:29:51 [INFO]
|
||||||
|
🧩 ENCODE SETTINGS
|
||||||
|
• Resolution: 1920x1080
|
||||||
|
• Scale Filter: lanczos
|
||||||
|
• CQ: 32
|
||||||
|
• Video Encoder: av1_nvenc (preset p1, pix_fmt p010le)
|
||||||
|
• Audio Streams:
|
||||||
|
2025-12-31 11:29:51 [INFO] - Stream #1: 6ch→6ch, src=und, detected=229kbps, action=ENCODE, target=384kbps
|
||||||
|
2025-12-31 11:29:51 [INFO] - Stream #2: 2ch→2ch, src=und, detected=73kbps, action=COPY (preserve), target=73kbps
|
||||||
|
2025-12-31 11:29:51 [INFO] Running CQ encode: How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv
|
||||||
|
2025-12-31 11:41:23 [INFO] Processing: How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv
|
||||||
|
2025-12-31 11:42:12 [INFO] Copied How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv → How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv
|
||||||
|
2025-12-31 11:42:12 [INFO] Source resolution detected: 1920x1040
|
||||||
|
2025-12-31 11:43:05 [INFO] Processing: How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv
|
||||||
|
2025-12-31 11:43:53 [INFO] Copied How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv → How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole.mkv
|
||||||
|
2025-12-31 11:43:53 [INFO] Source resolution detected: 1920x1040
|
||||||
|
2025-12-31 11:44:03 [INFO]
|
||||||
|
🧩 ENCODE SETTINGS
|
||||||
|
• Resolution: 1920x1080
|
||||||
|
• Scale Filter: lanczos
|
||||||
|
• CQ: 32
|
||||||
|
• Video Encoder: av1_nvenc (preset p1, pix_fmt p010le)
|
||||||
|
• Audio Streams:
|
||||||
|
2025-12-31 11:44:03 [INFO] - Stream #1: 6ch→6ch, src=und, detected=484kbps, action=ENCODE, target=448kbps
|
||||||
|
2025-12-31 11:44:03 [INFO] - Stream #2: 2ch→2ch, src=und, detected=65kbps, action=COPY (preserve), target=65kbps
|
||||||
|
2025-12-31 11:44:03 [INFO] Running CQ encode: How the Grinch Stole Christmas (2000) x265 AAC 5.1 Bluray-1080p Tigole -EHX.mkv
|
||||||
|
2025-12-31 11:52:44 [INFO] 📦 Original: 5626.25 MB → Encoded: 3268.49 MB (58.1% of original)
|
||||||
|
|||||||
331
main.py
331
main.py
@ -6,6 +6,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from core.config_helper import load_config_xml
|
from core.config_helper import load_config_xml
|
||||||
@ -31,22 +32,133 @@ if not TRACKER_FILE.exists():
|
|||||||
# =============================
|
# =============================
|
||||||
# AUDIO BUCKET LOGIC
|
# AUDIO BUCKET LOGIC
|
||||||
# =============================
|
# =============================
|
||||||
def choose_audio_bitrate(channels: int, bitrate_kbps: int, audio_config: dict, is_1080_class: bool) -> int:
|
def calculate_stream_bitrate(input_file: Path, stream_index: int) -> int:
|
||||||
"""Choose audio bitrate based on channel count and detected bitrate"""
|
"""
|
||||||
|
Extract audio stream to temporary file using -c copy, capture bitrate from ffmpeg output.
|
||||||
|
Returns bitrate in kbps. Falls back to 0 (and uses metadata) if extraction fails.
|
||||||
|
|
||||||
|
Uses ffmpeg's reported bitrate which is more accurate than calculating from file size/duration.
|
||||||
|
"""
|
||||||
|
temp_fd, temp_audio_path = tempfile.mkstemp(suffix=".aac", dir=None)
|
||||||
|
os.close(temp_fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Step 1: Extract audio stream with -c copy (lossless extraction)
|
||||||
|
# ffmpeg outputs bitrate info to stderr
|
||||||
|
extract_cmd = [
|
||||||
|
"ffmpeg", "-y", "-i", str(input_file),
|
||||||
|
"-map", f"0:a:{stream_index}",
|
||||||
|
"-c", "copy",
|
||||||
|
temp_audio_path
|
||||||
|
]
|
||||||
|
logger.debug(f"Extracting audio stream {stream_index} to temporary file for bitrate calculation...")
|
||||||
|
result = subprocess.run(extract_cmd, capture_output=True, text=True, check=True)
|
||||||
|
|
||||||
|
# Step 2: Parse bitrate from ffmpeg's output (stderr)
|
||||||
|
# Look for line like: "bitrate= 457.7kbits/s"
|
||||||
|
bitrate_kbps = 0
|
||||||
|
for line in result.stderr.split("\n"):
|
||||||
|
if "bitrate=" in line:
|
||||||
|
# Extract bitrate value from line like "size= 352162KiB time=01:45:03.05 bitrate= 457.7kbits/s"
|
||||||
|
parts = line.split("bitrate=")
|
||||||
|
if len(parts) > 1:
|
||||||
|
bitrate_str = parts[1].strip().split("kbits/s")[0].strip()
|
||||||
|
try:
|
||||||
|
bitrate_kbps = int(float(bitrate_str))
|
||||||
|
logger.debug(f"Stream {stream_index}: Extracted bitrate from ffmpeg output: {bitrate_kbps} kbps")
|
||||||
|
break
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we couldn't parse bitrate from output, fall back to calculation
|
||||||
|
if bitrate_kbps == 0:
|
||||||
|
logger.debug(f"Stream {stream_index}: Could not parse bitrate from ffmpeg output, calculating from file size...")
|
||||||
|
file_size_bytes = os.path.getsize(temp_audio_path)
|
||||||
|
|
||||||
|
# Get duration using ffprobe
|
||||||
|
duration_cmd = [
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-show_entries", "format=duration",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1:noprint_wrappers=1",
|
||||||
|
temp_audio_path
|
||||||
|
]
|
||||||
|
duration_result = subprocess.run(duration_cmd, capture_output=True, text=True, check=True)
|
||||||
|
duration_seconds = float(duration_result.stdout.strip())
|
||||||
|
|
||||||
|
bitrate_kbps = int((file_size_bytes * 8) / duration_seconds / 1000)
|
||||||
|
logger.debug(f"Stream {stream_index}: Calculated bitrate from file: {bitrate_kbps} kbps")
|
||||||
|
|
||||||
|
return bitrate_kbps
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to calculate bitrate for stream {stream_index}: {e}. Will fall back to metadata.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temporary audio file
|
||||||
|
try:
|
||||||
|
if os.path.exists(temp_audio_path):
|
||||||
|
os.remove(temp_audio_path)
|
||||||
|
logger.debug(f"Deleted temporary audio file: {temp_audio_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not delete temporary file {temp_audio_path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def choose_audio_bitrate(channels: int, bitrate_kbps: int, audio_config: dict, is_1080_class: bool) -> tuple:
|
||||||
|
"""
|
||||||
|
Choose audio codec and bitrate based on channel count, detected bitrate, and resolution.
|
||||||
|
|
||||||
|
Returns tuple: (codec, target_bitrate_bps)
|
||||||
|
- codec: "aac", "libopus", or "copy" (to preserve original without re-encoding)
|
||||||
|
- target_bitrate_bps: target bitrate in bits/sec (0 if using "copy")
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
Stereo + 1080p:
|
||||||
|
- Above 192k → high (192k) with AAC
|
||||||
|
- At/below 192k → preserve (copy)
|
||||||
|
|
||||||
|
Stereo + 720p:
|
||||||
|
- Above 160k → medium (160k) with AAC
|
||||||
|
- At/below 160k → preserve (copy)
|
||||||
|
|
||||||
|
Multi-channel:
|
||||||
|
- Below 384k → low (384k) with AAC
|
||||||
|
- 384k to below medium → low (384k) with AAC
|
||||||
|
- Medium and above → medium with AAC
|
||||||
|
"""
|
||||||
# Normalize to 2ch or 6ch output
|
# Normalize to 2ch or 6ch output
|
||||||
output_channels = 6 if channels >= 6 else 2
|
output_channels = 6 if channels >= 6 else 2
|
||||||
|
|
||||||
if output_channels == 2:
|
if output_channels == 2:
|
||||||
|
# Stereo logic
|
||||||
if is_1080_class:
|
if is_1080_class:
|
||||||
# Stereo-channel 1080p+: always use high
|
# 1080p+ stereo
|
||||||
return audio_config["stereo"]["high"]
|
high_br = audio_config["stereo"]["high"]
|
||||||
|
if bitrate_kbps > (high_br / 1000): # Above 192k
|
||||||
|
return ("aac", high_br)
|
||||||
|
else:
|
||||||
|
# Preserve original
|
||||||
|
return ("copy", 0)
|
||||||
else:
|
else:
|
||||||
# Stereo-channel 720p: always use medium
|
# 720p stereo
|
||||||
return audio_config["stereo"]["medium"]
|
medium_br = audio_config["stereo"]["medium"]
|
||||||
|
if bitrate_kbps > (medium_br / 1000): # Above 160k
|
||||||
|
return ("aac", medium_br)
|
||||||
|
else:
|
||||||
|
# Preserve original
|
||||||
|
return ("copy", 0)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Multi-channel (6ch+): always use medium
|
# Multi-channel (6ch+) logic
|
||||||
return audio_config["multi_channel"]["medium"]
|
low_br = audio_config["multi_channel"]["low"]
|
||||||
|
medium_br = audio_config["multi_channel"]["medium"]
|
||||||
|
|
||||||
|
if bitrate_kbps < (medium_br / 1000):
|
||||||
|
# Below medium, use low
|
||||||
|
return ("aac", low_br)
|
||||||
|
else:
|
||||||
|
# Medium and above, use medium
|
||||||
|
return ("aac", medium_br)
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# PATH NORMALIZATION
|
# PATH NORMALIZATION
|
||||||
@ -60,68 +172,109 @@ def normalize_path_for_service(local_path: str, path_mappings: dict) -> str:
|
|||||||
# =============================
|
# =============================
|
||||||
# AUDIO STREAMS DETECTION
|
# AUDIO STREAMS DETECTION
|
||||||
# =============================
|
# =============================
|
||||||
|
def get_source_resolution(input_file: Path) -> tuple:
|
||||||
|
"""
|
||||||
|
Get source video resolution (width, height).
|
||||||
|
Returns tuple: (width, height)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cmd = [
|
||||||
|
"ffprobe", "-v", "error",
|
||||||
|
"-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=width,height",
|
||||||
|
"-of", "default=noprint_wrappers=1:nokey=1:noprint_wrappers=1",
|
||||||
|
str(input_file)
|
||||||
|
]
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
||||||
|
lines = result.stdout.strip().split("\n")
|
||||||
|
width = int(lines[0]) if len(lines) > 0 else 1920
|
||||||
|
height = int(lines[1]) if len(lines) > 1 else 1080
|
||||||
|
logger.info(f"Source resolution detected: {width}x{height}")
|
||||||
|
return (width, height)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to detect source resolution: {e}. Defaulting to 1920x1080")
|
||||||
|
return (1920, 1080)
|
||||||
|
|
||||||
|
|
||||||
def get_audio_streams(input_file: Path):
|
def get_audio_streams(input_file: Path):
|
||||||
|
"""
|
||||||
|
Detect audio streams and calculate robust bitrates by extracting each stream.
|
||||||
|
Returns list of (index, channels, calculated_bitrate_kbps, language, metadata_bitrate_kbps)
|
||||||
|
"""
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffprobe","-v","error","-select_streams","a",
|
"ffprobe","-v","error","-select_streams","a",
|
||||||
"-show_entries","stream=index,channels,duration,bit_rate,tags=language",
|
"-show_entries","stream=index,channels,bit_rate,tags=language",
|
||||||
"-of","json", str(input_file)
|
"-of","json", str(input_file)
|
||||||
]
|
]
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
streams = []
|
streams = []
|
||||||
for s in data.get("streams", []):
|
|
||||||
|
for stream_num, s in enumerate(data.get("streams", [])):
|
||||||
index = s["index"]
|
index = s["index"]
|
||||||
channels = s.get("channels", 2)
|
channels = s.get("channels", 2)
|
||||||
src_lang = s.get("tags", {}).get("language", "und")
|
src_lang = s.get("tags", {}).get("language", "und")
|
||||||
bit_rate_meta = int(s.get("bit_rate", 0)) if s.get("bit_rate") else 0
|
bit_rate_meta = int(s.get("bit_rate", 0)) if s.get("bit_rate") else 0
|
||||||
|
|
||||||
# For multi-channel (6ch+), don't look at bitrate - always use medium
|
# Calculate robust bitrate by extracting the audio stream
|
||||||
if channels >= 6:
|
calculated_bitrate_kbps = calculate_stream_bitrate(input_file, stream_num)
|
||||||
avg_bitrate_kbps = 0 # Placeholder, won't be used
|
|
||||||
else:
|
|
||||||
# For stereo: try metadata first, then estimate, then default
|
|
||||||
if bit_rate_meta:
|
|
||||||
avg_bitrate_kbps = int(bit_rate_meta / 1000)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
duration = float(s.get("duration", 0))
|
|
||||||
if duration:
|
|
||||||
fmt_cmd = [
|
|
||||||
"ffprobe","-v","error","-show_entries","format=size,duration",
|
|
||||||
"-of","json", str(input_file)
|
|
||||||
]
|
|
||||||
fmt_result = subprocess.run(fmt_cmd, capture_output=True, text=True)
|
|
||||||
fmt_data = json.loads(fmt_result.stdout)
|
|
||||||
size_bytes = int(fmt_data.get("format", {}).get("size", 0))
|
|
||||||
total_duration = float(fmt_data.get("format", {}).get("duration", duration))
|
|
||||||
n_streams = len(data.get("streams", []))
|
|
||||||
avg_bitrate_kbps = int((size_bytes*8/n_streams)/total_duration/1000)
|
|
||||||
else:
|
|
||||||
# Default for stereo
|
|
||||||
avg_bitrate_kbps = 160
|
|
||||||
except Exception:
|
|
||||||
# Default: stereo only
|
|
||||||
avg_bitrate_kbps = 160
|
|
||||||
|
|
||||||
streams.append((index, channels, avg_bitrate_kbps, src_lang, int(bit_rate_meta / 1000)))
|
# If calculation failed, fall back to metadata
|
||||||
|
if calculated_bitrate_kbps == 0:
|
||||||
|
calculated_bitrate_kbps = int(bit_rate_meta / 1000) if bit_rate_meta else 160
|
||||||
|
logger.info(f"Stream {index}: Using fallback bitrate {calculated_bitrate_kbps} kbps")
|
||||||
|
|
||||||
|
streams.append((index, channels, calculated_bitrate_kbps, src_lang, int(bit_rate_meta / 1000) if bit_rate_meta else 0))
|
||||||
|
|
||||||
return streams
|
return streams
|
||||||
|
|
||||||
|
|
||||||
# =============================
|
# =============================
|
||||||
# FFmpeg ENCODE
|
# FFmpeg ENCODE
|
||||||
# =============================
|
# =============================
|
||||||
def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int,
|
def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, scale_height: int,
|
||||||
filter_flags: str, audio_config: dict, method: str, bitrate_config: dict):
|
filter_flags: str, audio_config: dict, method: str, bitrate_config: dict):
|
||||||
|
# Get source resolution
|
||||||
|
src_width, src_height = get_source_resolution(input_file)
|
||||||
|
|
||||||
streams = get_audio_streams(input_file)
|
streams = get_audio_streams(input_file)
|
||||||
header = f"\n🧩 ENCODE SETTINGS\n • Resolution: {scale_width}x{scale_height}\n • Scale Filter: {filter_flags}\n • CQ: {cq if method=='CQ' else 'N/A'}\n • Video Encoder: av1_nvenc (preset p1, pix_fmt p010le)\n • Audio Streams:"
|
|
||||||
|
# Log comprehensive encode settings
|
||||||
|
header = f"\n🧩 ENCODE SETTINGS"
|
||||||
logger.info(header)
|
logger.info(header)
|
||||||
print(" ")
|
print(" ")
|
||||||
|
|
||||||
|
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: av1_nvenc (preset p1, pix_fmt p010le)")
|
||||||
|
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) in streams:
|
for (index, channels, avg_bitrate, src_lang, meta_bitrate) in streams:
|
||||||
# Normalize to 2ch or 6ch output
|
# Normalize to 2ch or 6ch output
|
||||||
is_1080_class = scale_height >= 1080 or scale_width >= 1920
|
is_1080_class = scale_height >= 1080 or scale_width >= 1920
|
||||||
output_channels = 6 if is_1080_class and channels >= 6 else 2
|
output_channels = 6 if is_1080_class and channels >= 6 else 2
|
||||||
br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
||||||
line = f" - Stream #{index}: {channels}ch→{output_channels}ch, src={src_lang}, detected_bitrate={avg_bitrate}kbps, bucket_target={br/1000:.0f}kbps"
|
|
||||||
|
if codec == "copy":
|
||||||
|
action = "COPY (preserve)"
|
||||||
|
bitrate_display = f"{avg_bitrate}kbps"
|
||||||
|
else:
|
||||||
|
action = "ENCODE"
|
||||||
|
bitrate_display = f"{br/1000:.0f}kbps"
|
||||||
|
|
||||||
|
line = f" - Stream #{index}: {channels}ch→{output_channels}ch | Lang: {src_lang} | Detected: {avg_bitrate}kbps | Action: {action} | Target: {bitrate_display}"
|
||||||
print(line)
|
print(line)
|
||||||
logger.info(line)
|
logger.info(line)
|
||||||
|
|
||||||
@ -144,8 +297,19 @@ def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, s
|
|||||||
# Normalize to 2ch or 6ch output
|
# Normalize to 2ch or 6ch output
|
||||||
is_1080_class = scale_height >= 1080 or scale_width >= 1920
|
is_1080_class = scale_height >= 1080 or scale_width >= 1920
|
||||||
output_channels = 6 if is_1080_class and channels >= 6 else 2
|
output_channels = 6 if is_1080_class and channels >= 6 else 2
|
||||||
br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
codec, br = choose_audio_bitrate(output_channels, avg_bitrate, audio_config, is_1080_class)
|
||||||
cmd += [f"-c:a:{i}","aac",f"-b:a:{i}",str(br),f"-ac:{i}",str(output_channels,),f"-channel_layout:a:{i}", "5.1" if output_channels == 6 else "stereo"]
|
|
||||||
|
if codec == "copy":
|
||||||
|
# Preserve original audio
|
||||||
|
cmd += [f"-c:a:{i}", "copy"]
|
||||||
|
else:
|
||||||
|
# Re-encode with target bitrate
|
||||||
|
cmd += [
|
||||||
|
f"-c:a:{i}", codec,
|
||||||
|
f"-b:a:{i}", str(br),
|
||||||
|
f"-ac:{i}", str(output_channels),
|
||||||
|
f"-channel_layout:a:{i}", "5.1" if output_channels == 6 else "stereo"
|
||||||
|
]
|
||||||
|
|
||||||
cmd += ["-c:s","copy",str(output_file)]
|
cmd += ["-c:s","copy",str(output_file)]
|
||||||
|
|
||||||
@ -157,9 +321,17 @@ def run_ffmpeg(input_file: Path, output_file: Path, cq: int, scale_width: int, s
|
|||||||
orig_size = input_file.stat().st_size
|
orig_size = input_file.stat().st_size
|
||||||
out_size = output_file.stat().st_size
|
out_size = output_file.stat().st_size
|
||||||
reduction_ratio = out_size / orig_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)"
|
msg = f"📦 Original: {orig_size/1e6:.2f} MB → Encoded: {out_size/1e6:.2f} MB ({reduction_ratio:.1%} of original)"
|
||||||
print(msg)
|
print(msg)
|
||||||
logger.info(msg)
|
|
||||||
|
|
||||||
return orig_size, out_size, reduction_ratio
|
return orig_size, out_size, reduction_ratio
|
||||||
|
|
||||||
@ -180,17 +352,14 @@ def process_folder(folder: Path, cq: int, transcode_mode: str, resolution: str,
|
|||||||
ignore_tags = config["ignore_tags"]
|
ignore_tags = config["ignore_tags"]
|
||||||
reduction_ratio_threshold = config["reduction_ratio_threshold"]
|
reduction_ratio_threshold = config["reduction_ratio_threshold"]
|
||||||
|
|
||||||
res_height = 1080 if resolution=="1080" else 720
|
# Resolution logic: explicit arg takes precedence, else use smart defaults
|
||||||
res_width = 1920 if resolution=="1080" else 1280
|
explicit_resolution = resolution # Will be None if not specified
|
||||||
|
|
||||||
filter_flags = filters_config.get("default","lanczos")
|
filter_flags = filters_config.get("default","lanczos")
|
||||||
folder_lower = str(folder).lower()
|
folder_lower = str(folder).lower()
|
||||||
if "\\tv\\" in folder_lower or "/tv/" in folder_lower:
|
is_tv = "\\tv\\" in folder_lower or "/tv/" in folder_lower
|
||||||
|
if is_tv:
|
||||||
filter_flags = filters_config.get("tv","bicubic")
|
filter_flags = filters_config.get("tv","bicubic")
|
||||||
cq_default = config["encode"]["cq"].get(f"tv_{resolution}",32)
|
|
||||||
else:
|
|
||||||
cq_default = config["encode"]["cq"].get(f"movie_{resolution}",32)
|
|
||||||
if cq is None:
|
|
||||||
cq = cq_default
|
|
||||||
|
|
||||||
processing_folder = Path(config["processing_folder"])
|
processing_folder = Path(config["processing_folder"])
|
||||||
processing_folder.mkdir(parents=True, exist_ok=True)
|
processing_folder.mkdir(parents=True, exist_ok=True)
|
||||||
@ -213,11 +382,55 @@ def process_folder(folder: Path, cq: int, transcode_mode: str, resolution: str,
|
|||||||
temp_input = processing_folder / file.name
|
temp_input = processing_folder / file.name
|
||||||
shutil.copy2(file, temp_input)
|
shutil.copy2(file, temp_input)
|
||||||
logger.info(f"Copied {file.name} → {temp_input.name}")
|
logger.info(f"Copied {file.name} → {temp_input.name}")
|
||||||
|
|
||||||
|
# Detect source resolution and determine target resolution
|
||||||
|
src_width, src_height = get_source_resolution(temp_input)
|
||||||
|
|
||||||
|
# Smart resolution logic
|
||||||
|
if explicit_resolution:
|
||||||
|
# User explicitly specified resolution - always use it
|
||||||
|
target_resolution = explicit_resolution
|
||||||
|
if target_resolution == "1080":
|
||||||
|
res_height = 1080
|
||||||
|
res_width = 1920
|
||||||
|
elif target_resolution == "720":
|
||||||
|
res_height = 720
|
||||||
|
res_width = 1280
|
||||||
|
else: # 480
|
||||||
|
res_height = 480
|
||||||
|
res_width = 854
|
||||||
|
logger.info(f"Using explicitly specified resolution: {res_width}x{res_height}")
|
||||||
|
else:
|
||||||
|
# No explicit resolution - use smart defaults
|
||||||
|
if src_height > 1080:
|
||||||
|
# Scale down anything above 1080p to 1080p
|
||||||
|
target_resolution = "1080"
|
||||||
|
res_height = 1080
|
||||||
|
res_width = 1920
|
||||||
|
print(f"⚠️ Source {src_width}x{src_height} is above 1080p. Scaling down to 1080p.")
|
||||||
|
logger.info(f"Source {src_width}x{src_height} detected. Scaling to 1080p.")
|
||||||
|
else:
|
||||||
|
# Preserve source resolution (480p, 720p, 1080p, etc.)
|
||||||
|
res_height = src_height
|
||||||
|
res_width = src_width
|
||||||
|
if src_height <= 720:
|
||||||
|
target_resolution = "720"
|
||||||
|
print(f"ℹ️ Source {src_width}x{src_height} is 720p or lower. Preserving resolution.")
|
||||||
|
logger.info(f"Source {src_width}x{src_height} (<=720p). Preserving source resolution.")
|
||||||
|
else:
|
||||||
|
target_resolution = "1080"
|
||||||
|
print(f"ℹ️ Source {src_width}x{src_height} is at or below 1080p. Preserving resolution.")
|
||||||
|
logger.info(f"Source {src_width}x{src_height} (<=1080p). Preserving source resolution.")
|
||||||
|
|
||||||
|
# Set CQ based on content type and target resolution
|
||||||
|
content_cq = config["encode"]["cq"].get(f"tv_{target_resolution}" if is_tv else f"movie_{target_resolution}", 32)
|
||||||
|
file_cq = cq if cq is not None else content_cq
|
||||||
|
|
||||||
temp_output = processing_folder / f"{file.stem}{suffix}{file.suffix}"
|
temp_output = processing_folder / f"{file.stem}{suffix}{file.suffix}"
|
||||||
|
|
||||||
method = "Bitrate" if use_bitrate else "CQ"
|
method = "Bitrate" if use_bitrate else "CQ"
|
||||||
try:
|
try:
|
||||||
orig_size, out_size, reduction_ratio = run_ffmpeg(temp_input, temp_output, cq, res_width, res_height, filter_flags, audio_config, method, bitrate_config)
|
orig_size, out_size, reduction_ratio = run_ffmpeg(temp_input, temp_output, file_cq, res_width, res_height, filter_flags, audio_config, method, bitrate_config)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
print(f"❌ FFmpeg failed: {e}")
|
print(f"❌ FFmpeg failed: {e}")
|
||||||
logger.error(f"FFmpeg failed: {e}")
|
logger.error(f"FFmpeg failed: {e}")
|
||||||
@ -274,7 +487,11 @@ def process_folder(folder: Path, cq: int, transcode_mode: str, resolution: str,
|
|||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
writer.writerow([f_type, show, dest_file.name, orig_size_mb, proc_size_mb, percentage, method])
|
writer.writerow([f_type, show, dest_file.name, orig_size_mb, proc_size_mb, percentage, method])
|
||||||
|
|
||||||
logger.info(f"Tracked conversion: {dest_file.name}, {orig_size_mb}MB → {proc_size_mb}MB ({percentage}%), method={method}")
|
# Enhanced logging with all conversion details
|
||||||
|
logger.info(f"\n✅ CONVERSION COMPLETE: {dest_file.name}")
|
||||||
|
logger.info(f" Type: {f_type.upper()} | Show: {show}")
|
||||||
|
logger.info(f" Size: {orig_size_mb}MB → {proc_size_mb}MB ({percentage}% of original, {100-percentage:.1f}% reduction)")
|
||||||
|
logger.info(f" Method: {method} | Status: SUCCESS")
|
||||||
print(f"📝 Logged conversion: {dest_file.name} ({percentage}%), method={method}")
|
print(f"📝 Logged conversion: {dest_file.name} ({percentage}%), method={method}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -292,8 +509,8 @@ def main():
|
|||||||
parser = argparse.ArgumentParser(description="Batch encode videos with logging and tracker")
|
parser = argparse.ArgumentParser(description="Batch encode videos with logging and tracker")
|
||||||
parser.add_argument("folder", help="Path to folder containing videos")
|
parser.add_argument("folder", help="Path to folder containing videos")
|
||||||
parser.add_argument("--cq", type=int, help="Override default CQ")
|
parser.add_argument("--cq", type=int, help="Override default CQ")
|
||||||
parser.add_argument("--m", "--mode", dest="transcode_mode", default="cq", choices=["cq","bitrate"], help="Target resolution")
|
parser.add_argument("--m", "--mode", dest="transcode_mode", default="cq", choices=["cq","bitrate"], help="Encode mode (cq or bitrate)")
|
||||||
parser.add_argument("--r", "--resolution", dest="resolution", default="1080", choices=["720","1080"], help="Target resolution")
|
parser.add_argument("--r", "--resolution", dest="resolution", default=None, choices=["480","720","1080"], help="Force target resolution (if not specified, preserves source if <=1080p, else scales to 1080p)")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
config_path = Path(__file__).parent / "config.xml"
|
config_path = Path(__file__).parent / "config.xml"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user