Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6af7ac5c25 | |||
| 7a8a85d953 | |||
| 58a9c8ce3b | |||
| a71a07e2d2 | |||
| 33767e1f54 |
25
README.md
25
README.md
@@ -16,41 +16,30 @@ Ensure the following command-line tools are installed and available in your syst
|
||||
|
||||
The scripts are designed to be run in sequence. They create and use local directories (`cuts`, `segments`, `temp`) to store intermediate files.
|
||||
|
||||
### 1. (Optional) Detect Crop
|
||||
If your video has black bars, you can use `cropdetect.py` to find the correct crop values.
|
||||
|
||||
```bash
|
||||
python cropdetect.py "path/to/your/video.mkv"
|
||||
```
|
||||
|
||||
### 2. Cut Video into Scenes
|
||||
### 1. Cut Video into Scenes
|
||||
Use [`scene_cutter.py`](scene_cutter.py) to analyze the video, detect scene changes, and split the source into lossless segments in the `cuts/` directory.
|
||||
|
||||
```bash
|
||||
python scene_cutter.py "path/to/your/video.mkv"
|
||||
```
|
||||
|
||||
### 3. Encode Segments
|
||||
Choose one of the encoder scripts to process the files from the `cuts/` directory. Encoded files will be placed in the `segments/` directory.
|
||||
|
||||
**Option A: VMAF-based Encoding (Recommended)**
|
||||
Use [`vmaf_encoder.py`](vmaf_encoder.py) to encode each segment to a target VMAF quality level.
|
||||
If your video has black bars, you can use the `--autocrop` flag to automatically detect and apply the correct crop values during this process.
|
||||
|
||||
```bash
|
||||
python vmaf_encoder.py --target-vmaf 96.0
|
||||
python scene_cutter.py "path/to/your/video.mkv" --autocrop
|
||||
```
|
||||
|
||||
**Option B: Static CRF Encoding**
|
||||
Use [`static_encoder.py`](static_encoder.py) to encode all segments with a single, fixed CRF value.
|
||||
### 2. Encode Segments
|
||||
Use [`static_encoder.py`](static_encoder.py) to encode all segments from the `cuts/` directory with a single, fixed CRF value. Encoded files will be placed in the `segments/` directory.
|
||||
|
||||
```bash
|
||||
python static_encoder.py --crf 27
|
||||
```
|
||||
|
||||
### 4. Mux Final Video
|
||||
### 3. Mux Final Video
|
||||
Use [`segment_muxer.py`](segment_muxer.py) to combine all the encoded `.mkv` files from the `segments/` directory into a single output file.
|
||||
|
||||
```bash
|
||||
python segment_muxer.py --cleanup
|
||||
```
|
||||
Using the `--cleanup` flag will automatically remove the `cuts/` and `segments/` directories
|
||||
Using the `--cleanup` flag will automatically remove the `cuts/` and `segments/` directories upon successful completion of the muxing process.
|
||||
@@ -401,12 +401,30 @@ def main():
|
||||
duration = int(float(duration_str))
|
||||
print(f"Detected duration: {duration}s")
|
||||
|
||||
# Probe for resolution, handling multiple video streams (e.g., with cover art)
|
||||
probe_res_args = [
|
||||
'ffprobe', '-v', 'error', '-select_streams', 'v: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
|
||||
]
|
||||
resolution_str = subprocess.check_output(probe_res_args, stderr=subprocess.STDOUT, text=True)
|
||||
width, height = map(int, resolution_str.strip().split('x'))
|
||||
probe_output = subprocess.check_output(probe_res_args, stderr=subprocess.STDOUT, text=True)
|
||||
streams_data = json.loads(probe_output)
|
||||
|
||||
video_stream = None
|
||||
# Find the first video stream that is NOT an attached picture
|
||||
for stream in streams_data.get('streams', []):
|
||||
if stream.get('disposition', {}).get('attached_pic', 0) == 0:
|
||||
video_stream = stream
|
||||
break
|
||||
|
||||
if not video_stream or 'width' not in video_stream or 'height' not in video_stream:
|
||||
# If no suitable stream is found, raise an error.
|
||||
raise ValueError("Could not find a valid video stream to probe for resolution.")
|
||||
|
||||
width = int(video_stream['width'])
|
||||
height = int(video_stream['height'])
|
||||
print(f"Detected resolution: {width}x{height}")
|
||||
|
||||
except Exception as e:
|
||||
265
scene_cutter.py
265
scene_cutter.py
@@ -4,6 +4,10 @@ import json
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import re
|
||||
from collections import Counter
|
||||
import multiprocessing
|
||||
import shutil
|
||||
|
||||
# --- Utility Functions (from previous scripts) ---
|
||||
|
||||
@@ -64,18 +68,235 @@ def get_video_duration(video_path):
|
||||
print(f"\nError getting video duration: {e}")
|
||||
return None
|
||||
|
||||
# --- Core Logic Functions ---
|
||||
def get_video_resolution(video_path):
|
||||
"""Gets the resolution (width, height) of a video file using ffprobe's JSON output for robustness."""
|
||||
command = [
|
||||
'ffprobe',
|
||||
'-v', 'quiet',
|
||||
'-print_format', 'json',
|
||||
'-show_streams',
|
||||
video_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=True, encoding='utf-8')
|
||||
data = json.loads(result.stdout)
|
||||
for stream in data.get('streams', []):
|
||||
if stream.get('codec_type') == 'video' and 'width' in stream and 'height' in stream:
|
||||
return int(stream['width']), int(stream['height'])
|
||||
|
||||
# If no video stream with resolution is found
|
||||
raise ValueError("Could not find video stream with resolution in ffprobe output.")
|
||||
except (FileNotFoundError, subprocess.CalledProcessError, json.JSONDecodeError, ValueError) as e:
|
||||
print(f"\nError getting video resolution: {e}")
|
||||
return None, None
|
||||
|
||||
def detect_scenes(video_path, json_output_path, hwaccel=None, threshold=0.4):
|
||||
# --- Core Logic Functions (Ported 1:1 from cropdetect.py) ---
|
||||
|
||||
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 parse_crop_string(crop_str):
|
||||
"""Parses a 'crop=w:h:x:y' string into a dictionary of integers."""
|
||||
try:
|
||||
_, values = crop_str.split('=')
|
||||
w, h, x, y = map(int, values.split(':'))
|
||||
return {'w': w, 'h': h, 'x': x, 'y': y}
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
def snap_to_known_ar(w, h, x, y, video_w, video_h, tolerance=0.03):
|
||||
"""Snaps a crop rectangle to the nearest standard aspect ratio if it's close enough."""
|
||||
if h == 0: return f"crop={w}:{h}:{x}:{y}", None
|
||||
detected_ratio = w / h
|
||||
|
||||
best_match = None
|
||||
smallest_diff = float('inf')
|
||||
|
||||
for ar in KNOWN_ASPECT_RATIOS:
|
||||
diff = abs(detected_ratio - ar['ratio'])
|
||||
if diff < smallest_diff:
|
||||
smallest_diff = diff
|
||||
best_match = ar
|
||||
|
||||
if not best_match or (smallest_diff / best_match['ratio']) >= tolerance:
|
||||
return f"crop={w}:{h}:{x}:{y}", None
|
||||
|
||||
# Heuristic: if width is close to full video width, it's letterboxed.
|
||||
if abs(w - video_w) < 16:
|
||||
new_h = round(video_w / best_match['ratio'])
|
||||
# Round height up to the nearest multiple of 8 for cleaner dimensions.
|
||||
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 # Ensure y offset is even
|
||||
return f"crop={video_w}:{new_h}:0:{new_y}", best_match['name']
|
||||
|
||||
# Heuristic: if height is close to full video height, it's pillarboxed.
|
||||
if abs(h - video_h) < 16:
|
||||
new_w = round(video_h * best_match['ratio'])
|
||||
# Round width up to the nearest multiple of 8.
|
||||
if new_w % 8 != 0:
|
||||
new_w = new_w + (8 - (new_w % 8))
|
||||
new_x = round((video_w - new_w) / 2)
|
||||
if new_x % 2 != 0: new_x -= 1 # Ensure x offset is even
|
||||
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(crop_counts, tolerance=8):
|
||||
"""Groups similar crop values into clusters based on the top-left corner."""
|
||||
clusters = []
|
||||
temp_counts = crop_counts.copy()
|
||||
while temp_counts:
|
||||
center_str, _ = temp_counts.most_common(1)[0]
|
||||
parsed_center = parse_crop_string(center_str)
|
||||
if not parsed_center:
|
||||
del temp_counts[center_str]; continue
|
||||
|
||||
cx, cy = parsed_center['x'], parsed_center['y']
|
||||
cluster_total_count = 0
|
||||
crops_to_remove = []
|
||||
for crop_str, count in temp_counts.items():
|
||||
parsed_crop = parse_crop_string(crop_str)
|
||||
if parsed_crop and abs(parsed_crop['x'] - cx) <= tolerance and abs(parsed_crop['y'] - cy) <= tolerance:
|
||||
cluster_total_count += count
|
||||
crops_to_remove.append(crop_str)
|
||||
|
||||
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]
|
||||
|
||||
return sorted(clusters, key=lambda c: c['count'], reverse=True)
|
||||
|
||||
def calculate_bounding_box(crop_keys):
|
||||
"""Calculates a bounding box that contains all given crop rectangles."""
|
||||
min_x, max_x = float('inf'), float('-inf')
|
||||
min_y, max_y = float('inf'), float('-inf')
|
||||
for key in crop_keys:
|
||||
parsed = parse_crop_string(key)
|
||||
if parsed:
|
||||
x, y, w, h = parsed['x'], parsed['y'], parsed['w'], parsed['h']
|
||||
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)
|
||||
|
||||
final_w, final_h = (max_x - min_x), (max_y - min_y)
|
||||
if final_w % 2 != 0: final_w -= 1
|
||||
if final_h % 2 != 0: final_h -= 1
|
||||
return f"crop={final_w}:{final_h}:{min_x}:{min_y}"
|
||||
|
||||
def analyze_segment_for_crop(task_args):
|
||||
"""Worker process to analyze one video segment for crop values."""
|
||||
seek_time, input_file = 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')
|
||||
return re.findall(r'crop=\d+:\d+:\d+:\d+', result.stderr)
|
||||
|
||||
def detect_crop(video_path, hwaccel=None):
|
||||
"""
|
||||
Detects black bars using the full, robust logic from cropdetect.py, including
|
||||
multiprocess analysis, clustering, and aspect ratio snapping.
|
||||
"""
|
||||
print("\nStarting robust crop detection (1:1 logic from cropdetect.py)...")
|
||||
|
||||
# --- Parameters from original script ---
|
||||
significant_crop_threshold = 5.0
|
||||
num_workers = max(1, multiprocessing.cpu_count() // 2)
|
||||
|
||||
# --- Probing ---
|
||||
duration = get_video_duration(video_path)
|
||||
width, height = get_video_resolution(video_path)
|
||||
if not all([duration, width, height]):
|
||||
print("Could not get video metadata. Aborting crop detection.")
|
||||
return None
|
||||
|
||||
# --- Analysis ---
|
||||
num_tasks = num_workers * 4
|
||||
segment_duration = max(1, duration // num_tasks)
|
||||
tasks = [(i * segment_duration, video_path) for i in range(num_tasks)]
|
||||
|
||||
print(f"Analyzing {len(tasks)} segments across {num_workers} worker(s)...")
|
||||
all_crops = []
|
||||
with multiprocessing.Pool(processes=num_workers) as pool:
|
||||
for i, result in enumerate(pool.imap_unordered(analyze_segment_for_crop, tasks), 1):
|
||||
all_crops.extend(result)
|
||||
sys.stdout.write(f"\rAnalyzing Segments: {i}/{len(tasks)} completed...")
|
||||
sys.stdout.flush()
|
||||
print("\nAnalysis complete.")
|
||||
|
||||
if not all_crops:
|
||||
print("No black bars detected.")
|
||||
return None
|
||||
|
||||
# --- Decision Logic ---
|
||||
crop_counts = Counter(all_crops)
|
||||
clusters = cluster_crop_values(crop_counts)
|
||||
total_detections = sum(c['count'] for c in clusters)
|
||||
|
||||
if total_detections == 0:
|
||||
print("No valid crop detections found.")
|
||||
return None
|
||||
|
||||
significant_clusters = [c for c in clusters if (c['count'] / total_detections * 100) >= significant_crop_threshold]
|
||||
|
||||
final_crop = None
|
||||
ar_label = None
|
||||
|
||||
if not significant_clusters:
|
||||
print(f"No single crop value meets the {significant_crop_threshold}% significance threshold. No crop will be applied.")
|
||||
return None
|
||||
|
||||
elif len(significant_clusters) == 1:
|
||||
print("A single dominant aspect ratio was found.")
|
||||
final_crop = significant_clusters[0]['center']
|
||||
|
||||
else: # Mixed AR
|
||||
print("Mixed aspect ratios detected. Calculating a safe 'master' crop.")
|
||||
crop_keys = [c['center'] for c in significant_clusters]
|
||||
final_crop = calculate_bounding_box(crop_keys)
|
||||
|
||||
# --- Snapping ---
|
||||
parsed = parse_crop_string(final_crop)
|
||||
if not parsed: return None
|
||||
|
||||
snapped_crop, ar_label = snap_to_known_ar(parsed['w'], parsed['h'], parsed['x'], parsed['y'], width, height)
|
||||
if ar_label:
|
||||
print(f"The detected crop snaps to the '{ar_label}' aspect ratio.")
|
||||
|
||||
# --- Final Check ---
|
||||
parsed_snapped = parse_crop_string(snapped_crop)
|
||||
if parsed_snapped and parsed_snapped['w'] == width and parsed_snapped['h'] == height:
|
||||
print("Final crop matches source resolution. No cropping needed.")
|
||||
return None
|
||||
|
||||
print(f"Robust crop detection finished. Recommended filter: {snapped_crop}")
|
||||
return snapped_crop
|
||||
|
||||
def detect_scenes(video_path, json_output_path, hwaccel=None, threshold=0.23, crop_filter=None):
|
||||
"""Uses FFmpeg to detect scene changes and saves timestamps to a JSON file."""
|
||||
print(f"\nStarting scene detection for: {os.path.basename(video_path)}")
|
||||
# NOTE: Hardware acceleration is intentionally disabled for scene detection.
|
||||
# The scenedetect filter can be unreliable with hwaccel contexts as it
|
||||
# operates on CPU frames. The performance gain is negligible for this step.
|
||||
command = ['ffmpeg', '-hide_banner']
|
||||
if hwaccel:
|
||||
print(f"Attempting to use hardware acceleration: {hwaccel}")
|
||||
command.extend(['-hwaccel', hwaccel])
|
||||
|
||||
filter_string = f"select='gt(scene,{threshold})',showinfo"
|
||||
command.extend(['-i', video_path, '-vf', filter_string, '-f', 'null', '-'])
|
||||
filters = []
|
||||
if crop_filter:
|
||||
print(f"Applying crop filter during scene detection: {crop_filter}")
|
||||
filters.append(crop_filter)
|
||||
filters.append(f"select='gt(scene,{threshold})',showinfo")
|
||||
filter_string = ",".join(filters)
|
||||
|
||||
# Add -map 0:v:0 to explicitly select the first video stream, ignoring cover art.
|
||||
command.extend(['-i', video_path, '-map', '0:v:0', '-vf', filter_string, '-f', 'null', '-'])
|
||||
|
||||
try:
|
||||
process = subprocess.Popen(command, stderr=subprocess.PIPE, text=True, encoding='utf-8')
|
||||
@@ -105,7 +326,7 @@ def detect_scenes(video_path, json_output_path, hwaccel=None, threshold=0.4):
|
||||
print(f"\nAn error occurred during scene detection: {e}")
|
||||
return False
|
||||
|
||||
def cut_video_into_scenes(video_path, json_path, max_segment_length, hwaccel=None):
|
||||
def cut_video_into_scenes(video_path, json_path, max_segment_length, hwaccel=None, crop_filter=None):
|
||||
"""Cuts a video into segments, ensuring no segment exceeds a maximum length."""
|
||||
print(f"\nStarting segment cutting for: {os.path.basename(video_path)}")
|
||||
try:
|
||||
@@ -148,7 +369,15 @@ def cut_video_into_scenes(video_path, json_path, max_segment_length, hwaccel=Non
|
||||
if hwaccel:
|
||||
command.extend(['-hwaccel', hwaccel])
|
||||
|
||||
command.extend(['-i', video_path, '-c:v', 'utvideo', '-an', '-sn', '-dn', '-map_metadata', '-1', '-map_chapters', '-1', '-f', 'segment', '-segment_times', segment_times_str, '-segment_start_number', '1', '-reset_timestamps', '1', output_pattern])
|
||||
command.extend(['-i', video_path])
|
||||
|
||||
if crop_filter:
|
||||
print(f"Applying crop filter during cutting: {crop_filter}")
|
||||
command.extend(['-vf', crop_filter])
|
||||
|
||||
# Add -map 0:v:0 to explicitly select the first video stream for cutting.
|
||||
# Combine with -an/-sn to ensure no other streams are processed.
|
||||
command.extend(['-map', '0:v:0', '-c:v', 'utvideo', '-an', '-sn', '-dn', '-map_metadata', '-1', '-map_chapters', '-1', '-f', 'segment', '-segment_times', segment_times_str, '-segment_start_number', '1', '-reset_timestamps', '1', output_pattern])
|
||||
|
||||
print("\nStarting FFmpeg to cut all segments in a single pass...")
|
||||
try:
|
||||
@@ -167,6 +396,11 @@ def main():
|
||||
formatter_class=argparse.RawTextHelpFormatter
|
||||
)
|
||||
parser.add_argument("video_path", help="Path to the input video file.")
|
||||
parser.add_argument(
|
||||
"--autocrop",
|
||||
action='store_true',
|
||||
help="Automatically detect and apply cropping to remove black bars. Default: False"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--so", "--sceneonly",
|
||||
action='store_true',
|
||||
@@ -182,8 +416,8 @@ def main():
|
||||
parser.add_argument(
|
||||
"-t", "--threshold",
|
||||
type=float,
|
||||
default=0.4,
|
||||
help="Scene detection threshold (0.0 to 1.0). Lower is more sensitive. Default: 0.4"
|
||||
default=0.23,
|
||||
help="Scene detection threshold (0.0 to 1.0). Lower is more sensitive. Default: 0.23"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -195,21 +429,24 @@ def main():
|
||||
json_path = f"{base_name}.scenes.json"
|
||||
|
||||
hwaccel_method = get_best_hwaccel()
|
||||
crop_filter = None
|
||||
if args.autocrop:
|
||||
crop_filter = detect_crop(args.video_path, hwaccel_method)
|
||||
|
||||
if args.sceneonly:
|
||||
detect_scenes(args.video_path, json_path, hwaccel_method, args.threshold)
|
||||
detect_scenes(args.video_path, json_path, hwaccel_method, args.threshold, crop_filter)
|
||||
sys.exit(0)
|
||||
|
||||
# --- Full Workflow (Detect if needed, then Cut) ---
|
||||
if not os.path.isfile(json_path):
|
||||
print("--- Scene file not found, running detection first ---")
|
||||
if not detect_scenes(args.video_path, json_path, hwaccel_method, args.threshold):
|
||||
if not detect_scenes(args.video_path, json_path, hwaccel_method, args.threshold, crop_filter):
|
||||
print("\nScene detection failed. Aborting process.")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"--- Found existing scene file: {os.path.basename(json_path)} ---")
|
||||
|
||||
cut_video_into_scenes(args.video_path, json_path, args.segtime, hwaccel_method)
|
||||
cut_video_into_scenes(args.video_path, json_path, args.segtime, hwaccel_method, crop_filter)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,6 +4,9 @@ import os
|
||||
import sys
|
||||
import argparse
|
||||
import shutil
|
||||
import multiprocessing
|
||||
import re
|
||||
import time
|
||||
|
||||
def get_video_resolution(video_path):
|
||||
"""Gets video resolution using ffprobe."""
|
||||
@@ -16,110 +19,156 @@ def get_video_resolution(video_path):
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
||||
width, height = map(int, result.stdout.strip().split('x'))
|
||||
return width, height
|
||||
except (subprocess.CalledProcessError, ValueError) as e:
|
||||
print(f" Error getting video resolution for '{video_path}': {e}")
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return None, None
|
||||
|
||||
def encode_segment(segment_path, crf):
|
||||
def get_frame_count(video_path):
|
||||
"""Gets the total number of frames in a video file using ffprobe."""
|
||||
command = [
|
||||
'ffprobe', '-v', 'error', '-select_streams', 'v:0',
|
||||
'-count_frames', '-show_entries', 'stream=nb_read_frames',
|
||||
'-of', 'default=nokey=1:noprint_wrappers=1', video_path
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, check=True)
|
||||
return int(result.stdout.strip())
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return 0
|
||||
|
||||
def encode_segment_worker(task_args):
|
||||
"""
|
||||
Encodes a single segment with a static CRF value.
|
||||
Uses ffmpeg to pipe raw video to the external SvtAv1EncApp.exe encoder.
|
||||
Wrapper for the multiprocessing pool.
|
||||
Reports progress to shared memory objects.
|
||||
"""
|
||||
segment_path, crf, worker_id, progress_dict, total_processed_frames, lock = task_args
|
||||
|
||||
progress_dict[worker_id] = {'fps': 0.0, 'status': 'Starting'}
|
||||
|
||||
success = encode_segment(segment_path, crf, worker_id, progress_dict, total_processed_frames, lock)
|
||||
|
||||
status = 'Finished' if success else 'FAILED'
|
||||
progress_dict[worker_id] = {'fps': 0.0, 'status': status}
|
||||
|
||||
return success
|
||||
|
||||
def encode_segment(segment_path, crf, worker_id, progress_dict, total_processed_frames, lock):
|
||||
"""
|
||||
Encodes a single segment, reporting progress via shared objects.
|
||||
Calculates FPS manually based on frame processing time.
|
||||
"""
|
||||
output_dir = "segments"
|
||||
base_name = os.path.basename(segment_path)
|
||||
final_output_path = os.path.join(output_dir, base_name)
|
||||
|
||||
# Get video resolution to pass to the encoder
|
||||
width, height = get_video_resolution(segment_path)
|
||||
if not width or not height:
|
||||
print(f" Could not determine resolution for '{base_name}'. Skipping segment.")
|
||||
return False
|
||||
|
||||
print(f" -> Encoding with static CRF {crf} using SvtAv1EncApp.exe...")
|
||||
|
||||
# Command to use ffmpeg as a frameserver, decoding the segment
|
||||
# and piping raw 10-bit y4m video to stdout.
|
||||
ffmpeg_command = [
|
||||
'ffmpeg', '-hide_banner', '-loglevel', 'error',
|
||||
'-i', segment_path,
|
||||
'-pix_fmt', 'yuv420p10le', # Force 10-bit pixel format for the pipe
|
||||
'-f', 'yuv4mpegpipe',
|
||||
'-strict', '-1', # Allow non-standard pixel format in y4m pipe
|
||||
'-' # to stdout
|
||||
'ffmpeg', '-hide_banner', '-loglevel', 'error', '-i', segment_path,
|
||||
'-pix_fmt', 'yuv420p10le', '-f', 'yuv4mpegpipe', '-strict', '-1', '-'
|
||||
]
|
||||
|
||||
# Command for the external SVT-AV1-PSY encoder, reading from stdin
|
||||
svt_command = [
|
||||
'SvtAv1EncApp.exe',
|
||||
'-i', 'stdin',
|
||||
'--width', str(width),
|
||||
'--height', str(height),
|
||||
'--progress', '2',
|
||||
'--preset', '2',
|
||||
'--input-depth', '10',
|
||||
'--crf', str(crf),
|
||||
'--film-grain', '8',
|
||||
'--tune', '2',
|
||||
'--keyint', '-1',
|
||||
'--color-primaries', '1',
|
||||
'--transfer-characteristics', '1',
|
||||
'--matrix-coefficients', '1',
|
||||
'-b', final_output_path # Encode directly to the final path
|
||||
'SvtAv1EncApp.exe', '-i', 'stdin', '--width', str(width), '--height', str(height),
|
||||
'--progress', '2', '--preset', '2', '--input-depth', '10',
|
||||
'--crf', str(crf), '--film-grain', '8', '--tune', '2', '--keyint', '-1',
|
||||
'--color-primaries', '1', '--transfer-characteristics', '1', '--matrix-coefficients', '1',
|
||||
'-b', final_output_path
|
||||
]
|
||||
|
||||
ffmpeg_process = None
|
||||
svt_process = None
|
||||
try:
|
||||
# Start the ffmpeg frameserver process
|
||||
ffmpeg_process = subprocess.Popen(ffmpeg_command, stdout=subprocess.PIPE)
|
||||
svt_process = subprocess.Popen(svt_command, stdin=ffmpeg_process.stdout, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, encoding='utf-8')
|
||||
|
||||
# Start the SVT encoder process, taking stdin from ffmpeg's stdout
|
||||
# We pass sys.stderr to the encoder so progress is shown in real-time
|
||||
svt_process = subprocess.Popen(svt_command, stdin=ffmpeg_process.stdout, stdout=subprocess.PIPE, stderr=sys.stderr)
|
||||
|
||||
# This allows ffmpeg to receive a SIGPIPE if svt_process exits before ffmpeg is done.
|
||||
if ffmpeg_process.stdout:
|
||||
ffmpeg_process.stdout.close()
|
||||
|
||||
# Wait for the encoder to finish.
|
||||
|
||||
progress_regex = re.compile(r"Encoding frame\s+(\d+)")
|
||||
last_frame = 0
|
||||
frames_processed_this_segment = 0
|
||||
last_update_time = time.time()
|
||||
frames_at_last_update = 0
|
||||
|
||||
for line in iter(svt_process.stderr.readline, ''):
|
||||
line = line.strip()
|
||||
match = progress_regex.search(line)
|
||||
if match:
|
||||
current_frame = int(match.group(1))
|
||||
|
||||
current_time = time.time()
|
||||
time_since_last_update = current_time - last_update_time
|
||||
|
||||
fps = 0.0
|
||||
if time_since_last_update > 1.0:
|
||||
frames_since_last_update = current_frame - frames_at_last_update
|
||||
if frames_since_last_update > 0 and time_since_last_update > 0:
|
||||
fps = frames_since_last_update / time_since_last_update
|
||||
|
||||
last_update_time = current_time
|
||||
frames_at_last_update = current_frame
|
||||
|
||||
delta = current_frame - last_frame
|
||||
if delta > 0:
|
||||
with lock:
|
||||
total_processed_frames.value += delta
|
||||
frames_processed_this_segment += delta
|
||||
last_frame = current_frame
|
||||
|
||||
if fps > 0.0:
|
||||
progress_dict[worker_id] = {'fps': fps, 'status': 'Encoding'}
|
||||
|
||||
svt_process.communicate()
|
||||
ffmpeg_process.wait()
|
||||
|
||||
if svt_process.returncode != 0:
|
||||
# Manually raise an error to be caught by the except block
|
||||
raise subprocess.CalledProcessError(svt_process.returncode, svt_command)
|
||||
|
||||
print(f" -> Success! Finished encoding '{os.path.basename(final_output_path)}'")
|
||||
total_segment_frames = get_frame_count(segment_path)
|
||||
remaining_frames = total_segment_frames - frames_processed_this_segment
|
||||
if remaining_frames > 0:
|
||||
with lock:
|
||||
total_processed_frames.value += remaining_frames
|
||||
|
||||
return True
|
||||
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"\n Encoding failed for CRF {crf}.")
|
||||
if isinstance(e, subprocess.CalledProcessError):
|
||||
print(f" Encoder returned non-zero exit code {e.returncode}. See encoder output above for details.")
|
||||
else: # FileNotFoundError
|
||||
print(f" Error: '{e.filename}' not found. Please ensure SvtAv1EncApp.exe is in your PATH.")
|
||||
|
||||
# Clean up partially created file on failure
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
if os.path.exists(final_output_path):
|
||||
os.remove(final_output_path)
|
||||
return False
|
||||
|
||||
def draw_global_progress(processed_frames, total_frames, progress_dict):
|
||||
"""Draws a single line global progress bar."""
|
||||
bar_width = 50
|
||||
percentage = processed_frames / total_frames if total_frames > 0 else 0
|
||||
filled_len = int(round(bar_width * percentage))
|
||||
bar = '█' * filled_len + '-' * (bar_width - filled_len)
|
||||
|
||||
total_fps = sum(worker.get('fps', 0.0) for worker in progress_dict.values())
|
||||
|
||||
status_str = f"Progress: |{bar}| {processed_frames}/{total_frames} ({percentage:.1%}) @ {total_fps:.2f} FPS"
|
||||
|
||||
sys.stdout.write('\r' + status_str)
|
||||
sys.stdout.flush()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Encodes video segments from the 'cuts' directory to AV1 using a static CRF value.",
|
||||
formatter_class=argparse.RawTextHelpFormatter
|
||||
)
|
||||
parser.add_argument("--crf", type=int, default=27, help="The static CRF value to use for all segments. Default: 27")
|
||||
parser.add_argument(
|
||||
"--workers",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of segments to encode in parallel. Default: 4"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# --- Pre-flight check for executables ---
|
||||
for exe in ["SvtAv1EncApp.exe", "ffprobe"]:
|
||||
if not shutil.which(exe):
|
||||
print(f"Error: '{exe}' not found. Please ensure it is in your system's PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
# --- Setup Directories ---
|
||||
input_dir = "cuts"
|
||||
output_dir = "segments"
|
||||
if not os.path.isdir(input_dir):
|
||||
@@ -128,23 +177,44 @@ def main():
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# --- Process Segments ---
|
||||
segments = sorted([f for f in os.listdir(input_dir) if f.endswith('.mkv')])
|
||||
segments = sorted([os.path.join(input_dir, f) for f in os.listdir(input_dir) if f.endswith('.mkv')])
|
||||
total_segments = len(segments)
|
||||
if total_segments == 0:
|
||||
print(f"No segments found in '{input_dir}'.")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Found {total_segments} segments to process from '{input_dir}'.")
|
||||
print(f"Encoding with static CRF {args.crf}.")
|
||||
print(f"Final files will be saved in '{output_dir}'.")
|
||||
print(f"Encoding with static CRF {args.crf} using {args.workers} parallel worker(s).")
|
||||
|
||||
print("\nGathering segment information...")
|
||||
grand_total_frames = sum(get_frame_count(s) for s in segments)
|
||||
|
||||
manager = multiprocessing.Manager()
|
||||
progress_dict = manager.dict()
|
||||
total_processed_frames = manager.Value('i', 0)
|
||||
lock = manager.Lock()
|
||||
|
||||
tasks = [(segments[i], args.crf, i, progress_dict, total_processed_frames, lock) for i in range(total_segments)]
|
||||
|
||||
for i, segment_file in enumerate(segments):
|
||||
print(f"\n--- Processing segment {i+1}/{total_segments}: {segment_file} ---")
|
||||
segment_path = os.path.join(input_dir, segment_file)
|
||||
encode_segment(segment_path, args.crf)
|
||||
pool = multiprocessing.Pool(processes=args.workers)
|
||||
future_results = pool.imap_unordered(encode_segment_worker, tasks)
|
||||
|
||||
while total_processed_frames.value < grand_total_frames:
|
||||
draw_global_progress(total_processed_frames.value, grand_total_frames, progress_dict)
|
||||
time.sleep(0.1)
|
||||
if all(p.get('status') in ['Finished', 'FAILED'] for p in progress_dict.values()) and len(progress_dict) == total_segments:
|
||||
break
|
||||
|
||||
print("\n--- All segments processed. ---")
|
||||
pool.close()
|
||||
pool.join()
|
||||
|
||||
draw_global_progress(grand_total_frames, grand_total_frames, progress_dict)
|
||||
print()
|
||||
|
||||
results = list(future_results)
|
||||
successful_encodes = sum(1 for r in results if r)
|
||||
print(f"\n--- All segments processed. ---")
|
||||
print(f"Successfully encoded {successful_encodes}/{total_segments} segments.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user