36 Commits

Author SHA1 Message Date
da56bc3a81 Removed extra scene-detection step 2025-08-29 07:38:49 +02:00
1c371e80a8 don't add metadata when extracting audio 2025-08-28 12:59:39 +02:00
17afcb1579 adjusted the workers, "lp" and progress output 2025-08-28 12:02:06 +02:00
359ff6f04c level of parallelism 2025-08-28 11:52:48 +02:00
ba05642ad6 Double line print 2025-08-28 11:37:20 +02:00
6cfdb40e7f New changes to code 2025-08-28 11:34:21 +02:00
d325866162 Again some updates for Svt-Parameters 2025-08-28 11:25:31 +02:00
4b3f087104 CleaneUr Output ; Updates Svt-Params 2025-08-28 11:23:14 +02:00
665272ffc9 Updated script to include Svt-Av1-Essentials 2025-08-28 11:01:14 +02:00
8bc672ca6b added option for mono audio 2025-08-09 16:34:06 +02:00
5f108ade66 fix for negative crop with autocrop 2025-08-06 13:43:00 +02:00
9eaf823ffc Updated "LICENSE.md" 2025-08-02 08:30:35 +02:00
e9352ed6a0 Integration of cropdetec-logic into anime_audio_encoder and tv_audio_encoder. Rewrite of readmes 2025-07-20 22:30:45 +02:00
ba4eac1775 error in crop-detect logic 2025-07-20 10:12:29 +02:00
a9adbc5954 change of logic for cropdetect 2025-07-19 16:25:05 +02:00
98ea4baaad Bug when a simple "remux" should be done 2025-07-19 10:49:46 +02:00
7452a97e64 readme was cut off 2025-07-17 16:29:50 +02:00
d2c08e92eb added cropdetect script 2025-07-17 16:24:06 +02:00
c4a80e4818 again: another Readme update 2025-07-17 12:28:19 +02:00
ea9aa045c8 adjusted output folder bahavior 2025-07-17 12:13:59 +02:00
d357aaac80 updated logic about folder creation 2025-07-17 12:05:21 +02:00
1ea5cb77ff updated readme for latest change 2025-07-17 11:39:21 +02:00
2690340101 Added logging to audio-processing 2025-07-17 11:36:31 +02:00
14f40ef781 Added new script, adjusted README's 2025-07-17 11:20:56 +02:00
7c3d079cbe updated "readme" for better formating 2025-07-17 10:48:37 +02:00
09f5aa2d38 Updated readme's 2025-07-17 10:44:04 +02:00
e9454edafc Update README_Anime Audio Encoder.md 2025-07-17 10:36:31 +02:00
5a7076c117 added "logging" 2025-07-17 10:13:49 +02:00
8d2d272185 improvements by Gemini 2.5 Pro 2025-07-17 10:00:54 +02:00
583838c4f0 Remove unused variables and redundant code.
Consider using more robust mapping between ffprobe, mkvmerge, and mediainfo tracks (by language, title, or other tags if possible).
2025-07-17 09:54:49 +02:00
b53e79e005 changes in audio delay processing 2025-07-17 09:52:04 +02:00
d953574733 bug fix: one AI gives faulty error, another AI fixes it 2025-07-17 08:58:52 +02:00
79dd5316fc next change in audio processing 2025-07-17 08:54:13 +02:00
17bb9d7d0a Changed audio processing of input codecs 2025-07-17 08:03:31 +02:00
ccf43ab7fb Merge pull request 'Changed the loop to recheck for newly added MKV files' (#1) from version1 into main
Reviewed-on: #1
2025-07-17 07:52:15 +02:00
b7250156b4 Changed the loop to recheck for newly added MKV files 2025-07-17 07:48:01 +02:00
10 changed files with 1762 additions and 282 deletions

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 [Copyright Holder]
Copyright (c) 2024 The Encoding Scripts Project Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

313
MkvOpusEnc.py Normal file
View File

@@ -0,0 +1,313 @@
#!/usr/bin/env python3
"""
Processes or downmixes an MKV file's audio tracks sequentially using a specific toolchain.
This script is cross-platform and optimized for correctness and clean output.
This script intelligently handles audio streams in an MKV file one by one.
- AAC/Opus audio is remuxed.
- Multi-channel audio (DTS, AC3, etc.) can be re-encoded or optionally downmixed to stereo.
- All other streams and metadata (title, language, delay) are preserved.
"""
import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
class Tee:
def __init__(self, *files):
self.files = files
def write(self, obj):
for f in self.files:
f.write(obj)
f.flush()
def flush(self):
for f in self.files:
f.flush()
def check_tools():
"""Checks if all required command-line tools are in the system's PATH."""
required_tools = ["ffmpeg", "ffprobe", "mkvmerge", "sox", "opusenc", "mediainfo"]
print("--- Prerequisite Check ---")
all_found = True
for tool in required_tools:
if not shutil.which(tool):
print(f"Error: Required tool '{tool}' not found.", file=sys.stderr)
all_found = False
if not all_found:
sys.exit("Please install the missing tools and ensure they are in your system's PATH.")
print("All required tools found.")
def run_cmd(args, capture_output=False, check=True):
"""Helper function to run a command and return its output."""
process = subprocess.run(args, capture_output=capture_output, text=True, encoding='utf-8', check=check)
return process.stdout
def convert_audio_track(stream_index, channels, temp_dir, source_file, should_downmix, bitrate_info):
"""Extracts, normalizes, and encodes a single audio track to Opus."""
temp_extracted = temp_dir / f"track_{stream_index}_extracted.flac"
temp_normalized = temp_dir / f"track_{stream_index}_normalized.flac"
final_opus = temp_dir / f"track_{stream_index}_final.opus"
# Step 1: Extract audio, with conditional downmixing
print(" - Extracting to FLAC...")
ffmpeg_args = ["ffmpeg", "-v", "quiet", "-stats", "-y", "-i", str(source_file), "-map", f"0:{stream_index}"]
final_channels = channels
if should_downmix and channels >= 6:
if channels == 6: # 5.1
print(" (Downmixing 5.1 to Stereo with dialogue boost)")
ffmpeg_args.extend(["-af", "pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5"])
final_channels = 2
elif channels == 8: # 7.1
print(" (Downmixing 7.1 to Stereo with dialogue boost)")
ffmpeg_args.extend(["-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"])
final_channels = 2
else:
print(f" ({channels}-channel source, downmixing to stereo using default -ac 2)")
ffmpeg_args.extend(["-ac", "2"])
final_channels = 2
else:
print(f" (Preserving {channels}-channel layout)")
ffmpeg_args.extend(["-c:a", "flac", str(temp_extracted)])
run_cmd(ffmpeg_args)
# Step 2: Normalize the track with SoX
print(" - Normalizing with SoX...")
run_cmd(["sox", str(temp_extracted), str(temp_normalized), "-S", "--temp", str(temp_dir), "--guard", "gain", "-n"])
# Step 3: Encode to Opus with the correct bitrate
bitrate = "192k" # Fallback
if final_channels == 1:
bitrate = "64k"
elif final_channels == 2:
bitrate = "128k"
elif final_channels == 6:
bitrate = "256k"
elif final_channels == 8:
bitrate = "384k"
print(f" - Encoding to Opus at {bitrate}...")
print(f" Source: {bitrate_info} -> Destination: Opus {bitrate} ({final_channels} channels)")
run_cmd(["opusenc", "--vbr", "--bitrate", bitrate, str(temp_normalized), str(final_opus)])
return final_opus, final_channels, bitrate
def main():
"""Main script logic."""
parser = argparse.ArgumentParser(description="Batch processes MKV file audio tracks to Opus.")
parser.add_argument("--downmix", action="store_true", help="If present, multi-channel audio will be downmixed to stereo.")
args = parser.parse_args()
check_tools()
# Define directory paths but don't create them yet
DIR_COMPLETED = Path("completed")
DIR_ORIGINAL = Path("original")
DIR_LOGS = Path("conv_logs")
current_dir = Path(".")
# Check if there are any MKV files to process
files_to_process = sorted(
f for f in current_dir.glob("*.mkv")
if not f.name.startswith("temp-output-")
)
if not files_to_process:
print("No MKV files found to process. Exiting.")
return # Exit without creating directories
# Create directories only when we actually have files to process
DIR_COMPLETED.mkdir(exist_ok=True)
DIR_ORIGINAL.mkdir(exist_ok=True)
DIR_LOGS.mkdir(exist_ok=True)
for file_path in files_to_process:
# Setup logging
log_file_path = DIR_LOGS / f"{file_path.name}.log"
log_file = open(log_file_path, 'w', encoding='utf-8')
original_stdout = sys.stdout
original_stderr = sys.stderr
sys.stdout = Tee(original_stdout, log_file)
sys.stderr = Tee(original_stderr, log_file)
try:
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
print(f"Starting processing for: {file_path.name}")
print(f"Log file: {log_file_path}")
start_time = datetime.now()
intermediate_output_file = current_dir / f"temp-output-{file_path.name}"
temp_dir = Path(tempfile.mkdtemp(prefix="mkvopusenc_"))
print(f"Temporary directory for audio created at: {temp_dir}")
# 3. --- Get Media Information ---
print(f"Analyzing file: {file_path}")
ffprobe_info_json = run_cmd(["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(file_path)], capture_output=True)
ffprobe_info = json.loads(ffprobe_info_json)
mkvmerge_info_json = run_cmd(["mkvmerge", "-J", str(file_path)], capture_output=True)
mkv_info = json.loads(mkvmerge_info_json)
mediainfo_json_str = run_cmd(["mediainfo", "--Output=JSON", "-f", str(file_path)], capture_output=True)
media_info = json.loads(mediainfo_json_str)
# 4. --- Prepare for Final mkvmerge Command ---
processed_audio_files = []
tids_of_reencoded_tracks = []
# 5. --- Process Each Audio Stream ---
audio_streams = [s for s in ffprobe_info.get("streams", []) if s.get("codec_type") == "audio"]
# Check if the file has any audio streams
if not audio_streams:
print(f"Warning: No audio streams found in '{file_path.name}'. Skipping file.")
continue
mkv_tracks_list = mkv_info.get("tracks", [])
mkv_audio_tracks = [t for t in mkv_tracks_list if t.get("type") == "audio"]
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"}
print("\n=== Audio Track Analysis ===")
for audio_stream_idx, stream in enumerate(audio_streams):
stream_index = stream["index"]
codec = stream.get("codec_name")
channels = stream.get("channels", 2)
language = stream.get("tags", {}).get("language", "und")
track_id = -1
mkv_track = {}
if audio_stream_idx < len(mkv_audio_tracks):
mkv_track = mkv_audio_tracks[audio_stream_idx]
track_id = mkv_track.get("id", -1)
if track_id == -1:
print(f" -> Warning: Could not map ffprobe audio stream index {stream_index} to an mkvmerge track ID. Skipping this track.")
continue
track_title = mkv_track.get("properties", {}).get("track_name", "")
track_delay = 0
audio_track_info = mediainfo_audio_tracks.get(stream_index)
# Get bitrate information from mediainfo
bitrate = "Unknown"
if audio_track_info:
if "BitRate" in audio_track_info:
try:
br_value = int(audio_track_info["BitRate"])
bitrate = f"{int(br_value/1000)}k"
except (ValueError, TypeError):
pass
elif "BitRate_Nominal" in audio_track_info:
try:
br_value = int(audio_track_info["BitRate_Nominal"])
bitrate = f"{int(br_value/1000)}k"
except (ValueError, TypeError):
pass
delay_raw = audio_track_info.get("Video_Delay") if audio_track_info else None
if delay_raw is not None:
try:
delay_val = float(delay_raw)
# If the value is a float < 1, it's seconds, so convert to ms.
if delay_val < 1 and delay_val > -1:
track_delay = int(round(delay_val * 1000))
else:
track_delay = int(round(delay_val))
except Exception:
track_delay = 0
track_info = f"Audio Stream #{stream_index} (TID: {track_id}, Codec: {codec}, Bitrate: {bitrate}, Channels: {channels})"
if track_title:
track_info += f", Title: '{track_title}'"
if language != "und":
track_info += f", Language: {language}"
if track_delay != 0:
track_info += f", Delay: {track_delay}ms"
print(f"\nProcessing {track_info}")
if codec in {"aac", "opus"}:
print(f" -> Action: Remuxing track (keeping original {codec.upper()} {bitrate})")
# This track will be kept from the original file, so we don't need to add it to a special list.
else:
bitrate_info = f"{codec.upper()} {bitrate}"
print(f" -> Action: Re-encoding codec '{codec}' to Opus")
opus_file, final_channels, final_bitrate = convert_audio_track(
stream_index, channels, temp_dir, file_path, args.downmix, bitrate_info
)
processed_audio_files.append({
"Path": opus_file,
"Language": language,
"Title": track_title,
"Delay": track_delay
})
tids_of_reencoded_tracks.append(str(track_id))
# 6. --- Construct and Execute Final mkvmerge Command ---
print("\n=== Final MKV Creation ===")
print("Assembling final mkvmerge command...")
mkvmerge_args = ["mkvmerge", "-o", str(intermediate_output_file)]
# If no audio was re-encoded, we are just doing a full remux of the original file.
if not processed_audio_files:
print(" -> All audio tracks are in the desired format. Performing a full remux.")
mkvmerge_args.append(str(file_path))
else:
# If we re-encoded audio, copy everything from the source EXCEPT the original audio tracks that we replaced.
mkvmerge_args.extend(["--audio-tracks", "!" + ",".join(tids_of_reencoded_tracks)])
mkvmerge_args.append(str(file_path))
# Add the newly encoded Opus audio files.
for file_info in processed_audio_files:
mkvmerge_args.extend(["--language", f"0:{file_info['Language']}"])
if file_info['Title']:
mkvmerge_args.extend(["--track-name", f"0:{file_info['Title']}"])
if file_info['Delay'] != 0:
mkvmerge_args.extend(["--sync", f"0:{file_info['Delay']}"])
mkvmerge_args.append(str(file_info["Path"]))
print(f"Executing mkvmerge...")
run_cmd(mkvmerge_args)
print("MKV creation complete")
# Move files to their final destinations
print("\n=== File Management ===")
print(f"Moving processed file to: {DIR_COMPLETED / file_path.name}")
shutil.move(str(intermediate_output_file), DIR_COMPLETED / file_path.name)
print(f"Moving original file to: {DIR_ORIGINAL / file_path.name}")
shutil.move(str(file_path), DIR_ORIGINAL / file_path.name)
# Display total runtime
runtime = datetime.now() - start_time
runtime_str = str(runtime).split('.')[0] # Remove milliseconds
print(f"\nTotal processing time: {runtime_str}")
except Exception as e:
print(f"\nAn error occurred while processing '{file_path.name}': {e}", file=sys.stderr)
if intermediate_output_file.exists():
intermediate_output_file.unlink()
finally:
# 7. --- Cleanup ---
print("\n=== Cleanup ===")
print("Cleaning up temporary files...")
if temp_dir.exists():
shutil.rmtree(temp_dir)
print("Temporary directory removed.")
# Restore stdout/stderr and close log file
sys.stdout = original_stdout
sys.stderr = original_stderr
log_file.close()
if __name__ == "__main__":
main()

View File

@@ -1,77 +1,15 @@
# Anime Audio Encoder
# Encoding Scripts
## Overview
This is a collection of Python scripts for various video and audio processing tasks, such as encoding video to AV1 and audio to Opus.
anime_audio_encoder.py is a comprehensive batch-processing script for MKV files, specifically tailored for modern anime encoding workflows. It automates the entire pipeline, including advanced video encoding with AV1 (via av1an), sophisticated audio conversion to Opus, and intelligent handling of both Variable Frame Rate (VFR) and Constant Frame Rate (CFR) sources. To ensure transparency and aid in debugging, the script also preserves detailed, per-file logs of the entire conversion process.
## Scripts
## Features
- **[anime_audio_encoder.py](anime_audio_encoder.py)**: A script tailored for encoding anime. It handles Variable Frame Rate (VFR) sources and uses `av1an` for AV1 encoding. Now supports `--autocrop` to automatically crop black bars using cropdetect logic, applied to the UTVideo intermediate file. For more details, see the [Anime Audio Encoder README](README_Anime%20Audio%20Encoder.md).
* **Advanced Video Encoding:** Uses a robust VapourSynth-based pipeline with av1an and SVT-AV1 for efficient, high-quality AV1 video encoding.
- **[tv_audio_encoder.py](tv_audio_encoder.py)**: A script designed for encoding TV show episodes. It uses `alabamaEncoder` for the video encoding process. Now supports `--autocrop` to automatically crop black bars using cropdetect logic, applied to the UTVideo intermediate file. For more details, see the [TV Audio Encoder README](README_TV%20Audio%20Encoder.md).
* **VFR Handling:** Automatically detects Variable Frame Rate (VFR) sources using mediainfo and converts them to Constant Frame Rate (CFR) with HandBrakeCLI before encoding, ensuring broader playback compatibility.
- **[MkvOpusEnc.py](MkvOpusEnc.py)**: A cross-platform script for batch-processing audio tracks in MKV files to the Opus format. For more details, see the [MkvOpusEnc README](README_MkvOpusEnc.md).
* **Detailed Logging:** Creates a separate, detailed log file for each processed MKV in the `conv_logs/` directory, capturing the full terminal output for easy review.
- **[cropdetect.py](cropdetect.py)**: An advanced script for intelligently detecting video crop values using parallel processing and smart heuristics. For more details, see the [Cropdetect README](README_cropdetect.md).
* **Sophisticated Audio Processing:** Converts common audio formats to normalized Opus files. It provides an option to downmix surround sound to stereo or preserve the original channel layout.
* **File Organization:** Keeps your workspace tidy by automatically moving original files to `original/` and completed encodes to `completed/`.
* **Resumable & Efficient:** The script processes files one by one and uses av1an's resume feature, making it easy to continue an encoding batch if it's interrupted.
## Requirements
The following command-line tools must be installed and available in your system's PATH:
* ffmpeg
* ffprobe
* mkvmerge
* mkvpropedit
* sox
* opusenc
* mediainfo
* av1an
* HandBrakeCLI
## Usage
1. Place your `.mkv` files in the same directory as the script.
2. Make the script executable by running `chmod +x anime_audio_encoder.py`.
3. Execute the script from your terminal:
```bash
./anime_audio_encoder.py
```
### Optional Arguments
* `--no-downmix`: By default, the script downmixes surround sound audio (e.g., 5.1) to stereo. Use this flag to preserve the original audio channel layout.
Example:
```bash
./anime_audio_encoder.py --no-downmix
```
## Output
* Processed files are moved to the `completed/` directory.
* Original files are moved to the `original/` directory.
* Per-file logs are saved in the `conv_logs/` directory.
## Notes
* The script will check if a file can be decoded by ffmpeg before processing and will skip corrupted or unsupported files.
* The entire process, especially scene detection and AV1 encoding, can be very time-consuming and CPU-intensive.
For usage instructions, please refer to the individual scripts or the detailed README files.

View File

@@ -0,0 +1,65 @@
# Anime Audio Encoder
## Overview
`anime_audio_encoder.py` is a comprehensive batch-processing script for MKV files, specifically tailored for modern anime encoding workflows. It automates the entire pipeline, including advanced video encoding with AV1 (via `av1an`), sophisticated audio conversion to Opus, and intelligent handling of both Variable Frame Rate (VFR) and Constant Frame Rate (CFR) sources. To ensure transparency and aid in debugging, the script also preserves detailed, per-file logs of the entire conversion process.
## Features
* **Advanced Video Encoding:** Uses a robust VapourSynth-based pipeline with `av1an` and SVT-AV1 for efficient, high-quality AV1 video encoding.
* **VFR Handling:** Automatically detects Variable Frame Rate (VFR) sources using `mediainfo` and converts them to Constant Frame Rate (CFR) with `HandBrakeCLI` before encoding, ensuring broader playback compatibility.
* **Detailed Logging:** Creates a separate, detailed log file for each processed MKV in the `conv_logs/` directory, capturing the full terminal output for easy review.
* **Sophisticated Audio Processing:** Converts common audio formats to normalized Opus files. It provides an option to downmix surround sound to stereo or preserve the original channel layout.
* **File Organization:** Keeps your workspace tidy by automatically moving original files to `original/` and completed encodes to `completed/`.
* **Resumable & Efficient:** The script processes files one by one and uses `av1an`'s resume feature, making it easy to continue an encoding batch if it's interrupted.
## Requirements
The following command-line tools must be installed and available in your system's PATH:
* `ffmpeg`
* `ffprobe`
* `mkvmerge`
* `mkvpropedit`
* `sox`
* `opusenc`
* `mediainfo`
* `av1an`
* `HandBrakeCLI`
## Usage
1. Place your `.mkv` files in the same directory as the script.
2. Make the script executable (on Linux/macOS) by running `chmod +x anime_audio_encoder.py`.
3. Execute the script from your terminal:
```bash
./anime_audio_encoder.py
```
### Optional Arguments
* `--no-downmix`: By default, the script downmixes surround sound audio (e.g., 5.1) to stereo. Use this flag to preserve the original audio channel layout.
```bash
./anime_audio_encoder.py --no-downmix
```
* `--autocrop`: Automatically detect and crop black bars from video using cropdetect. The crop is applied only to the UTVideo intermediate file, ensuring no image data is lost even with variable crops.
```bash
./anime_audio_encoder.py --autocrop
```
You can combine with `--no-downmix`:
```bash
./anime_audio_encoder.py --autocrop --no-downmix
```
## Output
* Processed files are moved to the `completed/` directory.
* Original files are moved to the `original/` directory.
* Per-file logs are saved in the `conv_logs/` directory.
## Notes
* The script is primarily designed for **Linux/macOS** environments.
* The script will check if a file can be decoded by `ffmpeg` before processing and will skip corrupted or unsupported files.
* The entire process, especially scene detection and AV1 encoding, can be very time-consuming and

58
README_MkvOpusEnc.md Normal file
View File

@@ -0,0 +1,58 @@
# MkvOpusEnc
## Overview
`MkvOpusEnc.py` is a cross-platform Python script designed for batch-processing the audio tracks within MKV files. It automatically scans the current directory for MKV files and processes them sequentially. The script intelligently converts various audio codecs to the highly efficient Opus format while preserving all other tracks (video, subtitles, etc.) and metadata.
## Features
* **Automated Batch Processing:** Automatically finds and processes all MKV files in its directory, one by one.
* **Intelligent Codec Handling:**
* Remuxes existing `AAC` and `Opus` tracks without re-encoding to preserve quality.
* Re-encodes all other audio formats (DTS, AC3, TrueHD, FLAC, etc.) to Opus.
* **Advanced Downmixing:** Includes an optional `--downmix` flag that converts multi-channel audio (5.1, 7.1) to stereo using a dialogue-boosting formula.
* **Audio Normalization:** Uses `SoX` to normalize audio levels for a consistent listening experience.
* **Metadata Preservation:** Carefully preserves audio track metadata such as titles, language tags, and delay/sync information.
* **Detailed Logging:** Creates a separate, detailed log file for each processed MKV in the `conv_logs/` directory, capturing the full terminal output and conversion details for easy review.
* **File Organization:** Automatically moves the original source files to an `original/` directory and the newly processed files to a `completed/` directory, keeping your workspace clean.
* **Cross-Platform:** As a Python script using common command-line tools, it is designed to work on Windows, macOS, and Linux.
## Requirements
The following command-line tools must be installed and available in your system's PATH:
* `ffmpeg`
* `ffprobe`
* `mkvmerge`
* `sox`
* `opusenc`
* `mediainfo`
## Usage
1. Place your `.mkv` files in the same directory as the script.
2. Execute the script from your terminal:
```bash
python MkvOpusEnc.py
```
### Optional Arguments
* `--downmix`: By default, the script preserves the original audio channel layout. Use this flag to downmix multi-channel audio to stereo.
Example:
```bash
python MkvOpusEnc.py --downmix
```
## Output
* Processed files are moved to the `completed/` directory.
* Original files are moved to the `original/` directory.
* Per-file logs are saved in the `conv_logs/` directory, containing detailed information about:
* Original audio track properties (codec, bitrate, channels)
* Track titles, languages, and delay information
* Conversion details for each track, including target bitrates
* Any errors or warnings encountered during processing

View File

@@ -0,0 +1,73 @@
# TV Audio Encoder
## Overview
`tv_audio_encoder.py` is a comprehensive batch-processing script for MKV files, specifically designed for encoding TV show episodes. It automates the entire pipeline, including VMAF-targeted video encoding with AV1 (via `alabamaEncoder`) and sophisticated audio conversion to Opus. To ensure transparency and aid in debugging, the script also preserves detailed, per-file logs of the entire conversion process.
## Features
* **Advanced Video Encoding:** Uses `alabamaEncoder` for a simplified yet powerful VMAF-targeted AV1 encoding workflow, aiming for consistent quality.
* **Stable Workflow:** Creates a lossless UTVideo intermediate file from the source video, providing a stable and reliable input for the main encoding process.
* **Detailed Logging:** Creates a separate, detailed log file for each processed MKV in the `conv_logs/` directory, capturing the full terminal output for easy review.
* **Sophisticated Audio Processing:** Converts common audio formats to normalized Opus files. It provides an option to downmix surround sound to stereo or preserve the original channel layout.
* **File Organization:** Keeps your workspace tidy by automatically moving original files to `original/` and completed encodes to `completed/`.
* **Platform Specificity:** The script is designed for Linux systems, as `alabamaEncoder` is not supported on Windows.
## Requirements
The following command-line tools must be installed and available in your system's PATH:
* `ffmpeg`
* `ffprobe`
* `mkvmerge`
* `mkvpropedit`
* `sox`
* `opusenc`
* `mediainfo`
* `alabamaEncoder`
## Usage
1. Place your `.mkv` files in the same directory as the script.
2. Make the script executable by running `chmod +x tv_audio_encoder.py`.
3. Execute the script from your terminal:
```bash
./tv_audio_encoder.py
```
### Optional Arguments
* `--no-downmix`: By default, the script downmixes surround sound audio (e.g., 5.1, 7.1) to stereo. Use this flag to preserve the original audio channel layout.
Example:
```bash
./tv_audio_encoder.py --no-downmix
```
* `--autocrop`: Automatically detect and crop black bars from video using cropdetect. The crop is applied only to the UTVideo intermediate file, ensuring no image data is lost even with variable crops.
Example:
```bash
./tv_audio_encoder.py --autocrop
```
You can combine with `--no-downmix`:
```bash
./tv_audio_encoder.py --autocrop --no-downmix
```
## Output
* Processed files are moved to the `completed/` directory.
* Original files are moved to the `original/` directory.
* Per-file logs are saved in the `conv_logs/` directory.
## Notes
* This script is intended for use on **Linux** only.
* The entire process, especially the AV1 encoding, can be very time-consuming and CPU-intensive. Be prepared for long processing times, especially on large files or less powerful machines.
* Consider testing with a single file first to fine-tune your desired settings before batch processing a large library of videos.

76
README_cropdetect.md Normal file
View File

@@ -0,0 +1,76 @@
# Advanced Crop Detection Script
This Python script (`cropdetect.py`) provides robust, parallelized, and intelligent crop detection for video files. It is much more than a simple wrapper for `ffmpeg-cropdetect`—it uses parallel processing, aspect ratio heuristics, luma verification, and bounding box logic to recommend safe crop values, even for complex videos with mixed aspect ratios.
## Key Features
- **Parallel Processing:** Analyzes video segments in parallel for speed and reliability.
- **Aspect Ratio Snapping:** Automatically snaps detected crops to known cinematic standards (16:9, 2.39:1, 1.85:1, 4:3, IMAX, etc.), correcting minor detection errors.
- **Mixed Aspect Ratio Handling:** Detects and safely handles videos with changing aspect ratios (e.g., IMAX scenes), recommending a bounding box crop that never cuts into image data.
- **Luma Verification:** Discards unreliable crop detections from very dark scenes using a second analysis pass.
- **Credits/Logo Filtering:** Ignores crops that only appear in the first/last 5% of the video, preventing opening logos or credits from affecting the result.
- **No Crop Recommendation:** If the video is overwhelmingly detected as not needing a crop, the script will confidently recommend leaving it as is.
- **User-Friendly Output:** Color-coded recommendations and warnings for easy review.
- **Safe for Automation:** The recommended crop is always the most outer cropable frame, so no image data is lost—even with variable crops.
## Prerequisites
- **Python 3**
- **FFmpeg**: Both `ffmpeg` and `ffprobe` must be installed and in your system's `PATH`.
## Installation
Just save the script as `cropdetect.py` and make it executable if needed.
## Usage
Run the script from your terminal, passing the path to the video file as an argument:
```bash
python cropdetect.py "path/to/your/video.mkv"
```
### Options
- `-n, --num_workers`: Number of parallel worker threads (default: half your CPU cores).
- `-sct, --significant_crop_threshold`: Percentage a crop must be present to be considered significant (default: 5.0).
- `-mc, --min_crop`: Minimum pixels to crop on any side for it to be considered a major crop (default: 10).
- `--debug`: Enable detailed debug logging.
## Example Output
### Confident Crop Recommendation
For a standard widescreen movie:
```
Recommended crop filter: -vf crop=3840:2080:0:40
```
### Mixed Aspect Ratio Warning
For a movie with changing aspect ratios:
```
WARNING: Potentially Mixed Aspect Ratios Detected!
Recommendation: Manually check the video before applying a single crop.
```
### No Crop Needed
For a video that is already perfectly formatted:
```
Recommendation: No crop needed.
```
## Integration with Other Scripts
This crop detection logic is now integrated into `anime_audio_encoder.py` and `tv_audio_encoder.py` via the `--autocrop` option. When enabled, those scripts will automatically detect and apply the safest crop to the UTVideo intermediate file, ensuring no image data is lost—even with variable crops.
## Notes
- The script is safe for automation and batch workflows.
- The recommended crop will never cut into the actual image, only remove black bars.
- For complex videos, a bounding box crop is calculated to contain all significant scenes.
- If no crop is needed, none will be applied.

View File

@@ -19,6 +19,21 @@ DIR_CONV_LOGS = Path("conv_logs") # Directory for conversion logs
REMUX_CODECS = {"aac", "opus"} # Using a set for efficient lookups
SVT_AV1_PARAMS = {
"speed": "slower", # "slower", "slow", "medium", "fast", "faster"
"quality": "medium", # "higher", "high", "medium", "low", "lower"
"film-grain": 6,
"color-primaries": 1,
"transfer-characteristics": 1,
"matrix-coefficients": 1,
"scd": 0, # Scene change detection OFF for Av1an use
"keyint": 0, # Keyframe interval, 0 disables automatic keyframes placement at a constant interval
"lp": 2, # Level of parallelism
"auto-tiling": 1, # Auto tiling ON
"tune": 1, # 0 = VQ, 1 = PSNR, 2 = SSIM
"progress": 2, # Detailed progress output
}
def check_tools():
for tool in REQUIRED_TOOLS:
if shutil.which(tool) is None:
@@ -40,7 +55,7 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
print(f" - Extracting Audio Track #{index} to FLAC...")
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}", "-map_metadata", "-1"
]
if should_downmix and ch >= 6:
if ch == 6:
@@ -68,14 +83,16 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
else:
# Not downmixing (or source is already stereo or less).
# Base bitrate on the source channel count.
if ch == 2: # Stereo
if ch == 1: # Mono
bitrate = "64k"
elif ch == 2: # Stereo
bitrate = "128k"
elif ch == 6: # 5.1 Surround
bitrate = "256k"
elif ch == 8: # 7.1 Surround
bitrate = "384k"
else: # Mono or other layouts
bitrate = "96k" # A sensible default for mono.
else: # Other layouts
bitrate = "96k" # A sensible default for other/uncommon layouts.
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
run_cmd([
@@ -83,10 +100,9 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
])
return final_opus
def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for_handbrake):
def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for_handbrake, autocrop_filter=None):
print(" --- Starting Video Processing ---")
# source_file_base is file_path.stem (e.g., "my.anime.episode.01")
scene_file = Path(f"{source_file_base}.txt")
vpy_file = Path(f"{source_file_base}.vpy")
ut_video_file = Path(f"{source_file_base}.ut.mkv")
encoded_video_file = Path(f"temp-{source_file_base}.mkv")
@@ -143,7 +159,10 @@ def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for
ffmpeg_args = [
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", str(current_input_for_utvideo),
"-map", "0:v:0", "-map_metadata", "-1", "-map_chapters", "-1", "-an", "-sn", "-dn",
] + video_codec_args + [str(ut_video_file)]
]
if autocrop_filter:
ffmpeg_args += ["-vf", autocrop_filter]
ffmpeg_args += video_codec_args + [str(ut_video_file)]
run_cmd(ffmpeg_args)
ut_video_full_path = os.path.abspath(ut_video_file)
vpy_script_content = f"""import vapoursynth as vs
@@ -156,39 +175,19 @@ clip.set_output()
with vpy_file.open("w", encoding="utf-8") as f:
f.write(vpy_script_content)
if not scene_file.exists():
print(" - Performing scene detection with av1an...")
av1an_sc_args = [
"av1an", "-i", str(vpy_file), "-s", str(scene_file), "--sc-only", "--verbose"
]
run_cmd(av1an_sc_args)
else:
print(" - Found existing scene file, skipping detection.")
print(" - Starting AV1 encode with av1an (this will take a long time)...")
total_cores = os.cpu_count() or 4 # Fallback if cpu_count is None
workers = max(total_cores - 2, 1) # Ensure at least 1 worker
print(f" - Using {workers} workers for av1an (Total Cores: {total_cores}).")
workers = max(1, (total_cores // 2) - 1) # Half the cores minus one, with a minimum of 1 worker.
print(f" - Using {workers} workers for av1an (Total Cores: {total_cores}, Logic: (Cores/2)-1).")
svt_av1_params = {
"preset": 2,
"crf": 27,
"film-grain": 6,
"lp": 1,
"tune": 1,
"keyint": -1,
"color-primaries": 1,
"transfer-characteristics": 1,
"matrix-coefficients": 1,
}
# Create the parameter string for av1an's -v option, which expects a single string.
av1an_video_params_str = " ".join([f"--{key} {value}" for key, value in svt_av1_params.items()])
av1an_video_params_str = " ".join([f"--{key} {value}" for key, value in SVT_AV1_PARAMS.items()])
print(f" - Using SVT-AV1 parameters: {av1an_video_params_str}")
av1an_enc_args = [
"av1an", "-i", str(vpy_file), "-o", str(encoded_video_file), "-s", str(scene_file), "-n",
"av1an", "-i", str(vpy_file), "-o", str(encoded_video_file), "-n",
"-e", "svt-av1", "--resume", "--sc-pix-format", "yuv420p", "-c", "mkvmerge",
"--set-thread-affinity", "1", "--pix-format", "yuv420p10le", "--force",
"--set-thread-affinity", "2", "--pix-format", "yuv420p10le", "--force",
"-w", str(workers),
"-v", av1an_video_params_str
]
@@ -207,99 +206,314 @@ def is_ffmpeg_decodable(file_path):
except subprocess.CalledProcessError:
return False
def main(no_downmix=False):
# --- 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_h = min(new_h, video_h)
new_y = round((video_h - new_h) / 2)
if new_y % 2 != 0:
new_y -= 1
new_y = max(0, new_y)
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_w = min(new_w, video_w)
new_x = round((video_w - new_w) / 2)
if new_x % 2 != 0:
new_x -= 1
new_x = max(0, new_x)
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, speed=None, quality=None, grain=None):
check_tools()
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
DIR_ORIGINAL.mkdir(exist_ok=True, parents=True)
DIR_CONV_LOGS.mkdir(exist_ok=True, parents=True) # Create conv_logs directory
# Override default SVT-AV1 params if provided via command line
if speed:
SVT_AV1_PARAMS["speed"] = speed
if quality:
SVT_AV1_PARAMS["quality"] = quality
if grain is not None:
SVT_AV1_PARAMS["film-grain"] = grain
current_dir = Path(".")
files_to_process = sorted(
f for f in current_dir.glob("*.mkv")
if not (f.name.endswith(".ut.mkv") or f.name.startswith("temp-") or f.name.startswith("output-") or f.name.endswith(".cfr_temp.mkv"))
)
if not files_to_process:
print("No MKV files found to process. Exiting.")
return
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
DIR_ORIGINAL.mkdir(exist_ok=True, parents=True)
DIR_CONV_LOGS.mkdir(exist_ok=True, parents=True)
while True:
files_to_process = sorted(
f for f in current_dir.glob("*.mkv")
if not (f.name.endswith(".ut.mkv") or f.name.startswith("temp-") or f.name.startswith("output-") or f.name.endswith(".cfr_temp.mkv"))
)
if not files_to_process:
print("No more .mkv files found to process in the current directory. The script will now exit.")
break
# Process the first file in the list. The list is requeried in the next iteration.
file_path = files_to_process[0]
# --- Add ffmpeg decodability check here ---
if not is_ffmpeg_decodable(file_path):
print(f"ERROR: ffmpeg cannot decode '{file_path.name}'. Skipping this file.", file=sys.stderr)
shutil.move(str(file_path), DIR_ORIGINAL / file_path.name)
continue
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
# This print remains on the console, indicating which file is starting.
# The detailed "Starting full processing for..." will be in the log.
log_file_name = f"{file_path.stem}.log" # Use stem to avoid .mkv.log
log_file_name = f"{file_path.stem}.log"
log_file_path = DIR_CONV_LOGS / log_file_name
original_stdout_console = sys.stdout
original_stderr_console = sys.stderr
# Announce to console (original stdout)
print(f"Processing: {file_path.name}", file=original_stdout_console)
print(f"Logging output to: {log_file_path}", file=original_stdout_console)
log_file_handle = None
processing_error_occurred = False
date_for_runtime_calc = datetime.now() # For runtime calculation
try: # Outer try for log redirection and file handling
date_for_runtime_calc = datetime.now()
try:
log_file_handle = open(log_file_path, 'w', encoding='utf-8')
sys.stdout = log_file_handle
sys.stderr = log_file_handle
# --- Start of log-specific messages ---
print(f"STARTING LOG FOR: {file_path.name}")
print(f"Processing started at: {date_for_runtime_calc}")
print(f"Full input file path: {file_path.resolve()}")
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
input_file_abs = file_path.resolve() # Used by original logic
intermediate_output_file = current_dir / f"output-{file_path.name}" # Used by original logic
audio_temp_dir = None # Initialize before inner try
handbrake_intermediate_for_cleanup = None # Initialize before inner try
# This is the original try...except...finally block for processing a single file.
# All its print statements will now go to the log file.
input_file_abs = file_path.resolve()
intermediate_output_file = current_dir / f"output-{file_path.name}"
audio_temp_dir = None
handbrake_intermediate_for_cleanup = None
try:
audio_temp_dir = tempfile.mkdtemp(prefix="anime_audio_")
print(f"Audio temporary directory created at: {audio_temp_dir}")
print(f"Analyzing file: {input_file_abs}")
ffprobe_info_json = run_cmd([
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(input_file_abs)
], capture_output=True)
ffprobe_info = json.loads(ffprobe_info_json)
mkvmerge_info_json = run_cmd([
"mkvmerge", "-J", str(input_file_abs)
], capture_output=True)
mkv_info = json.loads(mkvmerge_info_json)
mediainfo_json = run_cmd([
"mediainfo", "--Output=JSON", "-f", str(input_file_abs)
], capture_output=True)
media_info = json.loads(mediainfo_json)
is_vfr = False
target_cfr_fps_for_handbrake = None
video_track_info = None
if media_info.get("media") and media_info["media"].get("track"):
for track in media_info["media"]["track"]:
if track.get("@type") == "Video":
video_track_info = track
break
if video_track_info:
frame_rate_mode = video_track_info.get("FrameRate_Mode")
if frame_rate_mode and frame_rate_mode.upper() in ["VFR", "VARIABLE"]:
@@ -310,20 +524,16 @@ def main(no_downmix=False):
match = re.search(r'\((\d+/\d+)\)', original_fps_str)
if match:
target_cfr_fps_for_handbrake = match.group(1)
else: # Fallback to decimal part if fraction not in parentheses
else:
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate_Original")
if not target_cfr_fps_for_handbrake: # Fallback if Original_String didn't yield
if not target_cfr_fps_for_handbrake:
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate_Original")
if not target_cfr_fps_for_handbrake: # Further fallback to current FrameRate
if not target_cfr_fps_for_handbrake:
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate")
if target_cfr_fps_for_handbrake:
print(f" - Using MediaInfo FrameRate ({target_cfr_fps_for_handbrake}) as fallback for HandBrake target FPS.")
if target_cfr_fps_for_handbrake:
print(f" - Target CFR for HandBrake: {target_cfr_fps_for_handbrake}")
# Convert fractional FPS to decimal for HandBrakeCLI if needed
if isinstance(target_cfr_fps_for_handbrake, str) and "/" in target_cfr_fps_for_handbrake:
try:
num, den = map(float, target_cfr_fps_for_handbrake.split('/'))
@@ -331,15 +541,22 @@ def main(no_downmix=False):
print(f" - Converted fractional FPS to decimal for HandBrake: {target_cfr_fps_for_handbrake}")
except ValueError:
print(f" - Warning: Could not parse fractional FPS '{target_cfr_fps_for_handbrake}'. HandBrakeCLI might fail.")
is_vfr = False # Revert if conversion fails
is_vfr = False
else:
print(" - Warning: VFR detected, but could not determine target CFR from MediaInfo. Will attempt standard UTVideo conversion without HandBrake.")
is_vfr = False # Revert to non-HandBrake path
is_vfr = False
else:
print(f" - Video appears to be CFR or FrameRate_Mode not specified as VFR/Variable by MediaInfo.")
autocrop_filter = None
if autocrop:
print("--- Running autocrop detection ---")
autocrop_filter = detect_autocrop_filter(str(input_file_abs))
if autocrop_filter:
print(f" - Autocrop filter detected: {autocrop_filter}")
else:
print(" - No crop needed or detected.")
encoded_video_file, handbrake_intermediate_for_cleanup = convert_video(
file_path.stem, str(input_file_abs), is_vfr, target_cfr_fps_for_handbrake
file_path.stem, str(input_file_abs), is_vfr, target_cfr_fps_for_handbrake, autocrop_filter=autocrop_filter
)
print("--- Starting Audio Processing ---")
@@ -376,10 +593,15 @@ def main(no_downmix=False):
# Find mediainfo track by StreamOrder
audio_track_info = mediainfo_audio_tracks.get(stream_index)
track_delay = 0
delay_in_seconds = audio_track_info.get("Video_Delay") if audio_track_info else None
if delay_in_seconds is not None:
delay_raw = audio_track_info.get("Video_Delay") if audio_track_info else None
if delay_raw is not None:
try:
track_delay = round(float(delay_in_seconds) * 1000)
delay_val = float(delay_raw)
# If the value is a float < 1, it's seconds, so convert to ms.
if delay_val < 1:
track_delay = int(round(delay_val * 1000))
else:
track_delay = int(round(delay_val))
except Exception:
track_delay = 0
@@ -425,7 +647,6 @@ def main(no_downmix=False):
print("Cleaning up persistent video temporary files (after successful processing)...")
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
@@ -504,7 +725,11 @@ def main(no_downmix=False):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Batch-process MKV files with resumable video encoding, audio downmixing, and per-file logging.")
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("--autocrop", action="store_true", help="Automatically detect and crop black bars from video using cropdetect.")
parser.add_argument("--speed", type=str, help="Set the encoding speed. Possible values: slower, slow, medium, fast, faster.")
parser.add_argument("--quality", type=str, help="Set the encoding quality. Possible values: lowest, low, medium, high, higher.")
parser.add_argument("--grain", type=int, help="Set the film-grain value (number). Adjusts the film grain synthesis level.")
args = parser.parse_args()
main(no_downmix=args.no_downmix)
main(no_downmix=args.no_downmix, autocrop=args.autocrop, speed=args.speed, quality=args.quality, grain=args.grain)

450
cropdetect.py Normal file
View File

@@ -0,0 +1,450 @@
#!/usr/bin/env python3
import argparse
import subprocess
import sys
import os
import re
from collections import Counter
import shutil
import multiprocessing
import json
# ANSI color codes
COLOR_GREEN = "\033[92m"
COLOR_RED = "\033[91m"
COLOR_YELLOW = "\033[93m"
COLOR_RESET = "\033[0m"
def check_prerequisites():
"""Checks if required tools are available."""
print("--- Prerequisite Check ---")
all_found = True
for tool in ['ffmpeg', 'ffprobe']:
if not shutil.which(tool):
print(f"Error: '{tool}' command not found. Is it installed and in your PATH?")
all_found = False
if not all_found:
sys.exit(1)
print("All required tools found.")
def analyze_segment(task_args):
"""Function to be run by each worker process. Analyzes one video segment."""
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 [] # Return empty list on error
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])
# Return the crop string along with the timestamp it was found at
significant_crops.append((f"crop={w}:{h}:{x}:{y}", seek_time))
return significant_crops
def get_frame_luma(input_file, seek_time):
"""Analyzes a single frame at a given timestamp to get its average luma."""
ffmpeg_args = [
'ffmpeg', '-hide_banner',
'-ss', str(seek_time),
'-i', input_file,
'-t', '1',
'-vf', 'signalstats',
'-f', 'null', '-'
]
result = subprocess.run(ffmpeg_args, capture_output=True, text=True, encoding='utf-8')
if result.returncode != 0:
return None # Error during analysis
# Find the average luma (YAVG) for the frame
match = re.search(r'YAVG:([0-9.]+)', result.stderr)
if match:
return float(match.group(1))
return None
def check_luma_for_group(task_args):
"""Worker function to check the luma for a single group."""
group_key, sample_ts, input_file, luma_threshold = task_args
luma = get_frame_luma(input_file, sample_ts)
is_bright = luma is not None and luma >= luma_threshold
return (group_key, is_bright)
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 snap_to_known_ar(w, h, x, y, video_w, video_h, tolerance=0.03):
"""Snaps a crop rectangle to the nearest standard aspect ratio if it's close enough."""
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 the best match is not within the tolerance, return the original
if not best_match or (smallest_diff / best_match['ratio']) >= tolerance:
return f"crop={w}:{h}:{x}:{y}", None
# Match found, now snap the dimensions.
# Heuristic: if width is close to full video width, it's letterboxed.
if abs(w - video_w) < 16:
new_h = round(video_w / best_match['ratio'])
# Round height up to the nearest multiple of 8 for cleaner dimensions and less aggressive cropping.
if new_h % 8 != 0:
new_h = new_h + (8 - (new_h % 8))
new_y = round((video_h - new_h) / 2)
# Ensure y offset is an even number for compatibility.
if new_y % 2 != 0:
new_y -= 1
return f"crop={video_w}:{new_h}:0:{new_y}", best_match['name']
# Heuristic: if height is close to full video height, it's pillarboxed.
if abs(h - video_h) < 16:
new_w = round(video_h * best_match['ratio'])
# Round width up to the nearest multiple of 8.
if new_w % 8 != 0:
new_w = new_w + (8 - (new_w % 8))
new_x = round((video_w - new_w) / 2)
# Ensure x offset is an even number.
if new_x % 2 != 0:
new_x -= 1
return f"crop={new_w}:{video_h}:{new_x}:0", best_match['name']
# If not clearly letterboxed or pillarboxed, don't snap.
return f"crop={w}:{h}:{x}:{y}", None
def cluster_crop_values(crop_counts, tolerance=8):
"""Groups similar crop values into clusters based on the top-left corner."""
clusters = []
temp_counts = crop_counts.copy()
while temp_counts:
# Get the most frequent remaining crop as the new cluster center
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] # Skip malformed strings
continue
cluster_total_count = 0
crops_to_remove = []
# Find all crops "close" to the center
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})
# Remove the clustered crops from the temporary counter
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(crop_str):
"""Parses a 'crop=w:h:x:y' string into a dictionary of integers."""
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(crop_keys):
"""Calculates a bounding box that contains all given crop rectangles."""
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(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)
# Heuristic: if the bounding box is very close to the min/max, it means all crops were similar
if (max_x - min_x) <= 2 and (max_y - min_y) <= 2:
return None # Too uniform, don't create a bounding box
# Create a crop that spans the entire bounding box
bounding_crop = f"crop={max_x - min_x}:{max_y - min_y}:{min_x}:{min_y}"
return bounding_crop
def is_major_crop(crop_str, video_w, video_h, min_crop_size):
"""Checks if a crop is significant enough to be recommended by checking if any side is cropped by at least min_crop_size pixels."""
parsed = parse_crop_string(crop_str)
if not parsed:
return False
w, h, x, y = parsed['w'], parsed['h'], parsed['x'], parsed['y']
# Calculate how much is cropped from each side
crop_top = y
crop_bottom = video_h - (y + h)
crop_left = x
crop_right = video_w - (x + w)
# Return True if the largest crop on any single side meets the threshold
if max(crop_top, crop_bottom, crop_left, crop_right) >= min_crop_size:
return True
return False
def analyze_video(input_file, duration, width, height, num_workers, significant_crop_threshold, min_crop, debug=False):
"""Main analysis function for the video."""
print(f"\n--- Analyzing Video: {os.path.basename(input_file)} ---")
# Step 1: Analyze video in segments to detect crops
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)]
print(f"Analyzing {len(tasks)} segments across {num_workers} worker(s)...")
crop_results = []
with multiprocessing.Pool(processes=num_workers) as pool:
total_tasks = len(tasks)
results_iterator = pool.imap_unordered(analyze_segment, tasks)
for i, result in enumerate(results_iterator, 1):
crop_results.append(result)
progress_message = f"Analyzing Segments: {i}/{total_tasks} completed..."
sys.stdout.write(f"\r{progress_message}")
sys.stdout.flush()
print()
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:
print(f"\n{COLOR_GREEN}Analysis complete. No black bars detected.{COLOR_RESET}")
return
crop_counts = Counter(all_crop_strings)
if debug:
print("\n--- Debug: Most Common Raw Detections ---")
for crop_str, count in crop_counts.most_common(10):
print(f" - {crop_str} (Count: {count})")
# Step 2: Cluster similar crop values
clusters = cluster_crop_values(crop_counts)
total_detections = sum(c['count'] for c in clusters)
if debug:
print("\n--- Debug: Detected Clusters ---")
for cluster in clusters:
percentage = (cluster['count'] / total_detections) * 100
print(f" - Center: {cluster['center']}, Count: {cluster['count']} ({percentage:.1f}%)")
# Step 3: Filter clusters that are below the significance threshold
significant_clusters = []
for cluster in clusters:
percentage = (cluster['count'] / total_detections) * 100
if percentage >= significant_crop_threshold:
significant_clusters.append(cluster)
# Step 4: Determine final recommendation based on significant clusters
print("\n--- Determining Final Crop Recommendation ---")
for cluster in significant_clusters:
parsed_crop = parse_crop_string(cluster['center'])
if parsed_crop:
_, ar_label = snap_to_known_ar(
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:
print(f"{COLOR_RED}No single crop value meets the {significant_crop_threshold}% significance threshold.{COLOR_RESET}")
print("Recommendation: Do not crop. Try lowering the -sct threshold.")
elif len(significant_clusters) == 1:
dominant_cluster = significant_clusters[0]
parsed_crop = parse_crop_string(dominant_cluster['center'])
snapped_crop, ar_label = snap_to_known_ar(
parsed_crop['w'], parsed_crop['h'], parsed_crop['x'], parsed_crop['y'], width, height
)
print("A single dominant aspect ratio was found.")
if ar_label:
print(f"The detected crop snaps to the '{ar_label}' aspect ratio.")
# Check if the final crop is a no-op (i.e., matches source dimensions)
parsed_snapped = parse_crop_string(snapped_crop)
if parsed_snapped and parsed_snapped['w'] == width and parsed_snapped['h'] == height:
print(f"\n{COLOR_GREEN}The detected crop matches the source resolution. No crop is needed.{COLOR_RESET}")
else:
print(f"\n{COLOR_GREEN}Recommended crop filter: -vf {snapped_crop}{COLOR_RESET}")
else: # len > 1, mixed AR case
print(f"{COLOR_YELLOW}Mixed aspect ratios detected (e.g., IMAX scenes).{COLOR_RESET}")
print("Calculating a safe 'master' crop to contain all significant scenes.")
crop_keys = [c['center'] for c in significant_clusters]
bounding_box_crop = calculate_bounding_box(crop_keys)
if bounding_box_crop:
parsed_bb = parse_crop_string(bounding_box_crop)
snapped_crop, ar_label = snap_to_known_ar(
parsed_bb['w'], parsed_bb['h'], parsed_bb['x'], parsed_bb['y'], width, height
)
print("\n--- Detected Significant Ratios ---")
for cluster in significant_clusters:
percentage = (cluster['count'] / total_detections) * 100
label = f"'{cluster['ar_label']}'" if cluster['ar_label'] else "Custom AR"
print(f" - {label} ({cluster['center']}) was found in {percentage:.1f}% of samples.")
print(f"\n{COLOR_GREEN}Analysis complete.{COLOR_RESET}")
if ar_label:
print(f"The calculated master crop snaps to the '{ar_label}' aspect ratio.")
# Check if the final crop is a no-op
parsed_snapped = parse_crop_string(snapped_crop)
if parsed_snapped and parsed_snapped['w'] == width and parsed_snapped['h'] == height:
print(f"{COLOR_GREEN}The final calculated crop matches the source resolution. No crop is needed.{COLOR_RESET}")
else:
print(f"{COLOR_GREEN}Recommended safe crop filter: -vf {snapped_crop}{COLOR_RESET}")
else:
print(f"{COLOR_RED}Could not calculate a bounding box. Manual review is required.{COLOR_RESET}")
def main():
parser = argparse.ArgumentParser(
description="Analyzes a video file to detect black bars and recommend crop values. "
"Handles mixed aspect ratios by calculating a safe bounding box.",
formatter_class=argparse.RawTextHelpFormatter
)
parser.add_argument("input", help="Input video file")
parser.add_argument("-n", "--num_workers", type=int, default=max(1, multiprocessing.cpu_count() // 2), help="Number of worker threads. Defaults to half of available cores.")
parser.add_argument("-sct", "--significant_crop_threshold", type=float, default=5.0, help="Percentage a crop must be present to be considered 'significant'. Default is 5.0.")
parser.add_argument("-mc", "--min_crop", type=int, default=10, help="Minimum pixels to crop on any side for it to be considered a 'major' crop. Default is 10.")
parser.add_argument("--debug", action="store_true", help="Enable detailed debug logging.")
args = parser.parse_args()
input_file = args.input
num_workers = args.num_workers
significant_crop_threshold = args.significant_crop_threshold
min_crop = args.min_crop
# Validate input file
if not os.path.isfile(input_file):
print(f"{COLOR_RED}Error: Input file does not exist.{COLOR_RESET}")
sys.exit(1)
# Always probe the video file for metadata
print("--- Probing video file for metadata ---")
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))
print(f"Detected duration: {duration}s")
# Probe for resolution, handling multiple video streams (e.g., with cover art)
probe_res_args = [
'ffprobe', '-v', 'error',
'-select_streams', 'v', # Select all video streams
'-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
# Find the first video stream that is NOT an attached picture
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:
# If no suitable stream is found, raise an error.
raise ValueError("Could not find a valid video stream to probe for resolution.")
width = int(video_stream['width'])
height = int(video_stream['height'])
print(f"Detected resolution: {width}x{height}")
except Exception as e:
print(f"{COLOR_RED}Error probing video file: {e}{COLOR_RESET}")
sys.exit(1)
print(f"\n--- Video Analysis Parameters ---")
print(f"Input File: {os.path.basename(input_file)}")
print(f"Duration: {duration}s")
print(f"Resolution: {width}x{height}")
print(f"Number of Workers: {num_workers}")
print(f"Significance Threshold: {significant_crop_threshold}%")
print(f"Minimum Crop Size: {min_crop}px")
# Check for required tools
check_prerequisites()
# Analyze the video
analyze_video(input_file, duration, width, height, num_workers, significant_crop_threshold, min_crop, args.debug)
if __name__ == "__main__":
main()

View File

@@ -8,6 +8,17 @@ import json
from datetime import datetime
from pathlib import Path
class Tee:
def __init__(self, *files):
self.files = files
def write(self, obj):
for f in self.files:
f.write(obj)
f.flush()
def flush(self):
for f in self.files:
f.flush()
REQUIRED_TOOLS_MAP = {
"ffmpeg": "extra/ffmpeg",
"ffprobe": "extra/ffmpeg", # Part of ffmpeg package
@@ -20,9 +31,10 @@ REQUIRED_TOOLS_MAP = {
}
DIR_COMPLETED = Path("completed")
DIR_ORIGINAL = Path("original")
DIR_LOGS = Path("conv_logs")
REMUX_CODECS = {"aac", "opus"} # Using a set for efficient lookups
CONVERT_CODECS = {"dts", "ac3", "eac3", "flac", "wavpack", "alac"}
# Removed CONVERT_CODECS, now all non-remux codecs will be converted
def check_tools():
if sys.platform == "win32":
@@ -78,14 +90,16 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
else:
# Not downmixing (or source is already stereo or less).
# Base bitrate on the source channel count.
if ch == 2: # Stereo
if ch == 1: # Mono
bitrate = "64k"
elif ch == 2: # Stereo
bitrate = "128k"
elif ch == 6: # 5.1 Surround
bitrate = "256k"
elif ch == 8: # 7.1 Surround
bitrate = "384k"
else: # Mono or other layouts
bitrate = "96k" # A sensible default for mono.
else: # Other layouts
bitrate = "96k" # A sensible default for other/uncommon layouts.
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
run_cmd([
@@ -93,7 +107,7 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
])
return final_opus
def convert_video(source_file_base, source_file_full):
def convert_video(source_file_base, source_file_full, autocrop_filter=None):
print(" --- Starting Video Processing ---")
# source_file_base is the full stem from the original file,
# e.g., "cheers.s01e04.der.lueckenbuesser.german.dl.fs.1080p.web.h264-cnhd"
@@ -117,7 +131,10 @@ def convert_video(source_file_base, source_file_full):
ffmpeg_args = [
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", source_file_full,
"-map", "0:v:0", "-map_metadata", "-1", "-map_chapters", "-1", "-an", "-sn", "-dn",
] + video_codec_args + [str(ut_video_file)]
]
if autocrop_filter:
ffmpeg_args += ["-vf", autocrop_filter]
ffmpeg_args += video_codec_args + [str(ut_video_file)]
run_cmd(ffmpeg_args)
print(" - Starting video encode with AlabamaEncoder (this will take a long time)...")
@@ -147,150 +164,415 @@ def convert_video(source_file_base, source_file_full):
print(" --- Finished Video Processing ---")
return ut_video_file, encoded_video_file
def main(no_downmix=False):
check_tools()
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
DIR_ORIGINAL.mkdir(exist_ok=True, parents=True)
# --- CROPDETECT LOGIC FROM cropdetect.py ---
import multiprocessing as _multiprocessing_cropdetect
from collections import Counter as _Counter_cropdetect
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 []
import re
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):
check_tools()
current_dir = Path(".")
files_to_process = sorted(
f for f in current_dir.glob("*.mkv")
if not (f.name.endswith(".ut.mkv") or f.name.startswith("temp-") or f.name.startswith("output-"))
)
if not files_to_process:
print("No .mkv files found to process in the current directory.")
print("No MKV files found to process. Exiting.")
return
for file_path in files_to_process:
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
print(f"Starting full processing for: {file_path.name}")
date = datetime.now()
input_file_abs = file_path.resolve()
intermediate_output_file = current_dir / f"output-{file_path.name}"
audio_temp_dir = None # Initialize to None
created_ut_video_path = None
created_encoded_video_path = None
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
DIR_ORIGINAL.mkdir(exist_ok=True, parents=True)
DIR_LOGS.mkdir(exist_ok=True, parents=True)
while True:
files_to_process = sorted(
f for f in current_dir.glob("*.mkv")
if not (f.name.endswith(".ut.mkv") or f.name.startswith("temp-") or f.name.startswith("output-"))
)
if not files_to_process:
print("No more .mkv files found to process in the current directory. The script will now exit.")
break
file_path = files_to_process[0]
log_file_path = DIR_LOGS / f"{file_path.name}.log"
log_file = open(log_file_path, 'w', encoding='utf-8')
original_stdout = sys.stdout
original_stderr = sys.stderr
sys.stdout = Tee(original_stdout, log_file)
sys.stderr = Tee(original_stderr, log_file)
try:
audio_temp_dir = tempfile.mkdtemp(prefix="tv_audio_") # UUID is not strictly needed for uniqueness
print(f"Audio temporary directory created at: {audio_temp_dir}")
print(f"Analyzing file: {input_file_abs}")
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
print(f"Starting full processing for: {file_path.name}")
date = datetime.now()
input_file_abs = file_path.resolve()
intermediate_output_file = current_dir / f"output-{file_path.name}"
audio_temp_dir = None
created_ut_video_path = None
created_encoded_video_path = None
try:
audio_temp_dir = tempfile.mkdtemp(prefix="tv_audio_")
print(f"Audio temporary directory created at: {audio_temp_dir}")
print(f"Analyzing file: {input_file_abs}")
ffprobe_info_json = run_cmd([
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(input_file_abs)
], capture_output=True)
ffprobe_info = json.loads(ffprobe_info_json)
ffprobe_info_json = run_cmd([
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(input_file_abs)
], capture_output=True)
ffprobe_info = json.loads(ffprobe_info_json)
mkvmerge_info_json = run_cmd([
"mkvmerge", "-J", str(input_file_abs)
], capture_output=True)
mkv_info = json.loads(mkvmerge_info_json)
mkvmerge_info_json = run_cmd([
"mkvmerge", "-J", str(input_file_abs)
], capture_output=True)
mkv_info = json.loads(mkvmerge_info_json)
mediainfo_json = run_cmd([
"mediainfo", "--Output=JSON", "-f", str(input_file_abs)
], capture_output=True)
media_info = json.loads(mediainfo_json)
mediainfo_json = run_cmd([
"mediainfo", "--Output=JSON", "-f", str(input_file_abs)
], capture_output=True)
media_info = json.loads(mediainfo_json)
created_ut_video_path, created_encoded_video_path = convert_video(file_path.stem, str(input_file_abs))
autocrop_filter = None
if autocrop:
print("--- Running autocrop detection ---")
autocrop_filter = detect_autocrop_filter(str(input_file_abs))
if autocrop_filter:
print(f" - Autocrop filter detected: {autocrop_filter}")
else:
print(" - No crop needed or detected.")
print("--- Starting Audio Processing ---")
processed_audio_files = []
audio_tracks_to_remux = []
audio_streams = [s for s in ffprobe_info.get("streams", []) if s.get("codec_type") == "audio"]
created_ut_video_path, created_encoded_video_path = convert_video(file_path.stem, str(input_file_abs), autocrop_filter=autocrop_filter)
for stream in audio_streams:
stream_index = stream["index"]
codec = stream.get("codec_name")
channels = stream.get("channels", 2)
language = stream.get("tags", {}).get("language", "und")
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_title = mkv_track.get("properties", {}).get("track_name", "")
track_delay = 0
print("--- Starting Audio Processing ---")
processed_audio_files = []
audio_tracks_to_remux = []
audio_streams = [s for s in ffprobe_info.get("streams", []) if s.get("codec_type") == "audio"]
# Build mkvmerge audio track list
mkv_audio_tracks_list = [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", [])
audio_track_info = next((t for t in media_tracks_data if t.get("@type") == "Audio" and int(t.get("StreamOrder", -1)) == stream_index), None)
delay_in_seconds = audio_track_info.get("Video_Delay") if audio_track_info else None
if delay_in_seconds is not None:
try:
track_delay = round(float(delay_in_seconds) * 1000)
except Exception:
track_delay = 0
mediainfo_audio_tracks = {int(t.get("StreamOrder", -1)): t for t in media_tracks_data if t.get("@type") == "Audio"}
print(f"Processing Audio Stream #{stream_index} (TID: {track_id}, Codec: {codec}, Channels: {channels})")
if codec in REMUX_CODECS:
audio_tracks_to_remux.append(str(track_id))
elif codec in CONVERT_CODECS:
opus_file = convert_audio_track(
stream_index, channels, language, audio_temp_dir, str(input_file_abs), not no_downmix
)
processed_audio_files.append({
"Path": opus_file,
"Language": language,
"Title": track_title,
"Delay": track_delay
})
for audio_idx, stream in enumerate(audio_streams):
stream_index = stream["index"]
codec = stream.get("codec_name")
channels = stream.get("channels", 2)
language = stream.get("tags", {}).get("language", "und")
# More robustly find the mkvmerge track by matching ffprobe's stream index
# to mkvmerge's 'stream_id' property.
mkv_track = next((t for t in mkv_info.get("tracks", []) if t.get("properties", {}).get("stream_id") == stream_index), None)
if not mkv_track:
# Fallback to the less reliable index-based method if stream_id isn't found
mkv_track = mkv_audio_tracks_list[audio_idx] if audio_idx < len(mkv_audio_tracks_list) else {}
track_id = mkv_track.get("id", -1)
track_title = mkv_track.get("properties", {}).get("track_name", "")
track_delay = 0
audio_track_info = mediainfo_audio_tracks.get(stream_index)
delay_raw = audio_track_info.get("Video_Delay") if audio_track_info else None
if delay_raw is not None:
try:
delay_val = float(delay_raw)
if delay_val < 1:
track_delay = int(round(delay_val * 1000))
else:
track_delay = int(round(delay_val))
except Exception:
track_delay = 0
print(f"Processing Audio Stream #{stream_index} (TID: {track_id}, Codec: {codec}, Channels: {channels})")
if codec in REMUX_CODECS:
audio_tracks_to_remux.append(str(track_id))
else:
opus_file = convert_audio_track(
stream_index, channels, language, audio_temp_dir, str(input_file_abs), not no_downmix
)
processed_audio_files.append({
"Path": opus_file,
"Language": language,
"Title": track_title,
"Delay": track_delay
})
print("--- Finished Audio Processing ---")
# Final mux
print("Assembling final file with mkvmerge...")
mkvmerge_args = ["mkvmerge", "-o", str(intermediate_output_file), str(created_encoded_video_path)]
for file_info in processed_audio_files:
mkvmerge_args.extend(["--language", f"0:{file_info['Language']}"])
if file_info['Title']: # Only add track name if it exists
mkvmerge_args.extend(["--track-name", f"0:{file_info['Title']}"])
if file_info['Delay']:
mkvmerge_args.extend(["--sync", f"0:{file_info['Delay']}"])
mkvmerge_args.append(str(file_info["Path"]))
source_copy_args = ["--no-video"]
if audio_tracks_to_remux:
source_copy_args += ["--audio-tracks", ",".join(audio_tracks_to_remux)]
else:
print(f"Warning: Unsupported codec '{codec}'. Remuxing as is.", file=sys.stderr)
audio_tracks_to_remux.append(str(track_id))
source_copy_args += ["--no-audio"]
mkvmerge_args += source_copy_args + [str(input_file_abs)]
run_cmd(mkvmerge_args)
print("--- Finished Audio Processing ---")
# Move files
print("Moving files to final destinations...")
shutil.move(str(file_path), DIR_ORIGINAL / file_path.name)
shutil.move(str(intermediate_output_file), DIR_COMPLETED / file_path.name)
# Final mux
print("Assembling final file with mkvmerge...")
mkvmerge_args = ["mkvmerge", "-o", str(intermediate_output_file), str(created_encoded_video_path)]
for file_info in processed_audio_files:
sync_switch = ["--sync", f"0:{file_info['Delay']}"] if file_info["Delay"] else []
mkvmerge_args += [
"--language", f"0:{file_info['Language']}",
"--track-name", f"0:{file_info['Title']}"
] + sync_switch + [str(file_info["Path"])]
except Exception as e:
print(f"An error occurred while processing '{file_path.name}': {e}", file=sys.stderr)
finally:
print("--- Starting Cleanup ---")
if audio_temp_dir and Path(audio_temp_dir).exists():
print(" - Cleaning up disposable audio temporary directory...")
shutil.rmtree(audio_temp_dir, ignore_errors=True)
source_copy_args = ["--no-video"]
if audio_tracks_to_remux:
source_copy_args += ["--audio-tracks", ",".join(audio_tracks_to_remux)]
else:
source_copy_args += ["--no-audio"]
mkvmerge_args += source_copy_args + [str(input_file_abs)]
run_cmd(mkvmerge_args)
if intermediate_output_file.exists():
print(" - Cleaning up intermediate output file...")
intermediate_output_file.unlink()
# Move files
print("Moving files to final destinations...")
shutil.move(str(file_path), DIR_ORIGINAL / file_path.name)
shutil.move(str(intermediate_output_file), DIR_COMPLETED / file_path.name)
print(" - Cleaning up persistent video temporary files...")
if created_ut_video_path and created_ut_video_path.exists():
print(f" - Deleting UT video file: {created_ut_video_path}")
created_ut_video_path.unlink()
if created_encoded_video_path and created_encoded_video_path.exists():
print(f" - Deleting encoded video temp file: {created_encoded_video_path}")
created_encoded_video_path.unlink()
except Exception as e:
print(f"An error occurred while processing '{file_path.name}': {e}", file=sys.stderr)
alabama_dirs = list(current_dir.glob('.alabamatemp-*'))
if alabama_dirs:
print(" - Cleaning up AlabamaEncoder temporary directories...")
for temp_dir_alabama in alabama_dirs:
if temp_dir_alabama.is_dir():
shutil.rmtree(temp_dir_alabama, ignore_errors=True)
print("--- Finished Cleanup ---")
runtime = datetime.now() - date
runtime_str = str(runtime).split('.')[0] # Format to remove milliseconds
print(f"Total runtime for {file_path.name}: {runtime_str}")
finally:
print("--- Starting Cleanup ---")
print(" - Cleaning up disposable audio temporary directory...")
if audio_temp_dir and Path(audio_temp_dir).exists():
shutil.rmtree(audio_temp_dir, ignore_errors=True)
print(" - Cleaning up intermediate output file (if any)...")
intermediate_output_file.unlink(missing_ok=True)
print(" - Cleaning up persistent video temporary files...")
if created_ut_video_path and created_ut_video_path.exists():
print(f" Deleting UT video file: {created_ut_video_path}")
created_ut_video_path.unlink(missing_ok=True)
if created_encoded_video_path and created_encoded_video_path.exists():
print(f" Deleting encoded video temp file: {created_encoded_video_path}")
created_encoded_video_path.unlink(missing_ok=True)
print(" - Cleaning up AlabamaEncoder temporary directories...")
for temp_dir_alabama in current_dir.glob('.alabamatemp-*'):
if temp_dir_alabama.is_dir():
shutil.rmtree(temp_dir_alabama, ignore_errors=True)
print("--- Finished Cleanup ---")
runtime = datetime.now() - date
runtime_str = str(runtime).split('.')[0] # Format to remove milliseconds
print(f"Total runtime for {file_path.name}: {runtime_str}")
# Restore stdout/stderr and close log file
sys.stdout = original_stdout
sys.stderr = original_stderr
log_file.close()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Batch-process MKV files with resumable video encoding and audio downmixing.")
parser = argparse.ArgumentParser(description="Batch-process MKV files with resumable video encoding and audio downmixing, with optional autocrop.")
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.")
args = parser.parse_args()
main(no_downmix=args.no_downmix)
main(no_downmix=args.no_downmix, autocrop=args.autocrop)