mirror of
https://github.com/shadoll/moma.git
synced 2026-08-28 11:33:25 +00:00
Add rename service and utility modules for file renaming operations
- Implemented RenameService for handling file renaming with features like name validation, proposed name generation, conflict detection, and atomic rename operations. - Created utility modules for language code extraction, regex pattern matching, and frame class matching to centralize common functionalities. - Added comprehensive logging for error handling and debugging across all new modules.
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
"""Frame class and aspect ratio matching utilities.
|
||||
|
||||
This module provides centralized logic for determining frame class
|
||||
(resolution classification) based on video dimensions.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from renamer.constants import FRAME_CLASSES
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FrameClassMatcher:
|
||||
"""Shared frame class matching logic.
|
||||
|
||||
This class centralizes the logic for determining frame class
|
||||
(e.g., "1080p", "720p") from video dimensions.
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_dimensions(1920, 1080, scan_type='p')
|
||||
'1080p'
|
||||
"""
|
||||
|
||||
# Tolerance for matching dimensions (pixels)
|
||||
HEIGHT_TOLERANCE_LARGE = 50 # For initial height matching
|
||||
HEIGHT_TOLERANCE_SMALL = 20 # For closest match
|
||||
WIDTH_TOLERANCE = 5 # For width matching
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the frame class matcher."""
|
||||
pass
|
||||
|
||||
def match_by_dimensions(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
scan_type: str = 'p'
|
||||
) -> Optional[str]:
|
||||
"""Match frame class by width and height dimensions.
|
||||
|
||||
Uses a multi-step matching algorithm:
|
||||
1. Try width-based matching with typical widths
|
||||
2. Fall back to effective height calculation
|
||||
3. Try exact height match
|
||||
4. Find closest standard height
|
||||
5. Return custom frame class if no match
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
scan_type: 'p' for progressive, 'i' for interlaced
|
||||
|
||||
Returns:
|
||||
Frame class string (e.g., "1080p") or None if invalid input
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_dimensions(1920, 1080, 'p')
|
||||
'1080p'
|
||||
>>> matcher.match_by_dimensions(1280, 720, 'p')
|
||||
'720p'
|
||||
"""
|
||||
if not width or not height:
|
||||
return None
|
||||
|
||||
# Calculate effective height for aspect ratio consideration
|
||||
aspect_ratio = 16 / 9
|
||||
if height > width:
|
||||
# Portrait mode - unlikely for video but handle it
|
||||
effective_height = height / aspect_ratio
|
||||
else:
|
||||
effective_height = height
|
||||
|
||||
# Step 1: Try to match width to typical widths
|
||||
width_match = self._match_by_width_and_aspect(
|
||||
width, height, scan_type
|
||||
)
|
||||
if width_match:
|
||||
return width_match
|
||||
|
||||
# Step 2: Try exact match with standard frame classes
|
||||
frame_class = f"{int(round(effective_height))}{scan_type}"
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return frame_class
|
||||
|
||||
# Step 3: Find closest standard height match
|
||||
closest_match = self._match_by_closest_height(
|
||||
effective_height, scan_type
|
||||
)
|
||||
if closest_match:
|
||||
return closest_match
|
||||
|
||||
# Step 4: Return custom frame class for non-standard resolutions
|
||||
return frame_class
|
||||
|
||||
def match_by_height(self, height: int) -> Optional[str]:
|
||||
"""Get frame class from video height only.
|
||||
|
||||
Tries exact match first, then finds closest match within tolerance.
|
||||
|
||||
Args:
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match within tolerance
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.match_by_height(1080)
|
||||
'1080p'
|
||||
>>> matcher.match_by_height(1078) # Close to 1080
|
||||
'1080p'
|
||||
"""
|
||||
if not height:
|
||||
return None
|
||||
|
||||
# Try exact match first
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
if height == info['nominal_height']:
|
||||
return frame_class
|
||||
|
||||
# Find closest match
|
||||
closest = None
|
||||
min_diff = float('inf')
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
diff = abs(height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest = frame_class
|
||||
|
||||
# Only return if difference is within tolerance
|
||||
if min_diff <= self.HEIGHT_TOLERANCE_LARGE:
|
||||
return closest
|
||||
|
||||
return None
|
||||
|
||||
def _match_by_width_and_aspect(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
scan_type: str
|
||||
) -> Optional[str]:
|
||||
"""Match frame class by width and aspect ratio.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
scan_type: 'p' or 'i'
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match
|
||||
"""
|
||||
width_matches = []
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
# Only consider frame classes with matching scan type
|
||||
if not frame_class.endswith(scan_type):
|
||||
continue
|
||||
|
||||
# Check if width matches any typical width for this frame class
|
||||
for typical_width in info['typical_widths']:
|
||||
if abs(width - typical_width) <= self.WIDTH_TOLERANCE:
|
||||
# Calculate height difference for this match
|
||||
height_diff = abs(height - info['nominal_height'])
|
||||
width_matches.append((frame_class, height_diff))
|
||||
|
||||
if width_matches:
|
||||
# Choose the frame class with smallest height difference
|
||||
width_matches.sort(key=lambda x: x[1])
|
||||
return width_matches[0][0]
|
||||
|
||||
return None
|
||||
|
||||
def _match_by_closest_height(
|
||||
self,
|
||||
height: float,
|
||||
scan_type: str
|
||||
) -> Optional[str]:
|
||||
"""Find closest standard frame class by height.
|
||||
|
||||
Args:
|
||||
height: Effective video height in pixels (can be float)
|
||||
scan_type: 'p' or 'i'
|
||||
|
||||
Returns:
|
||||
Frame class string or None if no match within tolerance
|
||||
"""
|
||||
closest_class = None
|
||||
min_diff = float('inf')
|
||||
|
||||
for frame_class, info in FRAME_CLASSES.items():
|
||||
# Only consider frame classes with matching scan type
|
||||
if not frame_class.endswith(scan_type):
|
||||
continue
|
||||
|
||||
diff = abs(height - info['nominal_height'])
|
||||
if diff < min_diff:
|
||||
min_diff = diff
|
||||
closest_class = frame_class
|
||||
|
||||
# Only return if within tolerance
|
||||
if closest_class and min_diff <= self.HEIGHT_TOLERANCE_SMALL:
|
||||
return closest_class
|
||||
|
||||
return None
|
||||
|
||||
def get_nominal_height(self, frame_class: str) -> Optional[int]:
|
||||
"""Get the nominal height for a frame class.
|
||||
|
||||
Args:
|
||||
frame_class: Frame class string (e.g., "1080p")
|
||||
|
||||
Returns:
|
||||
Nominal height in pixels or None if not found
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.get_nominal_height("1080p")
|
||||
1080
|
||||
"""
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return FRAME_CLASSES[frame_class]['nominal_height']
|
||||
return None
|
||||
|
||||
def get_typical_widths(self, frame_class: str) -> list[int]:
|
||||
"""Get typical widths for a frame class.
|
||||
|
||||
Args:
|
||||
frame_class: Frame class string (e.g., "1080p")
|
||||
|
||||
Returns:
|
||||
List of typical widths in pixels
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.get_typical_widths("1080p")
|
||||
[1920, 1440, 1280]
|
||||
"""
|
||||
if frame_class in FRAME_CLASSES:
|
||||
return FRAME_CLASSES[frame_class]['typical_widths']
|
||||
return []
|
||||
|
||||
def is_standard_resolution(self, width: int, height: int) -> bool:
|
||||
"""Check if dimensions match a standard resolution.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
True if dimensions are close to a standard resolution
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.is_standard_resolution(1920, 1080)
|
||||
True
|
||||
>>> matcher.is_standard_resolution(1234, 567)
|
||||
False
|
||||
"""
|
||||
# Try to match with either scan type
|
||||
match_p = self.match_by_dimensions(width, height, 'p')
|
||||
match_i = self.match_by_dimensions(width, height, 'i')
|
||||
|
||||
# If we got a match that exists in FRAME_CLASSES, it's standard
|
||||
if match_p and match_p in FRAME_CLASSES:
|
||||
return True
|
||||
if match_i and match_i in FRAME_CLASSES:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def detect_scan_type(self, interlaced: Optional[str]) -> str:
|
||||
"""Detect scan type from interlaced flag.
|
||||
|
||||
Args:
|
||||
interlaced: Interlaced flag (e.g., "Yes", "No", None)
|
||||
|
||||
Returns:
|
||||
'i' for interlaced, 'p' for progressive
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.detect_scan_type("Yes")
|
||||
'i'
|
||||
>>> matcher.detect_scan_type("No")
|
||||
'p'
|
||||
"""
|
||||
if interlaced and str(interlaced).lower() in ['yes', 'true', '1']:
|
||||
return 'i'
|
||||
return 'p'
|
||||
|
||||
def calculate_aspect_ratio(self, width: int, height: int) -> Optional[float]:
|
||||
"""Calculate aspect ratio from dimensions.
|
||||
|
||||
Args:
|
||||
width: Video width in pixels
|
||||
height: Video height in pixels
|
||||
|
||||
Returns:
|
||||
Aspect ratio as float (e.g., 1.777 for 16:9) or None if invalid
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> ratio = matcher.calculate_aspect_ratio(1920, 1080)
|
||||
>>> round(ratio, 2)
|
||||
1.78
|
||||
"""
|
||||
if not width or not height or height == 0:
|
||||
return None
|
||||
return width / height
|
||||
|
||||
def format_aspect_ratio(self, ratio: float) -> str:
|
||||
"""Format aspect ratio as a string.
|
||||
|
||||
Args:
|
||||
ratio: Aspect ratio as float
|
||||
|
||||
Returns:
|
||||
Formatted string (e.g., "16:9", "21:9")
|
||||
|
||||
Example:
|
||||
>>> matcher = FrameClassMatcher()
|
||||
>>> matcher.format_aspect_ratio(1.777)
|
||||
'16:9'
|
||||
>>> matcher.format_aspect_ratio(2.35)
|
||||
'21:9'
|
||||
"""
|
||||
# Common aspect ratios
|
||||
common_ratios = {
|
||||
1.33: "4:3",
|
||||
1.78: "16:9",
|
||||
1.85: "1.85:1",
|
||||
2.35: "21:9",
|
||||
2.39: "2.39:1",
|
||||
}
|
||||
|
||||
# Find closest match
|
||||
closest = min(common_ratios.keys(), key=lambda x: abs(x - ratio))
|
||||
if abs(closest - ratio) < 0.05: # Within 5% tolerance
|
||||
return common_ratios[closest]
|
||||
|
||||
# Return as decimal if no match
|
||||
return f"{ratio:.2f}:1"
|
||||
Reference in New Issue
Block a user