Updates processes and scripts

This commit is contained in:
2025-07-20 15:44:17 +02:00
parent 58a9c8ce3b
commit 7a8a85d953
3 changed files with 344 additions and 109 deletions

View File

@@ -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:

View File

@@ -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,57 +68,225 @@ 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
# --- 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 FFmpeg's cropdetect filter and returns the crop filter string.
Analyzes the first 60 seconds of the video for efficiency.
Detects black bars using the full, robust logic from cropdetect.py, including
multiprocess analysis, clustering, and aspect ratio snapping.
"""
print("\nStarting crop detection...")
command = ['ffmpeg', '-hide_banner']
if hwaccel:
command.extend(['-hwaccel', hwaccel])
print("\nStarting robust crop detection (1:1 logic from cropdetect.py)...")
# Analyze a portion of the video to find crop values
command.extend(['-i', video_path, '-t', '60', '-vf', 'cropdetect', '-f', 'null', '-'])
# --- Parameters from original script ---
significant_crop_threshold = 5.0
num_workers = max(1, multiprocessing.cpu_count() // 2)
try:
# Using Popen to read stderr line by line
process = subprocess.Popen(command, stderr=subprocess.PIPE, text=True, encoding='utf-8')
last_crop_line = ""
for line in iter(process.stderr.readline, ''):
if 'crop=' in line:
last_crop_line = line.strip()
process.wait()
if last_crop_line:
# Find the 'crop=' part in the line and extract it
crop_part_index = last_crop_line.find('crop=')
if crop_part_index != -1:
# Extract the substring starting from 'crop='
crop_filter = last_crop_line[crop_part_index:]
# In case there's other info on the line, split by space and take the first part
crop_filter = crop_filter.split(' ')[0]
print(f"Crop detection finished. Recommended filter: {crop_filter}")
return crop_filter
print("Could not determine crop. No black bars detected or an error occurred.")
# --- 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
except (FileNotFoundError, Exception) as e:
print(f"\nAn error occurred during crop detection: {e}")
# --- 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
def detect_scenes(video_path, json_output_path, hwaccel=None, threshold=0.4, crop_filter=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])
filters = []
if crop_filter:
@@ -123,7 +295,8 @@ def detect_scenes(video_path, json_output_path, hwaccel=None, threshold=0.4, cro
filters.append(f"select='gt(scene,{threshold})',showinfo")
filter_string = ",".join(filters)
command.extend(['-i', video_path, '-vf', filter_string, '-f', 'null', '-'])
# 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')
@@ -202,7 +375,9 @@ def cut_video_into_scenes(video_path, json_path, max_segment_length, hwaccel=Non
print(f"Applying crop filter during cutting: {crop_filter}")
command.extend(['-vf', crop_filter])
command.extend(['-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])
# 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:
@@ -241,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()

View File

@@ -4,6 +4,17 @@ import os
import sys
import argparse
import shutil
import multiprocessing
import re
import math
# --- Global lock for synchronized screen writing ---
LOCK = None
def init_worker(lock):
"""Initializer for each worker process to share the lock."""
global LOCK
LOCK = lock
def get_video_resolution(video_path):
"""Gets video resolution using ffprobe."""
@@ -20,86 +31,94 @@ def get_video_resolution(video_path):
print(f" Error getting video resolution for '{video_path}': {e}")
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 # Return 0 if we can't get the frame count
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 function for multiprocessing pool.
Unpacks arguments and calls the main encoding function.
"""
segment_path, crf, pbar_pos, total_frames = task_args
return encode_segment(segment_path, crf, pbar_pos, total_frames)
def encode_segment(segment_path, crf, pbar_pos, total_frames):
"""
Encodes a single segment, drawing a manual progress bar to the console.
"""
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', '--lp', '1', '--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.PIPE, 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+)/")
def draw_progress(frame, total):
if total == 0: return
bar_width = 40
percentage = frame / total
filled_len = int(round(bar_width * percentage))
bar = '' * filled_len + '-' * (bar_width - filled_len)
# Truncate filename for display
display_name = (base_name[:23] + '..') if len(base_name) > 25 else base_name
progress_str = f"{display_name:<25} |{bar}| {frame}/{total} ({percentage:.1%})"
with LOCK:
sys.stdout.write(f'\033[{pbar_pos};0H') # Move cursor to line `pbar_pos`
sys.stdout.write('\033[K') # Clear the line
sys.stdout.write(progress_str)
sys.stdout.flush()
try:
for line in iter(svt_process.stderr.readline, ''):
match = progress_regex.search(line)
if match:
current_frame = int(match.group(1))
draw_progress(current_frame, total_frames)
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)}'")
draw_progress(total_frames, total_frames) # Final update to 100%
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 Exception:
if os.path.exists(final_output_path):
os.remove(final_output_path)
return False
@@ -110,6 +129,12 @@ def main():
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=max(1, multiprocessing.cpu_count() // 2),
help="Number of segments to encode in parallel. Default: half of available CPU cores."
)
args = parser.parse_args()
@@ -129,22 +154,39 @@ 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"Encoding with static CRF {args.crf} using {args.workers} parallel worker(s).")
print(f"Final files will be saved in '{output_dir}'.")
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)
print("\nGathering segment information...")
frame_counts = [get_frame_count(s) for s in segments]
tasks = [(segments[i], args.crf, i + 1, frame_counts[i]) for i in range(total_segments)]
print("\n--- All segments processed. ---")
# --- Run Encoding ---
# Clear screen and reserve space for progress bars
os.system('cls' if os.name == 'nt' else 'clear')
print(f"--- Starting {args.workers} encoding worker(s) ---")
sys.stdout.write('\n' * args.workers)
sys.stdout.flush()
lock = multiprocessing.Lock()
with multiprocessing.Pool(processes=args.workers, initializer=init_worker, initargs=(lock,)) as pool:
# Use imap to maintain order, so pbar_pos is consistent
results = list(pool.imap(encode_segment_worker, tasks))
# Move cursor below the progress bars
sys.stdout.write(f'\033[{args.workers + 1};0H')
sys.stdout.write('\n')
successful_encodes = sum(1 for r in results if r)
print(f"--- All segments processed. ---")
print(f"Successfully encoded {successful_encodes}/{total_segments} segments.")
if __name__ == "__main__":
main()