Compare commits
18 Commits
backup_v3
...
4f347e930d
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f347e930d | |||
| 349265dee2 | |||
| 0932bb7019 | |||
| 8577bfcac8 | |||
| da56bc3a81 | |||
| 1c371e80a8 | |||
| 17afcb1579 | |||
| 359ff6f04c | |||
| ba05642ad6 | |||
| 6cfdb40e7f | |||
| d325866162 | |||
| 4b3f087104 | |||
| 665272ffc9 | |||
| 8bc672ca6b | |||
| 5f108ade66 | |||
| 9eaf823ffc | |||
| e9352ed6a0 | |||
| ba4eac1775 |
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class Tee:
|
|||||||
|
|
||||||
def check_tools():
|
def check_tools():
|
||||||
"""Checks if all required command-line tools are in the system's PATH."""
|
"""Checks if all required command-line tools are in the system's PATH."""
|
||||||
required_tools = ["ffmpeg", "ffprobe", "mkvmerge", "sox", "opusenc", "mediainfo"]
|
required_tools = ["ffmpeg", "ffprobe", "mkvmerge", "sox_ng", "opusenc", "mediainfo"]
|
||||||
print("--- Prerequisite Check ---")
|
print("--- Prerequisite Check ---")
|
||||||
all_found = True
|
all_found = True
|
||||||
for tool in required_tools:
|
for tool in required_tools:
|
||||||
@@ -80,12 +80,14 @@ def convert_audio_track(stream_index, channels, temp_dir, source_file, should_do
|
|||||||
|
|
||||||
# Step 2: Normalize the track with SoX
|
# Step 2: Normalize the track with SoX
|
||||||
print(" - Normalizing with SoX...")
|
print(" - Normalizing with SoX...")
|
||||||
run_cmd(["sox", str(temp_extracted), str(temp_normalized), "-S", "--temp", str(temp_dir), "--guard", "gain", "-n"])
|
run_cmd(["sox_ng", "--show-progress", str(temp_extracted), str(temp_normalized), "--temp", str(temp_dir), "loudness", "-18"])
|
||||||
|
|
||||||
# Step 3: Encode to Opus with the correct bitrate
|
# Step 3: Encode to Opus with the correct bitrate
|
||||||
bitrate = "192k" # Fallback
|
bitrate = "192k" # Fallback
|
||||||
|
|
||||||
if final_channels == 2:
|
if final_channels == 1:
|
||||||
|
bitrate = "64k"
|
||||||
|
elif final_channels == 2:
|
||||||
bitrate = "128k"
|
bitrate = "128k"
|
||||||
elif final_channels == 6:
|
elif final_channels == 6:
|
||||||
bitrate = "256k"
|
bitrate = "256k"
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
This is a collection of Python scripts for various video and audio processing tasks, such as encoding video to AV1 and audio to Opus.
|
This is a collection of Python scripts for various video and audio processing tasks, such as encoding video to AV1 and audio to Opus.
|
||||||
|
|
||||||
## Scripts
|
## Scripts
|
||||||
|
-
|
||||||
- **[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. For more details, see the [Anime Audio Encoder README](README_Anime%20Audio%20Encoder.md).
|
- **av1_opus_encoder.py**: The primary script for creating high-quality AV1/Opus encodes. It handles Variable Frame Rate (VFR) sources, uses `av1an` for efficient AV1 encoding, and supports automatic black bar cropping. For more details, see the AV1 Opus Encoder README.
|
||||||
|
-
|
||||||
- **[tv_audio_encoder.py](tv_audio_encoder.py)**: A script designed for encoding TV show episodes. It uses `alabamaEncoder` for the video encoding process. For more details, see the [TV Audio Encoder README](README_TV%20Audio%20Encoder.md).
|
- **[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).
|
||||||
|
|
||||||
- **[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).
|
- **[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).
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
|
||||||
@@ -24,7 +24,7 @@ The following command-line tools must be installed and available in your system'
|
|||||||
* `ffmpeg`
|
* `ffmpeg`
|
||||||
* `ffprobe`
|
* `ffprobe`
|
||||||
* `mkvmerge`
|
* `mkvmerge`
|
||||||
* `sox`
|
* `sox_ng`
|
||||||
* `opusenc`
|
* `opusenc`
|
||||||
* `mediainfo`
|
* `mediainfo`
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ The following command-line tools must be installed and available in your system'
|
|||||||
* `ffprobe`
|
* `ffprobe`
|
||||||
* `mkvmerge`
|
* `mkvmerge`
|
||||||
* `mkvpropedit`
|
* `mkvpropedit`
|
||||||
* `sox`
|
* `sox_ng`
|
||||||
* `opusenc`
|
* `opusenc`
|
||||||
* `mediainfo`
|
* `mediainfo`
|
||||||
* `alabamaEncoder`
|
* `alabamaEncoder`
|
||||||
@@ -46,6 +46,20 @@ The following command-line tools must be installed and available in your system'
|
|||||||
./tv_audio_encoder.py --no-downmix
|
./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
|
## Output
|
||||||
|
|
||||||
* Processed files are moved to the `completed/` directory.
|
* Processed files are moved to the `completed/` directory.
|
||||||
@@ -55,4 +69,5 @@ The following command-line tools must be installed and available in your system'
|
|||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
* This script is intended for use on **Linux** only.
|
* This script is intended for use on **Linux** only.
|
||||||
* The entire process, especially the AV1 encoding, can be very time-consuming and CPU
|
* 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.
|
||||||
64
README_av1_opus_encoder.md
Normal file
64
README_av1_opus_encoder.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# AV1 Opus Encoder
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`av1_opus_encoder.py` is a comprehensive, all-in-one batch-processing script for MKV files, designed for modern 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.
|
||||||
|
|
||||||
|
This script serves as the primary tool for creating high-quality, efficient AV1/Opus encodes.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
* **Autocrop:** Includes an optional `--autocrop` flag to intelligently detect and remove black bars, maximizing encoding efficiency.
|
||||||
|
* **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.
|
||||||
|
* **Loudness-Normalized Audio:** Converts all audio tracks (except AAC/Opus) to the Opus format, using EBU R 128 loudness normalization for a consistent listening experience.
|
||||||
|
* **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_ng`
|
||||||
|
* `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 av1_opus_encoder.py`.
|
||||||
|
3. Execute the script from your terminal:
|
||||||
|
```bash
|
||||||
|
./av1_opus_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.
|
||||||
|
* `--autocrop`: Automatically detect and crop black bars from video. The crop is applied to the UTVideo intermediate file.
|
||||||
|
* `--speed`: Override the default SVT-AV1 encoding speed (e.g., `--speed slow`).
|
||||||
|
* `--quality`: Override the default SVT-AV1 quality level (e.g., `--quality high`).
|
||||||
|
* `--grain`: Override the default SVT-AV1 film grain synthesis level (e.g., `--grain 8`).
|
||||||
|
|
||||||
|
Example with multiple arguments:
|
||||||
|
```bash
|
||||||
|
./av1_opus_encoder.py --autocrop --no-downmix --speed slow
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -1,32 +1,30 @@
|
|||||||
# Advanced Crop Detection Script
|
# Advanced Crop Detection Script
|
||||||
|
|
||||||
This Python script provides a robust and intelligent way to detect the correct crop values for video files. It goes far beyond a simple `ffmpeg-cropdetect` wrapper by using parallel processing and a series of smart heuristics to provide accurate and reliable recommendations, even for complex videos with mixed aspect ratios.
|
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
|
## Key Features
|
||||||
|
|
||||||
- **Parallel Processing**: Analyzes video segments in parallel to significantly speed up the detection process on multi-core systems.
|
- **Parallel Processing:** Analyzes video segments in parallel for speed and reliability.
|
||||||
- **Smart Aspect Ratio Snapping**: Automatically "snaps" detected crop values to known cinematic standards (e.g., 1.85:1, 2.39:1, 16:9, 4:3), correcting for minor detection errors.
|
- **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 Detection**: Intelligently identifies videos that switch aspect ratios (e.g., IMAX scenes in a widescreen movie) and warns the user against applying a single, destructive crop.
|
- **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.
|
||||||
- **Credits & Logo Filtering**: Automatically detects and ignores crop values that only appear in the first or last 5% of the video, preventing opening logos or closing credits from influencing the result.
|
- **Luma Verification:** Discards unreliable crop detections from very dark scenes using a second analysis pass.
|
||||||
- **Luma Verification**: Performs a second analysis pass on frames with unidentified aspect ratios. If a frame is too dark, the detection is discarded as unreliable, preventing false positives from dark scenes.
|
- **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.
|
||||||
- **Sanity Checks**: Provides context-aware warnings, such as when it suggests cropping a 4:3 video into a widescreen format.
|
- **No Crop Recommendation:** If the video is overwhelmingly detected as not needing a crop, the script will confidently recommend leaving it as is.
|
||||||
- **"No Crop" Logic**: If a video is overwhelmingly detected as not needing a crop (>95% of samples), it will confidently recommend leaving it as is, ignoring insignificant variations.
|
- **User-Friendly Output:** Color-coded recommendations and warnings for easy review.
|
||||||
- **User-Friendly Output**: Uses color-coded text to make recommendations and warnings easy to read at a glance.
|
- **Safe for Automation:** The recommended crop is always the most outer cropable frame, so no image data is lost—even with variable crops.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
1. **Python 3**: The script is written for Python 3.
|
- **Python 3**
|
||||||
2. **FFmpeg**: Both `ffmpeg` and `ffprobe` must be installed and accessible in your system's `PATH`. The script will check for these on startup.
|
- **FFmpeg**: Both `ffmpeg` and `ffprobe` must be installed and in your system's `PATH`.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
No complex installation is required. Simply save the script as `cropdetect.py` and ensure it is executable.
|
Just save the script as `cropdetect.py` and make it executable if needed.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Run the script from your terminal, passing the path to the video file as an argument.
|
Run the script from your terminal, passing the path to the video file as an argument:
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python cropdetect.py "path/to/your/video.mkv"
|
python cropdetect.py "path/to/your/video.mkv"
|
||||||
@@ -34,91 +32,45 @@ python cropdetect.py "path/to/your/video.mkv"
|
|||||||
|
|
||||||
### Options
|
### Options
|
||||||
|
|
||||||
- `-j, --jobs`: Specify the number of parallel processes to use for analysis. By default, it uses half of your available CPU cores.
|
- `-n, --num_workers`: Number of parallel worker threads (default: half your CPU cores).
|
||||||
```bash
|
- `-sct, --significant_crop_threshold`: Percentage a crop must be present to be considered significant (default: 5.0).
|
||||||
# Use 8 parallel jobs
|
- `-mc, --min_crop`: Minimum pixels to crop on any side for it to be considered a major crop (default: 10).
|
||||||
python cropdetect.py "path/to/video.mkv" --jobs 8
|
- `--debug`: Enable detailed debug logging.
|
||||||
```
|
|
||||||
- `-i, --interval`: Set the time interval (in seconds) between video samples. A smaller interval is more thorough but slower. The default is 30 seconds.
|
|
||||||
```bash
|
|
||||||
# Analyze the video every 15 seconds
|
|
||||||
python cropdetect.py "path/to/video.mkv" --interval 15
|
|
||||||
```
|
|
||||||
|
|
||||||
## Example Output
|
## Example Output
|
||||||
|
|
||||||
### Confident Crop Recommendation
|
### Confident Crop Recommendation
|
||||||
|
|
||||||
For a standard widescreen movie, the output will be clear and simple.
|
For a standard widescreen movie:
|
||||||
|
|
||||||
```
|
```
|
||||||
--- Prerequisite Check ---
|
|
||||||
All required tools found.
|
|
||||||
|
|
||||||
Video properties: 3840x2160, 7588.66s. Analyzing with up to 16 parallel jobs...
|
|
||||||
|
|
||||||
--- Starting Analysis ---
|
|
||||||
Analyzing Segments: 252/252 completed...
|
|
||||||
|
|
||||||
--- Final Verdict ---
|
|
||||||
--- Credits/Logo Detection ---
|
|
||||||
Ignoring 55 crop value(s) that appear only in the first/last 5% of the video.
|
|
||||||
|
|
||||||
--- Luma Verification ---
|
|
||||||
Verifying scenes: 97/97 completed...
|
|
||||||
Ignoring 347 detections that occurred in very dark scenes.
|
|
||||||
|
|
||||||
Analysis complete.
|
|
||||||
The video consistently uses the 'Widescreen (Flat)' aspect ratio.
|
|
||||||
Recommended crop filter: -vf crop=3840:2080:0:40
|
Recommended crop filter: -vf crop=3840:2080:0:40
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mixed Aspect Ratio Warning
|
### Mixed Aspect Ratio Warning
|
||||||
|
|
||||||
For a movie with changing aspect ratios, the script will advise against cropping.
|
For a movie with changing aspect ratios:
|
||||||
|
|
||||||
```
|
```
|
||||||
--- Prerequisite Check ---
|
WARNING: Potentially Mixed Aspect Ratios Detected!
|
||||||
All required tools found.
|
|
||||||
|
|
||||||
Video properties: 1920x1080, 3640.90s. Analyzing with up to 16 parallel jobs...
|
|
||||||
|
|
||||||
--- Starting Analysis ---
|
|
||||||
Analyzing Segments: 121/121 completed...
|
|
||||||
|
|
||||||
--- Final Verdict ---
|
|
||||||
--- Credits/Logo Detection ---
|
|
||||||
Ignoring 15 crop value(s) that appear only in the first/last 5% of the video.
|
|
||||||
|
|
||||||
--- Luma Verification ---
|
|
||||||
Verifying scenes: 121/121 completed...
|
|
||||||
Ignoring 737 detections that occurred in very dark scenes.
|
|
||||||
|
|
||||||
--- WARNING: Potentially Mixed Aspect Ratios Detected! ---
|
|
||||||
The dominant aspect ratio is 'Widescreen (Scope)' (crop=1920:808:0:136), found in 96.2% of samples.
|
|
||||||
However, other significantly different aspect ratios were also detected, although less frequently.
|
|
||||||
|
|
||||||
Recommendation: Manually check the video before applying a single crop.
|
Recommendation: Manually check the video before applying a single crop.
|
||||||
You can review the next most common detections below:
|
|
||||||
- 'Fullscreen (4:3)' (crop=1440:1080:240:0) was detected 69 time(s) (3.8%).
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### No Crop Needed
|
### No Crop Needed
|
||||||
|
|
||||||
For a video that is already perfectly formatted (e.g., a 4:3 TV show), the script will recommend doing nothing.
|
For a video that is already perfectly formatted:
|
||||||
|
|
||||||
```
|
```
|
||||||
--- Prerequisite Check ---
|
|
||||||
All required tools found.
|
|
||||||
|
|
||||||
Video properties: 768x576, 1770.78s. Analyzing with up to 16 parallel jobs...
|
|
||||||
|
|
||||||
--- Starting Analysis ---
|
|
||||||
Analyzing Segments: 58/58 completed...
|
|
||||||
|
|
||||||
--- Final Verdict ---
|
|
||||||
Analysis complete.
|
|
||||||
The video is overwhelmingly 'Fullscreen (4:3)' and does not require cropping.
|
|
||||||
Minor aspect ratio variations were detected but are considered insignificant due to their low frequency.
|
|
||||||
Recommendation: No crop needed.
|
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.
|
||||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
REQUIRED_TOOLS = [
|
REQUIRED_TOOLS = [
|
||||||
"ffmpeg", "ffprobe", "mkvmerge", "mkvpropedit",
|
"ffmpeg", "ffprobe", "mkvmerge", "mkvpropedit",
|
||||||
"sox", "opusenc", "mediainfo", "av1an", "HandBrakeCLI" # Added HandBrakeCLI
|
"sox_ng", "opusenc", "mediainfo", "av1an", "HandBrakeCLI" # Added HandBrakeCLI
|
||||||
]
|
]
|
||||||
DIR_COMPLETED = Path("completed")
|
DIR_COMPLETED = Path("completed")
|
||||||
DIR_ORIGINAL = Path("original")
|
DIR_ORIGINAL = Path("original")
|
||||||
@@ -19,6 +19,21 @@ DIR_CONV_LOGS = Path("conv_logs") # Directory for conversion logs
|
|||||||
|
|
||||||
REMUX_CODECS = {"aac", "opus"} # Using a set for efficient lookups
|
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():
|
def check_tools():
|
||||||
for tool in REQUIRED_TOOLS:
|
for tool in REQUIRED_TOOLS:
|
||||||
if shutil.which(tool) is None:
|
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...")
|
print(f" - Extracting Audio Track #{index} to FLAC...")
|
||||||
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}", "-map_metadata", "-1"
|
||||||
]
|
]
|
||||||
if should_downmix and ch >= 6:
|
if should_downmix and ch >= 6:
|
||||||
if ch == 6:
|
if ch == 6:
|
||||||
@@ -54,7 +69,7 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
|
|||||||
|
|
||||||
print(f" - Normalizing Audio Track #{index} with SoX...")
|
print(f" - Normalizing Audio Track #{index} with SoX...")
|
||||||
run_cmd([
|
run_cmd([
|
||||||
"sox", str(temp_extracted), str(temp_normalized), "-S", "--temp", str(audio_temp_path), "--guard", "gain", "-n"
|
"sox_ng", "--show-progress", str(temp_extracted), str(temp_normalized), "--temp", str(audio_temp_path), "loudness", "-18"
|
||||||
])
|
])
|
||||||
|
|
||||||
# Set bitrate based on the final channel count of the Opus file.
|
# Set bitrate based on the final channel count of the Opus file.
|
||||||
@@ -68,14 +83,16 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
|
|||||||
else:
|
else:
|
||||||
# Not downmixing (or source is already stereo or less).
|
# Not downmixing (or source is already stereo or less).
|
||||||
# Base bitrate on the source channel count.
|
# Base bitrate on the source channel count.
|
||||||
if ch == 2: # Stereo
|
if ch == 1: # Mono
|
||||||
|
bitrate = "64k"
|
||||||
|
elif ch == 2: # Stereo
|
||||||
bitrate = "128k"
|
bitrate = "128k"
|
||||||
elif ch == 6: # 5.1 Surround
|
elif ch == 6: # 5.1 Surround
|
||||||
bitrate = "256k"
|
bitrate = "256k"
|
||||||
elif ch == 8: # 7.1 Surround
|
elif ch == 8: # 7.1 Surround
|
||||||
bitrate = "384k"
|
bitrate = "384k"
|
||||||
else: # Mono or other layouts
|
else: # Other layouts
|
||||||
bitrate = "96k" # A sensible default for mono.
|
bitrate = "96k" # A sensible default for other/uncommon layouts.
|
||||||
|
|
||||||
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
|
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
|
||||||
run_cmd([
|
run_cmd([
|
||||||
@@ -83,10 +100,9 @@ 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):
|
def convert_video(source_file_base, source_file_full, is_vfr, target_cfr_fps_for_handbrake, autocrop_filter=None):
|
||||||
print(" --- Starting Video Processing ---")
|
print(" --- Starting Video Processing ---")
|
||||||
# source_file_base is file_path.stem (e.g., "my.anime.episode.01")
|
# 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")
|
vpy_file = Path(f"{source_file_base}.vpy")
|
||||||
ut_video_file = Path(f"{source_file_base}.ut.mkv")
|
ut_video_file = Path(f"{source_file_base}.ut.mkv")
|
||||||
encoded_video_file = Path(f"temp-{source_file_base}.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_args = [
|
||||||
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", str(current_input_for_utvideo),
|
"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",
|
"-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)
|
run_cmd(ffmpeg_args)
|
||||||
ut_video_full_path = os.path.abspath(ut_video_file)
|
ut_video_full_path = os.path.abspath(ut_video_file)
|
||||||
vpy_script_content = f"""import vapoursynth as vs
|
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:
|
with vpy_file.open("w", encoding="utf-8") as f:
|
||||||
f.write(vpy_script_content)
|
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)...")
|
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
|
total_cores = os.cpu_count() or 4 # Fallback if cpu_count is None
|
||||||
workers = max(total_cores - 2, 1) # Ensure at least 1 worker
|
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}).")
|
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.
|
# 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}")
|
print(f" - Using SVT-AV1 parameters: {av1an_video_params_str}")
|
||||||
|
|
||||||
av1an_enc_args = [
|
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",
|
"-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),
|
"-w", str(workers),
|
||||||
"-v", av1an_video_params_str
|
"-v", av1an_video_params_str
|
||||||
]
|
]
|
||||||
@@ -207,111 +206,314 @@ def is_ffmpeg_decodable(file_path):
|
|||||||
except subprocess.CalledProcessError:
|
except subprocess.CalledProcessError:
|
||||||
return False
|
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()
|
check_tools()
|
||||||
|
|
||||||
current_dir = Path(".")
|
# 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
|
||||||
|
|
||||||
# Check if there are any MKV files to process before creating directories
|
current_dir = Path(".")
|
||||||
files_to_process = sorted(
|
files_to_process = sorted(
|
||||||
f for f in current_dir.glob("*.mkv")
|
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 (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:
|
if not files_to_process:
|
||||||
print("No MKV files found to process. Exiting.")
|
print("No MKV files found to process. Exiting.")
|
||||||
return # Exit without creating directories
|
return
|
||||||
|
|
||||||
# Only create directories when we actually have files to process
|
|
||||||
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
|
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
|
||||||
DIR_ORIGINAL.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
|
DIR_CONV_LOGS.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
files_to_process = sorted(
|
files_to_process = sorted(
|
||||||
f for f in current_dir.glob("*.mkv")
|
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 (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:
|
if not files_to_process:
|
||||||
print("No more .mkv files found to process in the current directory. The script will now exit.")
|
print("No more .mkv files found to process in the current directory. The script will now exit.")
|
||||||
break
|
break
|
||||||
|
|
||||||
# Process the first file in the list. The list is requeried in the next iteration.
|
|
||||||
file_path = files_to_process[0]
|
file_path = files_to_process[0]
|
||||||
|
|
||||||
# --- Add ffmpeg decodability check here ---
|
|
||||||
if not is_ffmpeg_decodable(file_path):
|
if not is_ffmpeg_decodable(file_path):
|
||||||
print(f"ERROR: ffmpeg cannot decode '{file_path.name}'. Skipping this file.", file=sys.stderr)
|
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)
|
shutil.move(str(file_path), DIR_ORIGINAL / file_path.name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
|
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
|
||||||
# This print remains on the console, indicating which file is starting.
|
log_file_name = f"{file_path.stem}.log"
|
||||||
# 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_path = DIR_CONV_LOGS / log_file_name
|
log_file_path = DIR_CONV_LOGS / log_file_name
|
||||||
|
|
||||||
original_stdout_console = sys.stdout
|
original_stdout_console = sys.stdout
|
||||||
original_stderr_console = sys.stderr
|
original_stderr_console = sys.stderr
|
||||||
|
|
||||||
# Announce to console (original stdout)
|
|
||||||
print(f"Processing: {file_path.name}", file=original_stdout_console)
|
print(f"Processing: {file_path.name}", file=original_stdout_console)
|
||||||
print(f"Logging output to: {log_file_path}", file=original_stdout_console)
|
print(f"Logging output to: {log_file_path}", file=original_stdout_console)
|
||||||
|
|
||||||
log_file_handle = None
|
log_file_handle = None
|
||||||
processing_error_occurred = False
|
processing_error_occurred = False
|
||||||
date_for_runtime_calc = datetime.now() # For runtime calculation
|
date_for_runtime_calc = datetime.now()
|
||||||
|
try:
|
||||||
try: # Outer try for log redirection and file handling
|
|
||||||
log_file_handle = open(log_file_path, 'w', encoding='utf-8')
|
log_file_handle = open(log_file_path, 'w', encoding='utf-8')
|
||||||
sys.stdout = log_file_handle
|
sys.stdout = log_file_handle
|
||||||
sys.stderr = log_file_handle
|
sys.stderr = log_file_handle
|
||||||
|
|
||||||
# --- Start of log-specific messages ---
|
|
||||||
print(f"STARTING LOG FOR: {file_path.name}")
|
print(f"STARTING LOG FOR: {file_path.name}")
|
||||||
print(f"Processing started at: {date_for_runtime_calc}")
|
print(f"Processing started at: {date_for_runtime_calc}")
|
||||||
print(f"Full input file path: {file_path.resolve()}")
|
print(f"Full input file path: {file_path.resolve()}")
|
||||||
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
|
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
|
||||||
|
input_file_abs = file_path.resolve()
|
||||||
input_file_abs = file_path.resolve() # Used by original logic
|
intermediate_output_file = current_dir / f"output-{file_path.name}"
|
||||||
intermediate_output_file = current_dir / f"output-{file_path.name}" # Used by original logic
|
audio_temp_dir = None
|
||||||
audio_temp_dir = None # Initialize before inner try
|
handbrake_intermediate_for_cleanup = None
|
||||||
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.
|
|
||||||
try:
|
try:
|
||||||
audio_temp_dir = tempfile.mkdtemp(prefix="anime_audio_")
|
audio_temp_dir = tempfile.mkdtemp(prefix="anime_audio_")
|
||||||
print(f"Audio temporary directory created at: {audio_temp_dir}")
|
print(f"Audio temporary directory created at: {audio_temp_dir}")
|
||||||
print(f"Analyzing file: {input_file_abs}")
|
print(f"Analyzing file: {input_file_abs}")
|
||||||
|
|
||||||
ffprobe_info_json = run_cmd([
|
ffprobe_info_json = run_cmd([
|
||||||
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(input_file_abs)
|
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", str(input_file_abs)
|
||||||
], capture_output=True)
|
], capture_output=True)
|
||||||
ffprobe_info = json.loads(ffprobe_info_json)
|
ffprobe_info = json.loads(ffprobe_info_json)
|
||||||
|
|
||||||
mkvmerge_info_json = run_cmd([
|
mkvmerge_info_json = run_cmd([
|
||||||
"mkvmerge", "-J", str(input_file_abs)
|
"mkvmerge", "-J", str(input_file_abs)
|
||||||
], capture_output=True)
|
], capture_output=True)
|
||||||
mkv_info = json.loads(mkvmerge_info_json)
|
mkv_info = json.loads(mkvmerge_info_json)
|
||||||
|
|
||||||
mediainfo_json = run_cmd([
|
mediainfo_json = run_cmd([
|
||||||
"mediainfo", "--Output=JSON", "-f", str(input_file_abs)
|
"mediainfo", "--Output=JSON", "-f", str(input_file_abs)
|
||||||
], capture_output=True)
|
], capture_output=True)
|
||||||
media_info = json.loads(mediainfo_json)
|
media_info = json.loads(mediainfo_json)
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
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":
|
||||||
video_track_info = track
|
video_track_info = track
|
||||||
break
|
break
|
||||||
|
|
||||||
if video_track_info:
|
if video_track_info:
|
||||||
frame_rate_mode = video_track_info.get("FrameRate_Mode")
|
frame_rate_mode = video_track_info.get("FrameRate_Mode")
|
||||||
if frame_rate_mode and frame_rate_mode.upper() in ["VFR", "VARIABLE"]:
|
if frame_rate_mode and frame_rate_mode.upper() in ["VFR", "VARIABLE"]:
|
||||||
@@ -322,20 +524,16 @@ def main(no_downmix=False):
|
|||||||
match = re.search(r'\((\d+/\d+)\)', original_fps_str)
|
match = re.search(r'\((\d+/\d+)\)', original_fps_str)
|
||||||
if match:
|
if match:
|
||||||
target_cfr_fps_for_handbrake = match.group(1)
|
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")
|
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate_Original")
|
||||||
|
if not target_cfr_fps_for_handbrake:
|
||||||
if not target_cfr_fps_for_handbrake: # Fallback if Original_String didn't yield
|
|
||||||
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate_Original")
|
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate_Original")
|
||||||
|
if not target_cfr_fps_for_handbrake:
|
||||||
if not target_cfr_fps_for_handbrake: # Further fallback to current FrameRate
|
|
||||||
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate")
|
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate")
|
||||||
if target_cfr_fps_for_handbrake:
|
if target_cfr_fps_for_handbrake:
|
||||||
print(f" - Using MediaInfo FrameRate ({target_cfr_fps_for_handbrake}) as fallback for HandBrake target FPS.")
|
print(f" - Using MediaInfo FrameRate ({target_cfr_fps_for_handbrake}) as fallback for HandBrake target FPS.")
|
||||||
|
|
||||||
if target_cfr_fps_for_handbrake:
|
if target_cfr_fps_for_handbrake:
|
||||||
print(f" - Target CFR for HandBrake: {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:
|
if isinstance(target_cfr_fps_for_handbrake, str) and "/" in target_cfr_fps_for_handbrake:
|
||||||
try:
|
try:
|
||||||
num, den = map(float, target_cfr_fps_for_handbrake.split('/'))
|
num, den = map(float, target_cfr_fps_for_handbrake.split('/'))
|
||||||
@@ -343,15 +541,22 @@ def main(no_downmix=False):
|
|||||||
print(f" - Converted fractional FPS to decimal for HandBrake: {target_cfr_fps_for_handbrake}")
|
print(f" - Converted fractional FPS to decimal for HandBrake: {target_cfr_fps_for_handbrake}")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
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 # Revert if conversion fails
|
is_vfr = False
|
||||||
else:
|
else:
|
||||||
print(" - Warning: VFR detected, but could not determine target CFR from MediaInfo. Will attempt standard UTVideo conversion without HandBrake.")
|
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:
|
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.")
|
||||||
|
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(
|
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 ---")
|
print("--- Starting Audio Processing ---")
|
||||||
@@ -442,7 +647,6 @@ def main(no_downmix=False):
|
|||||||
|
|
||||||
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 = [
|
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}.vpy",
|
||||||
current_dir / f"{file_path.stem}.ut.mkv",
|
current_dir / f"{file_path.stem}.ut.mkv",
|
||||||
current_dir / f"temp-{file_path.stem}.mkv", # This is encoded_video_file
|
current_dir / f"temp-{file_path.stem}.mkv", # This is encoded_video_file
|
||||||
@@ -521,7 +725,11 @@ def main(no_downmix=False):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
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("--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()
|
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)
|
||||||
|
|||||||
735
av1_opus_encoder.py
Normal file
735
av1_opus_encoder.py
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
import re # Added for VFR frame rate parsing
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REQUIRED_TOOLS = [
|
||||||
|
"ffmpeg", "ffprobe", "mkvmerge", "mkvpropedit",
|
||||||
|
"sox_ng", "opusenc", "mediainfo", "av1an", "HandBrakeCLI" # Added HandBrakeCLI
|
||||||
|
]
|
||||||
|
DIR_COMPLETED = Path("completed")
|
||||||
|
DIR_ORIGINAL = Path("original")
|
||||||
|
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:
|
||||||
|
print(f"Required tool '{tool}' not found in PATH.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def run_cmd(cmd, capture_output=False, check=True):
|
||||||
|
if capture_output:
|
||||||
|
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=check, text=True)
|
||||||
|
return result.stdout
|
||||||
|
else:
|
||||||
|
subprocess.run(cmd, check=check)
|
||||||
|
|
||||||
|
def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_downmix):
|
||||||
|
audio_temp_path = Path(audio_temp_dir)
|
||||||
|
temp_extracted = audio_temp_path / f"track_{index}_extracted.flac"
|
||||||
|
temp_normalized = audio_temp_path / f"track_{index}_normalized.flac"
|
||||||
|
final_opus = audio_temp_path / f"track_{index}_final.opus"
|
||||||
|
|
||||||
|
print(f" - Extracting Audio Track #{index} to FLAC...")
|
||||||
|
ffmpeg_args = [
|
||||||
|
"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:
|
||||||
|
ffmpeg_args += ["-af", "pan=stereo|c0=c2+0.30*c0+0.30*c4|c1=c2+0.30*c1+0.30*c5"]
|
||||||
|
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"]
|
||||||
|
else: # Other multi-channel (e.g. 7ch, 10ch)
|
||||||
|
ffmpeg_args += ["-ac", "2"]
|
||||||
|
ffmpeg_args += ["-c:a", "flac", str(temp_extracted)]
|
||||||
|
run_cmd(ffmpeg_args)
|
||||||
|
|
||||||
|
print(f" - Normalizing Audio Track #{index} with SoX...")
|
||||||
|
run_cmd([
|
||||||
|
"sox_ng", "--show-progress", str(temp_extracted), str(temp_normalized), "--temp", str(audio_temp_path), "loudness", "-18"
|
||||||
|
])
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
if is_being_downmixed:
|
||||||
|
# Downmixing from 5.1 or 7.1 results in a stereo track.
|
||||||
|
bitrate = "128k"
|
||||||
|
else:
|
||||||
|
# Not downmixing (or source is already stereo or less).
|
||||||
|
# Base bitrate on the source channel count.
|
||||||
|
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: # Other layouts
|
||||||
|
bitrate = "96k" # A sensible default for other/uncommon layouts.
|
||||||
|
|
||||||
|
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
|
||||||
|
run_cmd([
|
||||||
|
"opusenc", "--vbr", "--bitrate", bitrate, str(temp_normalized), str(final_opus)
|
||||||
|
])
|
||||||
|
return final_opus
|
||||||
|
|
||||||
|
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")
|
||||||
|
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")
|
||||||
|
handbrake_cfr_intermediate_file = None # To store path of HandBrake output if created
|
||||||
|
|
||||||
|
current_input_for_utvideo = Path(source_file_full)
|
||||||
|
|
||||||
|
if is_vfr and target_cfr_fps_for_handbrake:
|
||||||
|
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_args = [
|
||||||
|
"HandBrakeCLI",
|
||||||
|
"--input", str(source_file_full),
|
||||||
|
"--output", str(handbrake_cfr_intermediate_file),
|
||||||
|
"--cfr",
|
||||||
|
"--rate", str(target_cfr_fps_for_handbrake),
|
||||||
|
"--encoder", "x264_10bit", # Changed to x264_10bit for 10-bit CFR intermediate
|
||||||
|
"--quality", "0", # CRF 0 for x264 is often considered visually lossless, or near-lossless
|
||||||
|
"--encoder-preset", "superfast", # Use a fast preset for quicker processing
|
||||||
|
"--encoder-tune", "fastdecode", # Added tune for faster decoding
|
||||||
|
"--audio", "none",
|
||||||
|
"--subtitle", "none",
|
||||||
|
"--crop-mode", "none" # Disable auto-cropping
|
||||||
|
]
|
||||||
|
print(f" - Running HandBrakeCLI: {' '.join(handbrake_args)}")
|
||||||
|
try:
|
||||||
|
run_cmd(handbrake_args)
|
||||||
|
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}")
|
||||||
|
current_input_for_utvideo = handbrake_cfr_intermediate_file
|
||||||
|
else:
|
||||||
|
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 # Ensure it's None if failed
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f" - Error during HandBrakeCLI execution: {e}")
|
||||||
|
print(f" - Proceeding with original source for UTVideo.")
|
||||||
|
handbrake_cfr_intermediate_file = None # Ensure it's None if failed
|
||||||
|
|
||||||
|
|
||||||
|
print(" - Creating UTVideo intermediate file (overwriting if exists)...")
|
||||||
|
# Check if source is already UTVideo
|
||||||
|
ffprobe_cmd = [
|
||||||
|
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||||
|
"-show_entries", "stream=codec_name", "-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
|
str(current_input_for_utvideo) # Use current input, which might be HandBrake output
|
||||||
|
]
|
||||||
|
source_codec = run_cmd(ffprobe_cmd, capture_output=True, check=True).strip()
|
||||||
|
|
||||||
|
video_codec_args = ["-c:v", "utvideo"]
|
||||||
|
if source_codec == "utvideo" and current_input_for_utvideo == Path(source_file_full): # Only copy if original was UTVideo
|
||||||
|
print(" - Source is already UTVideo. Copying video stream...")
|
||||||
|
video_codec_args = ["-c:v", "copy"]
|
||||||
|
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
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
|
||||||
|
core = vs.core
|
||||||
|
core.num_threads = 4
|
||||||
|
clip = core.lsmas.LWLibavSource(source=r'''{ut_video_full_path}''')
|
||||||
|
clip = core.resize.Point(clip, format=vs.YUV420P10, matrix_in_s="709") # type: ignore
|
||||||
|
clip.set_output()
|
||||||
|
"""
|
||||||
|
with vpy_file.open("w", encoding="utf-8") as f:
|
||||||
|
f.write(vpy_script_content)
|
||||||
|
|
||||||
|
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(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).")
|
||||||
|
|
||||||
|
# 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()])
|
||||||
|
print(f" - Using SVT-AV1 parameters: {av1an_video_params_str}")
|
||||||
|
|
||||||
|
av1an_enc_args = [
|
||||||
|
"av1an", "-i", str(vpy_file), "-o", str(encoded_video_file), "-n",
|
||||||
|
"-e", "svt-av1", "--resume", "--sc-pix-format", "yuv420p", "-c", "mkvmerge",
|
||||||
|
"--set-thread-affinity", "2", "--pix-format", "yuv420p10le", "--force",
|
||||||
|
"-w", str(workers),
|
||||||
|
"-v", av1an_video_params_str
|
||||||
|
]
|
||||||
|
run_cmd(av1an_enc_args)
|
||||||
|
print(" --- Finished Video Processing ---")
|
||||||
|
return encoded_video_file, handbrake_cfr_intermediate_file
|
||||||
|
|
||||||
|
def is_ffmpeg_decodable(file_path):
|
||||||
|
"""Quickly check if ffmpeg can decode the input file."""
|
||||||
|
try:
|
||||||
|
# Try to decode a short segment of the first audio stream
|
||||||
|
subprocess.run([
|
||||||
|
"ffmpeg", "-v", "error", "-i", str(file_path), "-map", "0:a:0", "-t", "1", "-f", "null", "-"
|
||||||
|
], check=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return 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()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
file_path = files_to_process[0]
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
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()
|
||||||
|
try:
|
||||||
|
log_file_handle = open(log_file_path, 'w', encoding='utf-8')
|
||||||
|
sys.stdout = log_file_handle
|
||||||
|
sys.stderr = log_file_handle
|
||||||
|
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()
|
||||||
|
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"]:
|
||||||
|
is_vfr = True
|
||||||
|
print(f" - Detected VFR based on MediaInfo FrameRate_Mode: {frame_rate_mode}")
|
||||||
|
original_fps_str = video_track_info.get("FrameRate_Original_String")
|
||||||
|
if original_fps_str:
|
||||||
|
match = re.search(r'\((\d+/\d+)\)', original_fps_str)
|
||||||
|
if match:
|
||||||
|
target_cfr_fps_for_handbrake = match.group(1)
|
||||||
|
else:
|
||||||
|
target_cfr_fps_for_handbrake = video_track_info.get("FrameRate_Original")
|
||||||
|
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:
|
||||||
|
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}")
|
||||||
|
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('/'))
|
||||||
|
target_cfr_fps_for_handbrake = f"{num / den:.3f}"
|
||||||
|
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
|
||||||
|
else:
|
||||||
|
print(" - Warning: VFR detected, but could not determine target CFR from MediaInfo. Will attempt standard UTVideo conversion without HandBrake.")
|
||||||
|
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, autocrop_filter=autocrop_filter
|
||||||
|
)
|
||||||
|
|
||||||
|
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 track mapping by track ID
|
||||||
|
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", [])
|
||||||
|
mediainfo_audio_tracks = {int(t.get("StreamOrder", -1)): t for t in media_tracks_data if t.get("@type") == "Audio"}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
# Find mkvmerge track by matching ffprobe stream index to mkvmerge track's 'properties'->'stream_id'
|
||||||
|
mkv_track = None
|
||||||
|
for t in mkv_info.get("tracks", []):
|
||||||
|
if t.get("type") == "audio" and t.get("properties", {}).get("stream_id") == stream_index:
|
||||||
|
mkv_track = t
|
||||||
|
break
|
||||||
|
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 {}
|
||||||
|
|
||||||
|
track_id = mkv_track.get("id", -1)
|
||||||
|
track_title = mkv_track.get("properties", {}).get("track_name", "")
|
||||||
|
|
||||||
|
# Find mediainfo track by StreamOrder
|
||||||
|
audio_track_info = mediainfo_audio_tracks.get(stream_index)
|
||||||
|
track_delay = 0
|
||||||
|
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:
|
||||||
|
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:
|
||||||
|
# Convert any codec that is not in REMUX_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
|
||||||
|
})
|
||||||
|
|
||||||
|
print("--- Finished Audio Processing ---")
|
||||||
|
|
||||||
|
# Final mux
|
||||||
|
print("Assembling final file with mkvmerge...")
|
||||||
|
mkvmerge_args = ["mkvmerge", "-o", str(intermediate_output_file), str(encoded_video_file)]
|
||||||
|
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"])]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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 (after successful processing)...")
|
||||||
|
video_temp_files_on_success = [
|
||||||
|
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():
|
||||||
|
video_temp_files_on_success.append(handbrake_intermediate_for_cleanup)
|
||||||
|
|
||||||
|
for temp_vid_file in video_temp_files_on_success:
|
||||||
|
if temp_vid_file.exists():
|
||||||
|
print(f" Deleting: {temp_vid_file}")
|
||||||
|
temp_vid_file.unlink(missing_ok=True)
|
||||||
|
else:
|
||||||
|
print(f" Skipping (not found): {temp_vid_file}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
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")
|
||||||
|
processing_error_occurred = True
|
||||||
|
finally:
|
||||||
|
# This is the original 'finally' block. Its prints go to the log file.
|
||||||
|
print("--- Starting Universal Cleanup (for this file) ---")
|
||||||
|
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(f" - Deleted audio temp dir: {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}")
|
||||||
|
else: # Was never created
|
||||||
|
print(f" - Audio temp dir was not created.")
|
||||||
|
|
||||||
|
print(" - Cleaning up intermediate output file (if it wasn't moved on success)...")
|
||||||
|
if intermediate_output_file.exists(): # Check if it still exists (e.g. error before move)
|
||||||
|
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.")
|
||||||
|
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.")
|
||||||
|
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}")
|
||||||
|
else:
|
||||||
|
# File does not exist at original path
|
||||||
|
if not processing_error_occurred:
|
||||||
|
print(f" - Intermediate output file successfully moved (not found at original path, as expected): {intermediate_output_file}")
|
||||||
|
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).")
|
||||||
|
# --- End of original per-file processing block ---
|
||||||
|
|
||||||
|
print(f"FINISHED LOG FOR: {file_path.name}")
|
||||||
|
# --- End of log-specific messages ---
|
||||||
|
|
||||||
|
finally: # Outer finally for restoring stdout/stderr and closing log file
|
||||||
|
runtime = datetime.now() - date_for_runtime_calc
|
||||||
|
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}")
|
||||||
|
|
||||||
|
if sys.stdout != original_stdout_console:
|
||||||
|
sys.stdout = original_stdout_console
|
||||||
|
if sys.stderr != original_stderr_console:
|
||||||
|
sys.stderr = original_stderr_console
|
||||||
|
if log_file_handle:
|
||||||
|
log_file_handle.close()
|
||||||
|
|
||||||
|
# Announce to console (original stdout/stderr) that this file is done
|
||||||
|
if processing_error_occurred:
|
||||||
|
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"Runtime: {runtime_str}\n")
|
||||||
|
else:
|
||||||
|
original_stdout_console.write(f"File: {file_path.name}\n")
|
||||||
|
original_stdout_console.write(f"Log: {log_file_path}\n")
|
||||||
|
original_stdout_console.write(f"Runtime: {runtime_str}\n")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
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, autocrop=args.autocrop, speed=args.speed, quality=args.quality, grain=args.grain)
|
||||||
@@ -401,12 +401,30 @@ def main():
|
|||||||
duration = int(float(duration_str))
|
duration = int(float(duration_str))
|
||||||
print(f"Detected duration: {duration}s")
|
print(f"Detected duration: {duration}s")
|
||||||
|
|
||||||
|
# Probe for resolution, handling multiple video streams (e.g., with cover art)
|
||||||
probe_res_args = [
|
probe_res_args = [
|
||||||
'ffprobe', '-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=s=x:p=0',
|
'ffprobe', '-v', 'error',
|
||||||
|
'-select_streams', 'v', # Select all video streams
|
||||||
|
'-show_entries', 'stream=width,height,disposition',
|
||||||
|
'-of', 'json',
|
||||||
input_file
|
input_file
|
||||||
]
|
]
|
||||||
resolution_str = subprocess.check_output(probe_res_args, stderr=subprocess.STDOUT, text=True)
|
probe_output = subprocess.check_output(probe_res_args, stderr=subprocess.STDOUT, text=True)
|
||||||
width, height = map(int, resolution_str.strip().split('x'))
|
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}")
|
print(f"Detected resolution: {width}x{height}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ REQUIRED_TOOLS_MAP = {
|
|||||||
"ffprobe": "extra/ffmpeg", # Part of ffmpeg package
|
"ffprobe": "extra/ffmpeg", # Part of ffmpeg package
|
||||||
"mkvmerge": "extra/mkvtoolnix-cli",
|
"mkvmerge": "extra/mkvtoolnix-cli",
|
||||||
"mkvpropedit": "extra/mkvtoolnix-cli", # Part of mkvtoolnix-cli
|
"mkvpropedit": "extra/mkvtoolnix-cli", # Part of mkvtoolnix-cli
|
||||||
"sox": "extra/sox",
|
"sox_ng": "extra/sox-ng",
|
||||||
"opusenc": "extra/opus-tools",
|
"opusenc": "extra/opus-tools",
|
||||||
"mediainfo": "extra/mediainfo",
|
"mediainfo": "extra/mediainfo",
|
||||||
"alabamaEncoder": "pipx install alabamaEncoder"
|
"alabamaEncoder": "pipx install alabamaEncoder"
|
||||||
@@ -76,7 +76,7 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
|
|||||||
|
|
||||||
print(f" - Normalizing Audio Track #{index} with SoX...")
|
print(f" - Normalizing Audio Track #{index} with SoX...")
|
||||||
run_cmd([
|
run_cmd([
|
||||||
"sox", str(temp_extracted), str(temp_normalized), "-S", "--temp", str(audio_temp_path), "--guard", "gain", "-n"
|
"sox_ng", "--show-progress", str(temp_extracted), str(temp_normalized), "--temp", str(audio_temp_path), "loudness", "-18"
|
||||||
])
|
])
|
||||||
|
|
||||||
# Set bitrate based on the final channel count of the Opus file.
|
# Set bitrate based on the final channel count of the Opus file.
|
||||||
@@ -90,14 +90,16 @@ def convert_audio_track(index, ch, lang, audio_temp_dir, source_file, should_dow
|
|||||||
else:
|
else:
|
||||||
# Not downmixing (or source is already stereo or less).
|
# Not downmixing (or source is already stereo or less).
|
||||||
# Base bitrate on the source channel count.
|
# Base bitrate on the source channel count.
|
||||||
if ch == 2: # Stereo
|
if ch == 1: # Mono
|
||||||
|
bitrate = "64k"
|
||||||
|
elif ch == 2: # Stereo
|
||||||
bitrate = "128k"
|
bitrate = "128k"
|
||||||
elif ch == 6: # 5.1 Surround
|
elif ch == 6: # 5.1 Surround
|
||||||
bitrate = "256k"
|
bitrate = "256k"
|
||||||
elif ch == 8: # 7.1 Surround
|
elif ch == 8: # 7.1 Surround
|
||||||
bitrate = "384k"
|
bitrate = "384k"
|
||||||
else: # Mono or other layouts
|
else: # Other layouts
|
||||||
bitrate = "96k" # A sensible default for mono.
|
bitrate = "96k" # A sensible default for other/uncommon layouts.
|
||||||
|
|
||||||
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
|
print(f" - Encoding Audio Track #{index} to Opus at {bitrate}...")
|
||||||
run_cmd([
|
run_cmd([
|
||||||
@@ -105,7 +107,7 @@ 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):
|
def convert_video(source_file_base, source_file_full, autocrop_filter=None):
|
||||||
print(" --- Starting Video Processing ---")
|
print(" --- Starting Video Processing ---")
|
||||||
# source_file_base is the full stem from the original file,
|
# source_file_base is the full stem from the original file,
|
||||||
# e.g., "cheers.s01e04.der.lueckenbuesser.german.dl.fs.1080p.web.h264-cnhd"
|
# e.g., "cheers.s01e04.der.lueckenbuesser.german.dl.fs.1080p.web.h264-cnhd"
|
||||||
@@ -129,7 +131,10 @@ def convert_video(source_file_base, source_file_full):
|
|||||||
ffmpeg_args = [
|
ffmpeg_args = [
|
||||||
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", source_file_full,
|
"ffmpeg", "-hide_banner", "-v", "quiet", "-stats", "-y", "-i", source_file_full,
|
||||||
"-map", "0:v:0", "-map_metadata", "-1", "-map_chapters", "-1", "-an", "-sn", "-dn",
|
"-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)
|
run_cmd(ffmpeg_args)
|
||||||
|
|
||||||
print(" - Starting video encode with AlabamaEncoder (this will take a long time)...")
|
print(" - Starting video encode with AlabamaEncoder (this will take a long time)...")
|
||||||
@@ -159,58 +164,263 @@ def convert_video(source_file_base, source_file_full):
|
|||||||
print(" --- Finished Video Processing ---")
|
print(" --- Finished Video Processing ---")
|
||||||
return ut_video_file, encoded_video_file
|
return ut_video_file, encoded_video_file
|
||||||
|
|
||||||
def main(no_downmix=False):
|
# --- 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()
|
check_tools()
|
||||||
|
|
||||||
current_dir = Path(".")
|
current_dir = Path(".")
|
||||||
|
|
||||||
# Check if there are any MKV files to process before creating directories
|
|
||||||
files_to_process = sorted(
|
files_to_process = sorted(
|
||||||
f for f in current_dir.glob("*.mkv")
|
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 (f.name.endswith(".ut.mkv") or f.name.startswith("temp-") or f.name.startswith("output-"))
|
||||||
)
|
)
|
||||||
|
|
||||||
if not files_to_process:
|
if not files_to_process:
|
||||||
print("No MKV files found to process. Exiting.")
|
print("No MKV files found to process. Exiting.")
|
||||||
return # Exit without creating directories
|
return
|
||||||
|
|
||||||
# Only create directories when we actually have files to process
|
|
||||||
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
|
DIR_COMPLETED.mkdir(exist_ok=True, parents=True)
|
||||||
DIR_ORIGINAL.mkdir(exist_ok=True, parents=True)
|
DIR_ORIGINAL.mkdir(exist_ok=True, parents=True)
|
||||||
DIR_LOGS.mkdir(exist_ok=True, parents=True)
|
DIR_LOGS.mkdir(exist_ok=True, parents=True)
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
files_to_process = sorted(
|
files_to_process = sorted(
|
||||||
f for f in current_dir.glob("*.mkv")
|
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 (f.name.endswith(".ut.mkv") or f.name.startswith("temp-") or f.name.startswith("output-"))
|
||||||
)
|
)
|
||||||
|
|
||||||
if not files_to_process:
|
if not files_to_process:
|
||||||
print("No more .mkv files found to process in the current directory. The script will now exit.")
|
print("No more .mkv files found to process in the current directory. The script will now exit.")
|
||||||
break
|
break
|
||||||
|
|
||||||
file_path = files_to_process[0]
|
file_path = files_to_process[0]
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
log_file_path = DIR_LOGS / f"{file_path.name}.log"
|
log_file_path = DIR_LOGS / f"{file_path.name}.log"
|
||||||
log_file = open(log_file_path, 'w', encoding='utf-8')
|
log_file = open(log_file_path, 'w', encoding='utf-8')
|
||||||
original_stdout = sys.stdout
|
original_stdout = sys.stdout
|
||||||
original_stderr = sys.stderr
|
original_stderr = sys.stderr
|
||||||
sys.stdout = Tee(original_stdout, log_file)
|
sys.stdout = Tee(original_stdout, log_file)
|
||||||
sys.stderr = Tee(original_stderr, log_file)
|
sys.stderr = Tee(original_stderr, log_file)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
|
print("-" * shutil.get_terminal_size(fallback=(80, 24)).columns)
|
||||||
print(f"Starting full processing for: {file_path.name}")
|
print(f"Starting full processing for: {file_path.name}")
|
||||||
date = datetime.now()
|
date = datetime.now()
|
||||||
input_file_abs = file_path.resolve()
|
input_file_abs = file_path.resolve()
|
||||||
intermediate_output_file = current_dir / f"output-{file_path.name}"
|
intermediate_output_file = current_dir / f"output-{file_path.name}"
|
||||||
audio_temp_dir = None # Initialize to None
|
audio_temp_dir = None
|
||||||
created_ut_video_path = None
|
created_ut_video_path = None
|
||||||
created_encoded_video_path = None
|
created_encoded_video_path = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
audio_temp_dir = tempfile.mkdtemp(prefix="tv_audio_") # UUID is not strictly needed for uniqueness
|
audio_temp_dir = tempfile.mkdtemp(prefix="tv_audio_")
|
||||||
print(f"Audio temporary directory created at: {audio_temp_dir}")
|
print(f"Audio temporary directory created at: {audio_temp_dir}")
|
||||||
print(f"Analyzing file: {input_file_abs}")
|
print(f"Analyzing file: {input_file_abs}")
|
||||||
|
|
||||||
@@ -229,7 +439,16 @@ def main(no_downmix=False):
|
|||||||
], capture_output=True)
|
], capture_output=True)
|
||||||
media_info = json.loads(mediainfo_json)
|
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.")
|
||||||
|
|
||||||
|
created_ut_video_path, created_encoded_video_path = convert_video(file_path.stem, str(input_file_abs), autocrop_filter=autocrop_filter)
|
||||||
|
|
||||||
print("--- Starting Audio Processing ---")
|
print("--- Starting Audio Processing ---")
|
||||||
processed_audio_files = []
|
processed_audio_files = []
|
||||||
@@ -352,7 +571,8 @@ def main(no_downmix=False):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
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("--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()
|
args = parser.parse_args()
|
||||||
main(no_downmix=args.no_downmix)
|
main(no_downmix=args.no_downmix, autocrop=args.autocrop)
|
||||||
|
|||||||
Reference in New Issue
Block a user