First full version

This commit is contained in:
2025-08-02 13:09:03 +02:00
parent 1b9543fbaa
commit 973eeb9ddb

View File

@@ -5,66 +5,47 @@ import subprocess
import shutil import shutil
import tempfile import tempfile
import json import json
import re import re # Added for VFR frame rate parsing
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
# List of required external tools
REQUIRED_TOOLS = [ REQUIRED_TOOLS = [
"ffmpeg", "ffprobe", "mkvmerge", "mkvpropedit", "ffmpeg", "ffprobe", "mkvmerge", "mkvpropedit",
"sox", "opusenc", "mediainfo", "SvtAv1EncApp2", "HandBrakeCLI" "sox", "opusenc", "mediainfo", "av1an", "HandBrakeCLI" # Added HandBrakeCLI
] ]
DIR_COMPLETED = Path("completed")
DIR_ORIGINAL = Path("original")
DIR_CONV_LOGS = Path("conv_logs") # Directory for conversion logs
# Set the SVT-AV1 encoder binary here for easy switching REMUX_CODECS = {"aac", "opus"} # Using a set for efficient lookups
SVTAV1_BIN = "SvtAv1EncApp2"
# SVT-AV1-Essential (nekotrix) encoding parameters (edit here as needed)
SVT_AV1_PARAMS = { SVT_AV1_PARAMS = {
"speed": "slow", # "slower", "slow", "medium", "fast", "faster" "speed": "slow", # "slower", "slow", "medium", "fast", "faster"
"quality": "high", # "higher", "high", "medium", "low", "lower" "quality": "high", # "higher", "high", "medium", "low", "lower"
"film-grain": 6, "film-grain": 6,
"color-primaries": 1, "color-primaries": 1,
"transfer-characteristics": 1, "transfer-characteristics": 1,
"matrix-coefficients": 1, "matrix-coefficients": 1,
"scd": 1, # Scene change detection ON (recommended) "scd": 1, # Scene change detection ON
"auto-tiling": 1, # Auto tiling ON (recommended) "auto-tiling": 1, # Auto tiling ON
"tune": 1, # 0 = VQ, 1 = PSNR, 2 = SSIM "tune": 1, # 0 = VQ, 1 = PSNR, 2 = SSIM
"progress": 2, # Detailed progress output "progress": 2, # Detailed progress output
} }
SVT_AV1_PARAMS_LIST = []
for key, value in SVT_AV1_PARAMS.items():
SVT_AV1_PARAMS_LIST.extend([f"--{key}", str(value)])
DIR_COMPLETED = Path("completed")
DIR_ORIGINAL = Path("original")
DIR_CONV_LOGS = Path("conv_logs")
REMUX_CODECS = {"aac", "opus"}
def check_tools(): def check_tools():
"""Check if all required tools are available in PATH."""
for tool in REQUIRED_TOOLS: for tool in REQUIRED_TOOLS:
if shutil.which(tool) is None: if shutil.which(tool) is None:
print(f"Required tool '{tool}' not found in PATH.") print(f"Required tool '{tool}' not found in PATH.")
sys.exit(1) sys.exit(1)
def run_cmd(cmd, capture_output=False, check=True): def run_cmd(cmd, capture_output=False, check=True):
"""Run a subprocess command, optionally capturing output.""" if capture_output:
try: result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check, text=True)
if capture_output: return result.stdout
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check, text=True) else:
return result.stdout subprocess.run(cmd, check=check)
else:
subprocess.run(cmd, check=check)
except subprocess.CalledProcessError as e:
print(f"Command failed: {' '.join(map(str, cmd))}")
print(f"Return code: {e.returncode}")
if capture_output:
print(f"Output: {e.output}")
raise
def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_downmix): def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_downmix):
"""Extract, normalize, and encode an audio track to Opus."""
audio_temp_path = Path(audio_temp_dir) audio_temp_path = Path(audio_temp_dir)
temp_extracted = audio_temp_path / f"track_{index}_extracted.flac" temp_extracted = audio_temp_path / f"track_{index}_extracted.flac"
temp_normalized = audio_temp_path / f"track_{index}_normalized.flac" temp_normalized = audio_temp_path / f"track_{index}_normalized.flac"
@@ -74,13 +55,12 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
ffmpeg_args = [ ffmpeg_args = [
"ffmpeg", "-v", "quiet", "-stats", "-y", "-i", str(source_file), "-map", f"0:{index}" "ffmpeg", "-v", "quiet", "-stats", "-y", "-i", str(source_file), "-map", f"0:{index}"
] ]
# Downmix if needed
if should_downmix and ch >= 6: if should_downmix and ch >= 6:
if ch == 6: if ch == 6:
ffmpeg_args += ["-af", "pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5"] ffmpeg_args += ["-af", "pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5"]
elif ch == 8: elif ch == 8:
ffmpeg_args += ["-af", "pan=stereo|c0=c2+0.30*c0+0.30*c4+0.30*c6|c1=c2+0.30*c1+0.30*c5+0.30*c7"] ffmpeg_args += ["-af", "pan=stereo|c0=c2+0.30*c0+0.30*c4+0.30*c6|c1=c2+0.30*c1+0.30*c5+0.30*c7"]
else: else: # Other multi-channel (e.g. 7ch, 10ch)
ffmpeg_args += ["-ac", "2"] ffmpeg_args += ["-ac", "2"]
ffmpeg_args += ["-c:a", "flac", str(temp_extracted)] ffmpeg_args += ["-c:a", "flac", str(temp_extracted)]
run_cmd(ffmpeg_args) run_cmd(ffmpeg_args)
@@ -90,19 +70,25 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
"sox", str(temp_extracted), str(temp_normalized), "-S", "--temp", str(audio_temp_path), "--guard", "gain", "-n" "sox", str(temp_extracted), str(temp_normalized), "-S", "--temp", str(audio_temp_path), "--guard", "gain", "-n"
]) ])
# Set bitrate based on channel count and downmixing # Set bitrate based on the final channel count of the Opus file.
# If we are downmixing, the result is stereo.
# If not, the result has the original channel count.
is_being_downmixed = should_downmix and ch >= 6 is_being_downmixed = should_downmix and ch >= 6
if is_being_downmixed: if is_being_downmixed:
# Downmixing from 5.1 or 7.1 results in a stereo track.
bitrate = "128k" bitrate = "128k"
else: else:
if ch == 2: # Not downmixing (or source is already stereo or less).
# Base bitrate on the source channel count.
if ch == 2: # Stereo
bitrate = "128k" bitrate = "128k"
elif ch == 6: elif ch == 6: # 5.1 Surround
bitrate = "256k" bitrate = "256k"
elif ch == 8: elif ch == 8: # 7.1 Surround
bitrate = "384k" bitrate = "384k"
else: else: # Mono or other layouts
bitrate = "96k" bitrate = "96k" # A sensible default for mono.
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...") print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
run_cmd([ run_cmd([
@@ -111,16 +97,14 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
return final_opus return final_opus
def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for_handbrake, autocrop_filter=None): def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for_handbrake, autocrop_filter=None):
"""
Convert video to AV1 using ffmpeg and SvtAv1EncApp.
If VFR, use HandBrakeCLI to convert to CFR first.
"""
print(" --- Starting Video Processing ---") print(" --- Starting Video Processing ---")
encoded_video_file = Path(f"temp-{source_file_base}.ivf") scene_file = Path(f"{source_file_base}.txt") # Not used anymore, but kept for compatibility
ut_video_file = Path(f"{source_file_base}.ut.mkv")
encoded_video_file = Path(f"temp-{source_file_base}.mkv")
handbrake_cfr_intermediate_file = None handbrake_cfr_intermediate_file = None
current_input = Path(source_file_full)
# VFR-to-CFR conversion using HandBrakeCLI if needed current_input_for_utvideo = Path(source_file_full)
if is_vfr and target_cfr_fps_for_handbrake: if is_vfr and target_cfr_fps_for_handbrake:
print(f" - Source is VFR. Converting to CFR ({target_cfr_fps_for_handbrake}) with HandBrakeCLI...") print(f" - Source is VFR. Converting to CFR ({target_cfr_fps_for_handbrake}) with HandBrakeCLI...")
handbrake_cfr_intermediate_file = Path(f"{source_file_base}.cfr_temp.mkv") handbrake_cfr_intermediate_file = Path(f"{source_file_base}.cfr_temp.mkv")
@@ -143,64 +127,65 @@ def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for
run_cmd(handbrake_args) run_cmd(handbrake_args)
if handbrake_cfr_intermediate_file.exists() and handbrake_cfr_intermediate_file.stat().st_size > 0: if handbrake_cfr_intermediate_file.exists() and handbrake_cfr_intermediate_file.stat().st_size > 0:
print(f" - HandBrake VFR to CFR conversion successful: {handbrake_cfr_intermediate_file}") print(f" - HandBrake VFR to CFR conversion successful: {handbrake_cfr_intermediate_file}")
current_input = handbrake_cfr_intermediate_file current_input_for_utvideo = handbrake_cfr_intermediate_file
else: else:
print(f" - Warning: HandBrakeCLI VFR-to-CFR conversion failed or produced an empty file. Proceeding with original source.") print(f" - Warning: HandBrakeCLI VFR-to-CFR conversion failed or produced an empty file. Proceeding with original source for UTVideo.")
handbrake_cfr_intermediate_file = None handbrake_cfr_intermediate_file = None
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
print(f" - Error during HandBrakeCLI execution: {e}") print(f" - Error during HandBrakeCLI execution: {e}")
print(f" - Proceeding with original source.") print(f" - Proceeding with original source for UTVideo.")
handbrake_cfr_intermediate_file = None handbrake_cfr_intermediate_file = None
print(" - Starting AV1 encode with ffmpeg and SvtAv1EncApp...") print(" - Creating UTVideo intermediate file (overwriting if exists)...")
# Probe video info for width, height, and frame rate
ffprobe_cmd = [ ffprobe_cmd = [
"ffprobe", "-v", "error", "-select_streams", "v:0", "ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate", "-of", "json", "-show_entries", "stream=codec_name", "-of", "default=noprint_wrappers=1:nokey=1",
str(current_input) str(current_input_for_utvideo)
] ]
video_info_json = run_cmd(ffprobe_cmd, capture_output=True, check=True) source_codec = run_cmd(ffprobe_cmd, capture_output=True, check=True).strip()
video_info = json.loads(video_info_json)["streams"][0]
width = video_info["width"] video_codec_args = ["-c:v", "utvideo"]
height = video_info["height"] if source_codec == "utvideo" and current_input_for_utvideo == Path(source_file_full):
frame_rate = video_info["r_frame_rate"] print(" - Source is already UTVideo. Copying video stream...")
video_codec_args = ["-c:v", "copy"]
# Prepare ffmpeg command for yuv4mpegpipe
ffmpeg_args = [ ffmpeg_args = [
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", str(current_input), "ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", str(current_input_for_utvideo),
"-pix_fmt", "yuv420p10le", "-f", "yuv4mpegpipe", "-" "-map", "0:v:0", "-map_metadata", "-1", "-map_chapters", "-1", "-an", "-sn", "-dn",
] ]
if autocrop_filter: if autocrop_filter:
match = re.match(r"crop=(\d+):(\d+):(\d+):(\d+)", autocrop_filter) ffmpeg_args += ["-vf", autocrop_filter]
if match: ffmpeg_args += video_codec_args + [str(ut_video_file)]
width = int(match.group(1)) run_cmd(ffmpeg_args)
height = int(match.group(2)) ut_video_full_path = os.path.abspath(ut_video_file)
ffmpeg_args.insert(-2, "-vf")
ffmpeg_args.insert(-2, autocrop_filter)
svt_enc_args = [ # --- SVT-AV1 ENCODING (ffmpeg pipe to SvtAv1EncApp) ---
SVTAV1_BIN, "-i", "stdin", print(" - Starting AV1 encode with ffmpeg -> SvtAv1EncApp pipe (this will take a long time)...")
"--width", str(width), "--height", str(height), svtav1_param_list = []
"--fps-num", frame_rate.split('/')[0], "--fps-den", frame_rate.split('/')[1], for k, v in SVT_AV1_PARAMS.items():
"-b", str(encoded_video_file) if isinstance(v, bool):
] + SVT_AV1_PARAMS_LIST v = int(v)
svtav1_param_list.append(f"--{k} {v}")
svtav1_param_str = " ".join(svtav1_param_list)
print(f" - Using SvtAv1EncApp parameters: {svtav1_param_str}")
print(f" - FFmpeg command: {' '.join(ffmpeg_args)}") # ffmpeg: always output yuv420p10le (10-bit), even if input is 8-bit
print(f" - SvtAv1EncApp command: {' '.join(svt_enc_args)}") ffmpeg_pipe_cmd = [
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", ut_video_full_path,
"-pix_fmt", "yuv420p10le", "-f", "yuv4mpegpipe", "-an", "-sn", "-dn", "-map", "0:v:0", "-"
]
svtav1_cmd = [
"SvtAv1EncApp2", "--input", "-", "--output", str(encoded_video_file)
] + svtav1_param_list
# Pipe ffmpeg output to SvtAv1EncApp print(f" - Running: {' '.join(ffmpeg_pipe_cmd)} | {' '.join(svtav1_cmd)}")
ffmpeg_process = subprocess.Popen(ffmpeg_args, stdout=subprocess.PIPE) # Use subprocess.Popen for piping
svt_process = subprocess.Popen(svt_enc_args, stdin=ffmpeg_process.stdout) with subprocess.Popen(ffmpeg_pipe_cmd, stdout=subprocess.PIPE) as ffmpeg_proc:
with subprocess.Popen(svtav1_cmd, stdin=ffmpeg_proc.stdout) as svt_proc:
if ffmpeg_process.stdout: ffmpeg_proc.stdout.close() # Allow ffmpeg_proc to receive a SIGPIPE if svt_proc exits
ffmpeg_process.stdout.close() svt_proc.communicate()
if svt_proc.returncode != 0:
svt_process.wait() raise RuntimeError(f"SvtAv1EncApp failed with exit code {svt_proc.returncode}")
ffmpeg_process.wait()
if svt_process.returncode != 0:
raise subprocess.CalledProcessError(svt_process.returncode, svt_enc_args)
print(" --- Finished Video Processing ---") print(" --- Finished Video Processing ---")
return encoded_video_file, handbrake_cfr_intermediate_file return encoded_video_file, handbrake_cfr_intermediate_file
@@ -208,6 +193,7 @@ def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for
def is_ffmpeg_decodable(file_path): def is_ffmpeg_decodable(file_path):
"""Quickly check if ffmpeg can decode the input file.""" """Quickly check if ffmpeg can decode the input file."""
try: try:
# Try to decode a short segment of the first audio stream
subprocess.run([ subprocess.run([
"ffmpeg", "-v", "error", "-i", str(file_path), "-map", "0:a:0", "-t", "1", "-f", "null", "-" "ffmpeg", "-v", "error", "-i", str(file_path), "-map", "0:a:0", "-t", "1", "-f", "null", "-"
], check=True) ], check=True)
@@ -215,10 +201,230 @@ def is_ffmpeg_decodable(file_path):
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return False return False
# --- Cropdetect logic omitted for brevity (unchanged) --- # --- CROPDETECT LOGIC FROM cropdetect.py ---
import argparse as _argparse_cropdetect
import multiprocessing as _multiprocessing_cropdetect
from collections import Counter as _Counter_cropdetect
COLOR_GREEN = "\033[92m"
COLOR_RED = "\033[91m"
COLOR_YELLOW = "\033[93m"
COLOR_RESET = "\033[0m"
KNOWN_ASPECT_RATIOS = [
{"name": "HDTV (16:9)", "ratio": 16/9},
{"name": "Widescreen (Scope)", "ratio": 2.39},
{"name": "Widescreen (Flat)", "ratio": 1.85},
{"name": "IMAX Digital (1.90:1)", "ratio": 1.90},
{"name": "Fullscreen (4:3)", "ratio": 4/3},
{"name": "IMAX 70mm (1.43:1)", "ratio": 1.43},
]
def _check_prerequisites_cropdetect():
for tool in ['ffmpeg', 'ffprobe']:
if not shutil.which(tool):
print(f"Error: '{tool}' command not found. Is it installed and in your PATH?")
return False
return True
def _analyze_segment_cropdetect(task_args):
seek_time, input_file, width, height = task_args
ffmpeg_args = [
'ffmpeg', '-hide_banner',
'-ss', str(seek_time),
'-i', input_file, '-t', '1', '-vf', 'cropdetect',
'-f', 'null', '-'
]
result = subprocess.run(ffmpeg_args, capture_output=True, text=True, encoding='utf-8')
if result.returncode != 0:
return []
crop_detections = re.findall(r'crop=(\d+):(\d+):(\d+):(\d+)', result.stderr)
significant_crops = []
for w_str, h_str, x_str, y_str in crop_detections:
w, h, x, y = map(int, [w_str, h_str, x_str, y_str])
significant_crops.append((f"crop={w}:{h}:{x}:{y}", seek_time))
return significant_crops
def _snap_to_known_ar_cropdetect(w, h, x, y, video_w, video_h, tolerance=0.03):
if h == 0: return f"crop={w}:{h}:{x}:{y}", None
detected_ratio = w / h
best_match = None
smallest_diff = float('inf')
for ar in KNOWN_ASPECT_RATIOS:
diff = abs(detected_ratio - ar['ratio'])
if diff < smallest_diff:
smallest_diff = diff
best_match = ar
if not best_match or (smallest_diff / best_match['ratio']) >= tolerance:
return f"crop={w}:{h}:{x}:{y}", None
if abs(w - video_w) < 16:
new_h = round(video_w / best_match['ratio'])
if new_h % 8 != 0:
new_h = new_h + (8 - (new_h % 8))
new_y = round((video_h - new_h) / 2)
if new_y % 2 != 0:
new_y -= 1
return f"crop={video_w}:{new_h}:0:{new_y}", best_match['name']
if abs(h - video_h) < 16:
new_w = round(video_h * best_match['ratio'])
if new_w % 8 != 0:
new_w = new_w + (8 - (new_w % 8))
new_x = round((video_w - new_w) / 2)
if new_x % 2 != 0:
new_x -= 1
return f"crop={new_w}:{video_h}:{new_x}:0", best_match['name']
return f"crop={w}:{h}:{x}:{y}", None
def _cluster_crop_values_cropdetect(crop_counts, tolerance=8):
clusters = []
temp_counts = crop_counts.copy()
while temp_counts:
center_str, _ = temp_counts.most_common(1)[0]
try:
_, values = center_str.split('=');
cw, ch, cx, cy = map(int, values.split(':'))
except (ValueError, IndexError):
del temp_counts[center_str]
continue
cluster_total_count = 0
crops_to_remove = []
for crop_str, count in temp_counts.items():
try:
_, values = crop_str.split('=');
w, h, x, y = map(int, values.split(':'))
if abs(x - cx) <= tolerance and abs(y - cy) <= tolerance:
cluster_total_count += count
crops_to_remove.append(crop_str)
except (ValueError, IndexError):
continue
if cluster_total_count > 0:
clusters.append({'center': center_str, 'count': cluster_total_count})
for crop_str in crops_to_remove:
del temp_counts[crop_str]
clusters.sort(key=lambda c: c['count'], reverse=True)
return clusters
def _parse_crop_string_cropdetect(crop_str):
try:
_, values = crop_str.split('=');
w, h, x, y = map(int, values.split(':'))
return {'w': w, 'h': h, 'x': x, 'y': y}
except (ValueError, IndexError):
return None
def _calculate_bounding_box_cropdetect(crop_keys):
min_x = min_w = min_y = min_h = float('inf')
max_x = max_w = max_y = max_h = float('-inf')
for key in crop_keys:
parsed = _parse_crop_string_cropdetect(key)
if not parsed:
continue
w, h, x, y = parsed['w'], parsed['h'], parsed['x'], parsed['y']
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x + w)
max_y = max(max_y, y + h)
min_w = min(min_w, w)
min_h = min(min_h, h)
max_w = max(max_w, w)
max_h = max(max_h, h)
if (max_x - min_x) <= 2 and (max_y - min_y) <= 2:
return None
bounding_crop = f"crop={max_x - min_x}:{max_y - min_y}:{min_x}:{min_y}"
return bounding_crop
def _analyze_video_cropdetect(input_file, duration, width, height, num_workers, significant_crop_threshold, min_crop, debug=False):
num_tasks = num_workers * 4
segment_duration = max(1, duration // num_tasks)
tasks = [(i * segment_duration, input_file, width, height) for i in range(num_tasks)]
crop_results = []
with _multiprocessing_cropdetect.Pool(processes=num_workers) as pool:
results_iterator = pool.imap_unordered(_analyze_segment_cropdetect, tasks)
for result in results_iterator:
crop_results.append(result)
all_crops_with_ts = [crop for sublist in crop_results for crop in sublist]
all_crop_strings = [item[0] for item in all_crops_with_ts]
if not all_crop_strings:
return None
crop_counts = _Counter_cropdetect(all_crop_strings)
clusters = _cluster_crop_values_cropdetect(crop_counts)
total_detections = sum(c['count'] for c in clusters)
significant_clusters = []
for cluster in clusters:
percentage = (cluster['count'] / total_detections) * 100
if percentage >= significant_crop_threshold:
significant_clusters.append(cluster)
for cluster in significant_clusters:
parsed_crop = _parse_crop_string_cropdetect(cluster['center'])
if parsed_crop:
_, ar_label = _snap_to_known_ar_cropdetect(
parsed_crop['w'], parsed_crop['h'], parsed_crop['x'], parsed_crop['y'], width, height
)
cluster['ar_label'] = ar_label
else:
cluster['ar_label'] = None
if not significant_clusters:
return None
elif len(significant_clusters) == 1:
dominant_cluster = significant_clusters[0]
parsed_crop = _parse_crop_string_cropdetect(dominant_cluster['center'])
snapped_crop, ar_label = _snap_to_known_ar_cropdetect(
parsed_crop['w'], parsed_crop['h'], parsed_crop['x'], parsed_crop['y'], width, height
)
parsed_snapped = _parse_crop_string_cropdetect(snapped_crop)
if parsed_snapped and parsed_snapped['w'] == width and parsed_snapped['h'] == height:
return None
else:
return snapped_crop
else:
crop_keys = [c['center'] for c in significant_clusters]
bounding_box_crop = _calculate_bounding_box_cropdetect(crop_keys)
if bounding_box_crop:
parsed_bb = _parse_crop_string_cropdetect(bounding_box_crop)
snapped_crop, ar_label = _snap_to_known_ar_cropdetect(
parsed_bb['w'], parsed_bb['h'], parsed_bb['x'], parsed_bb['y'], width, height
)
parsed_snapped = _parse_crop_string_cropdetect(snapped_crop)
if parsed_snapped and parsed_snapped['w'] == width and parsed_snapped['h'] == height:
return None
else:
return snapped_crop
else:
return None
def detect_autocrop_filter(input_file, significant_crop_threshold=5.0, min_crop=10, debug=False):
if not _check_prerequisites_cropdetect():
return None
try:
probe_duration_args = [
'ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1',
input_file
]
duration_str = subprocess.check_output(probe_duration_args, stderr=subprocess.STDOUT, text=True)
duration = int(float(duration_str))
probe_res_args = [
'ffprobe', '-v', 'error',
'-select_streams', 'v',
'-show_entries', 'stream=width,height,disposition',
'-of', 'json',
input_file
]
probe_output = subprocess.check_output(probe_res_args, stderr=subprocess.STDOUT, text=True)
streams_data = json.loads(probe_output)
video_stream = None
for stream in streams_data.get('streams', []):
if stream.get('disposition', {}).get('attached_pic', 0) == 0:
video_stream = stream
break
if not video_stream or 'width' not in video_stream or 'height' not in video_stream:
return None
width = int(video_stream['width'])
height = int(video_stream['height'])
except Exception:
return None
return _analyze_video_cropdetect(input_file, duration, width, height, max(1, os.cpu_count() // 2), significant_crop_threshold, min_crop, debug)
def main(no_downmix=False, autocrop=False): def main(no_downmix=False, autocrop=False):
"""Main batch-processing loop for MKV files."""
check_tools() check_tools()
current_dir = Path(".") current_dir = Path(".")
files_to_process = sorted( files_to_process = sorted(
@@ -285,7 +491,6 @@ def main(no_downmix=False, autocrop=False):
is_vfr = False is_vfr = False
target_cfr_fps_for_handbrake = None target_cfr_fps_for_handbrake = None
video_track_info = None video_track_info = None
# Find the video track in MediaInfo
if media_info.get("media") and media_info["media"].get("track"): if media_info.get("media") and media_info["media"].get("track"):
for track in media_info["media"]["track"]: for track in media_info["media"]["track"]:
if track.get("@type") == "Video": if track.get("@type") == "Video":
@@ -320,7 +525,7 @@ def main(no_downmix=False, autocrop=False):
print(f" - Warning: Could not parse fractional FPS '{target_cfr_fps_for_handbrake}'. HandBrakeCLI might fail.") print(f" - Warning: Could not parse fractional FPS '{target_cfr_fps_for_handbrake}'. HandBrakeCLI might fail.")
is_vfr = False is_vfr = False
else: else:
print(" - Warning: VFR detected, but could not determine target CFR from MediaInfo. Will attempt encode without HandBrake.") print(" - Warning: VFR detected, but could not determine target CFR from MediaInfo. Will attempt standard UTVideo conversion without HandBrake.")
is_vfr = False is_vfr = False
else: else:
print(f" - Video appears to be CFR or FrameRate_Mode not specified as VFR/Variable by MediaInfo.") print(f" - Video appears to be CFR or FrameRate_Mode not specified as VFR/Variable by MediaInfo.")
@@ -341,8 +546,10 @@ def main(no_downmix=False, autocrop=False):
audio_tracks_to_remux = [] audio_tracks_to_remux = []
audio_streams = [s for s in ffprobe_info.get("streams", []) if s.get("codec_type") == "audio"] audio_streams = [s for s in ffprobe_info.get("streams", []) if s.get("codec_type") == "audio"]
# Build mkvmerge track mapping by track ID
mkv_audio_tracks = {t["id"]: t for t in mkv_info.get("tracks", []) if t.get("type") == "audio"} mkv_audio_tracks = {t["id"]: t for t in mkv_info.get("tracks", []) if t.get("type") == "audio"}
# Build mediainfo track mapping by StreamOrder
media_tracks_data = media_info.get("media", {}).get("track", []) media_tracks_data = media_info.get("media", {}).get("track", [])
mediainfo_audio_tracks = {int(t.get("StreamOrder", -1)): t for t in media_tracks_data if t.get("@type") == "Audio"} mediainfo_audio_tracks = {int(t.get("StreamOrder", -1)): t for t in media_tracks_data if t.get("@type") == "Audio"}
@@ -352,23 +559,27 @@ def main(no_downmix=False, autocrop=False):
channels = stream.get("channels", 2) channels = stream.get("channels", 2)
language = stream.get("tags", {}).get("language", "und") language = stream.get("tags", {}).get("language", "und")
# Find mkvmerge track by matching ffprobe stream index to mkvmerge track's 'properties'->'stream_id'
mkv_track = None mkv_track = None
for t in mkv_info.get("tracks", []): for t in mkv_info.get("tracks", []):
if t.get("type") == "audio" and t.get("properties", {}).get("stream_id") == stream_index: if t.get("type") == "audio" and t.get("properties", {}).get("stream_id") == stream_index:
mkv_track = t mkv_track = t
break break
if not mkv_track: if not mkv_track:
# Fallback: try by position
mkv_track = mkv_info.get("tracks", [])[stream_index] if stream_index < len(mkv_info.get("tracks", [])) else {} mkv_track = mkv_info.get("tracks", [])[stream_index] if stream_index < len(mkv_info.get("tracks", [])) else {}
track_id = mkv_track.get("id", -1) track_id = mkv_track.get("id", -1)
track_title = mkv_track.get("properties", {}).get("track_name", "") track_title = mkv_track.get("properties", {}).get("track_name", "")
# Find mediainfo track by StreamOrder
audio_track_info = mediainfo_audio_tracks.get(stream_index) audio_track_info = mediainfo_audio_tracks.get(stream_index)
track_delay = 0 track_delay = 0
delay_raw = audio_track_info.get("Video_Delay") if audio_track_info else None delay_raw = audio_track_info.get("Video_Delay") if audio_track_info else None
if delay_raw is not None: if delay_raw is not None:
try: try:
delay_val = float(delay_raw) delay_val = float(delay_raw)
# If the value is a float < 1, it's seconds, so convert to ms.
if delay_val < 1: if delay_val < 1:
track_delay = int(round(delay_val * 1000)) track_delay = int(round(delay_val * 1000))
else: else:
@@ -380,6 +591,7 @@ def main(no_downmix=False, autocrop=False):
if codec in REMUX_CODECS: if codec in REMUX_CODECS:
audio_tracks_to_remux.append(str(track_id)) audio_tracks_to_remux.append(str(track_id))
else: else:
# Convert any codec that is not in REMUX_CODECS
opus_file = convert_audio_track( opus_file = convert_audio_track(
stream_index, channels, language, audio_temp_dir, str(input_file_abs), not no_downmix stream_index, channels, language, audio_temp_dir, str(input_file_abs), not no_downmix
) )
@@ -392,6 +604,7 @@ def main(no_downmix=False, autocrop=False):
print("--- Finished Audio Processing ---") print("--- Finished Audio Processing ---")
# Final mux
print("Assembling final file with mkvmerge...") print("Assembling final file with mkvmerge...")
mkvmerge_args = ["mkvmerge", "-o", str(intermediate_output_file), str(encoded_video_file)] mkvmerge_args = ["mkvmerge", "-o", str(intermediate_output_file), str(encoded_video_file)]
for file_info in processed_audio_files: for file_info in processed_audio_files:
@@ -409,12 +622,19 @@ def main(no_downmix=False, autocrop=False):
mkvmerge_args += source_copy_args + [str(input_file_abs)] mkvmerge_args += source_copy_args + [str(input_file_abs)]
run_cmd(mkvmerge_args) run_cmd(mkvmerge_args)
# Move files
print("Moving files to final destinations...") print("Moving files to final destinations...")
shutil.move(str(file_path), DIR_ORIGINAL / file_path.name) shutil.move(str(file_path), DIR_ORIGINAL / file_path.name)
shutil.move(str(intermediate_output_file), DIR_COMPLETED / file_path.name) shutil.move(str(intermediate_output_file), DIR_COMPLETED / file_path.name)
print("Cleaning up persistent video temporary files (after successful processing)...") print("Cleaning up persistent video temporary files (after successful processing)...")
video_temp_files_on_success = [encoded_video_file] video_temp_files_on_success = [
current_dir / f"{file_path.stem}.txt",
current_dir / f"{file_path.stem}.vpy",
current_dir / f"{file_path.stem}.ut.mkv",
current_dir / f"temp-{file_path.stem}.mkv", # This is encoded_video_file
current_dir / f"{file_path.stem}.ut.mkv.lwi",
]
if handbrake_intermediate_for_cleanup and handbrake_intermediate_for_cleanup.exists(): if handbrake_intermediate_for_cleanup and handbrake_intermediate_for_cleanup.exists():
video_temp_files_on_success.append(handbrake_intermediate_for_cleanup) video_temp_files_on_success.append(handbrake_intermediate_for_cleanup)
@@ -426,40 +646,47 @@ def main(no_downmix=False, autocrop=False):
print(f" Skipping (not found): {temp_vid_file}") print(f" Skipping (not found): {temp_vid_file}")
except Exception as e: except Exception as e:
print(f"ERROR: An error occurred while processing '{file_path.name}': {e}", file=sys.stderr) print(f"ERROR: An error occurred while processing '{file_path.name}': {e}", file=sys.stderr) # Goes to log
original_stderr_console.write(f"ERROR during processing of '{file_path.name}': {e}\nSee log '{log_file_path}' for details.\n") original_stderr_console.write(f"ERROR during processing of '{file_path.name}': {e}\nSee log '{log_file_path}' for details.\n")
processing_error_occurred = True processing_error_occurred = True
finally: finally:
# This is the original 'finally' block. Its prints go to the log file.
print("--- Starting Universal Cleanup (for this file) ---") print("--- Starting Universal Cleanup (for this file) ---")
print(" - Cleaning up disposable audio temporary directory...") print(" - Cleaning up disposable audio temporary directory...")
if audio_temp_dir and Path(audio_temp_dir).exists(): if audio_temp_dir and Path(audio_temp_dir).exists():
shutil.rmtree(audio_temp_dir, ignore_errors=True) shutil.rmtree(audio_temp_dir, ignore_errors=True)
print(f" - Deleted audio temp dir: {audio_temp_dir}") print(f" - Deleted audio temp dir: {audio_temp_dir}")
elif audio_temp_dir: elif audio_temp_dir: # Was created but now not found
print(f" - Audio temp dir not found or already cleaned: {audio_temp_dir}") print(f" - Audio temp dir not found or already cleaned: {audio_temp_dir}")
else: else: # Was never created
print(f" - Audio temp dir was not created.") print(f" - Audio temp dir was not created.")
print(" - Cleaning up intermediate output file (if it wasn't moved on success)...") print(" - Cleaning up intermediate output file (if it wasn't moved on success)...")
if intermediate_output_file.exists(): if intermediate_output_file.exists(): # Check if it still exists (e.g. error before move)
if processing_error_occurred: if processing_error_occurred:
print(f" - WARNING: Processing error occurred. Intermediate output file '{intermediate_output_file}' is being preserved at its original path for inspection.") print(f" - WARNING: Processing error occurred. Intermediate output file '{intermediate_output_file}' is being preserved at its original path for inspection.")
else: else:
# No processing error, so it should have been moved.
# If it's still here, it's unexpected but we'll clean it up.
print(f" - INFO: Intermediate output file '{intermediate_output_file}' found at original path despite no errors (expected to be moved). Cleaning up.") print(f" - INFO: Intermediate output file '{intermediate_output_file}' found at original path despite no errors (expected to be moved). Cleaning up.")
intermediate_output_file.unlink(missing_ok=True) intermediate_output_file.unlink(missing_ok=True) # Only unlink if no error and it exists
print(f" - Deleted intermediate output file from original path: {intermediate_output_file}") print(f" - Deleted intermediate output file from original path: {intermediate_output_file}")
else: else:
# File does not exist at original path
if not processing_error_occurred: if not processing_error_occurred:
print(f" - Intermediate output file successfully moved (not found at original path, as expected): {intermediate_output_file}") print(f" - Intermediate output file successfully moved (not found at original path, as expected): {intermediate_output_file}")
else: else:
print(f" - Processing error occurred, and intermediate output file '{intermediate_output_file}' not found at original path (likely not created or cleaned by another step).") print(f" - Processing error occurred, and intermediate output file '{intermediate_output_file}' not found at original path (likely not created or cleaned by another step).")
# --- End of original per-file processing block ---
print(f"FINISHED LOG FOR: {file_path.name}") print(f"FINISHED LOG FOR: {file_path.name}")
# --- End of log-specific messages ---
finally: finally: # Outer finally for restoring stdout/stderr and closing log file
runtime = datetime.now() - date_for_runtime_calc runtime = datetime.now() - date_for_runtime_calc
runtime_str = str(runtime).split('.')[0] runtime_str = str(runtime).split('.')[0]
# This print goes to the log file, as stdout is not yet restored.
print(f"\nTotal runtime for this file: {runtime_str}") print(f"\nTotal runtime for this file: {runtime_str}")
if sys.stdout != original_stdout_console: if sys.stdout != original_stdout_console:
@@ -469,6 +696,7 @@ def main(no_downmix=False, autocrop=False):
if log_file_handle: if log_file_handle:
log_file_handle.close() log_file_handle.close()
# Announce to console (original stdout/stderr) that this file is done
if processing_error_occurred: if processing_error_occurred:
original_stderr_console.write(f"File: {file_path.name}\n") original_stderr_console.write(f"File: {file_path.name}\n")
original_stderr_console.write(f"Log: {log_file_path}\n") original_stderr_console.write(f"Log: {log_file_path}\n")
@@ -480,7 +708,7 @@ def main(no_downmix=False, autocrop=False):
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
parser = argparse.ArgumentParser(description="Batch-process MKV files with ffmpeg+SvtAv1EncApp, audio downmixing, per-file logging, and optional autocrop.") parser = argparse.ArgumentParser(description="Batch-process MKV files with resumable video encoding, audio downmixing, per-file logging, and optional autocrop.")
parser.add_argument("--no-downmix", action="store_true", help="Preserve original audio channel layout.") parser.add_argument("--no-downmix", action="store_true", help="Preserve original audio channel layout.")
parser.add_argument("--autocrop", action="store_true", help="Automatically detect and crop black bars from video using cropdetect.") parser.add_argument("--autocrop", action="store_true", help="Automatically detect and crop black bars from video using cropdetect.")
args = parser.parse_args() args = parser.parse_args()