import functools
import re
from ... import errors, utils
import logging # isort:skip
_log = logging.getLogger(__name__)
NO_DEFAULT_VALUE = object()
[docs]
@functools.cache
def get_width(path, *, dar=True, default=NO_DEFAULT_VALUE):
"""
Return displayed width of video file `path`
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param bool dar: Return display aspect ratio instead of storage aspect ratio
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if width can't be determined
"""
try:
width = utils.mediainfo.lookup(path, ('Video', 'DEFAULT', 'Width'), type=int)
except errors.ContentError as e:
if default is NO_DEFAULT_VALUE:
raise e
else:
return default
else:
par = utils.mediainfo.lookup(path, ('Video', 'DEFAULT', 'PixelAspectRatio'), type=float, default=1.0)
if dar and par > 1.0:
_log.debug('Display width: %r * %r = %r', width, par, width * par)
width = int(width * par)
return width
[docs]
@functools.cache
def get_height(path, *, dar=True, default=NO_DEFAULT_VALUE):
"""
Return displayed height of video file `path`
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param bool dar: Return display aspect ratio instead of storage aspect ratio
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if height can't be determined
"""
try:
height = utils.mediainfo.lookup(path, ('Video', 'DEFAULT', 'Height'), type=int)
except errors.ContentError as e:
if default is NO_DEFAULT_VALUE:
raise e
else:
return default
else:
par = utils.mediainfo.lookup(path, ('Video', 'DEFAULT', 'PixelAspectRatio'), type=float, default=1.0)
if dar and par < 1.0:
_log.debug('Display height: (1 / %r) * %r = %r', par, height, (1 / par) * height)
height = int((1 / par) * height)
return height
[docs]
def get_resolution(path, default=NO_DEFAULT_VALUE):
"""
Return resolution and scan type of video file `path` as :class:`str` (e.g. "1080p")
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if resolution can't be determined
"""
try:
resolution = get_resolution_int(path)
scan_type = get_scan_type(path)
except errors.ContentError as e:
if default is NO_DEFAULT_VALUE:
raise e
else:
return default
else:
return f'{resolution}{scan_type}'
# Normal widths and heights for normal aspect ratios.
std_resolutions = {
4 / 3: {
240: (320, 240),
360: (480, 360),
480: (640, 480),
576: (768, 576),
720: (960, 720),
1080: (1440, 1080),
2160: (2880, 2160),
4320: (7680, 4320),
},
16 / 9: {
240: (427, 240),
360: (640, 360),
480: (854, 480),
576: (1024, 576),
720: (1280, 720),
1080: (1920, 1080),
2160: (3840, 2160),
4320: (7680, 4320),
},
21 / 9: {
# Height must shrink because width cannot grow.
240: (427, 183),
360: (640, 275),
480: (854, 366),
576: (1024, 439),
720: (1280, 549),
1080: (1920, 823),
2160: (3840, 1646),
4320: (7680, 3292),
},
}
# Maximum widths and heights per resolution while ignoring aspect ratios.
max_resolutions = {
240: (427, 240),
360: (640, 360),
480: (854, 480),
576: (1024, 576),
720: (1280, 720),
1080: (1920, 1080),
2160: (3840, 2160),
4320: (7680, 4320),
}
[docs]
@functools.cache
def get_resolution_int(path, default=NO_DEFAULT_VALUE):
"""
Return resolution of video file `path` as :class:`int` (e.g. ``1080``)
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if resolution can't be determined
"""
try:
sar_width = get_width(path, dar=False)
sar_height = get_height(path, dar=False)
dar_width = get_width(path, dar=True)
dar_height = get_height(path, dar=True)
except errors.ContentError as e:
if default is NO_DEFAULT_VALUE:
raise e
else:
return default
else:
aspect_ratio = dar_width / dar_height
closest_aspect_ratio = utils.closest_number(aspect_ratio, std_resolutions)
is_anamorphic = sar_width != dar_width or sar_height != dar_height
_log.debug('sar_width=%r, sar_height=%r, dar_width=%r, dar_height=%r, aspect_ratio=%.3f->%.3f, is_anamorphic=%r',
sar_width, sar_height, dar_width, dar_height, aspect_ratio, closest_aspect_ratio, is_anamorphic)
# Some resolutions are in the middle between two resolutions. For example, 1480x620 is
# conventionally labeled as 720p even though it is too big.
height_distances = {}
for resolution, (max_width, max_height) in sorted(max_resolutions.items()):
std_width, std_height = std_resolutions[closest_aspect_ratio][resolution]
_log.debug('%4d: %dx%d (max: %dx%d)', resolution, std_width, std_height, max_width, max_height)
# Find width/height distance to normal width/height for the closest standard aspect ratio.
width_distance_from_std_aspect_ratio = sar_width - std_width
_log.debug(f' {width_distance_from_std_aspect_ratio=}: {sar_width} - {std_width}')
height_distance_from_std_aspect_ratio = sar_height - std_height
_log.debug(f' {height_distance_from_std_aspect_ratio=}: {sar_height} - {std_height}')
height_distances[abs(height_distance_from_std_aspect_ratio)] = resolution
# Check if width/height is smaller than the maximum for this resolution.
is_within_bounds = sar_width <= max_width and sar_height <= max_height
_log.debug(f' {is_within_bounds=}: {sar_width} <= {max_width} and {sar_height} <= {max_height}')
# Check if width is too far away from the standard width.
if is_anamorphic:
is_reasonably_close = True
else:
is_reasonably_close = abs(width_distance_from_std_aspect_ratio) < std_width * 0.06
_log.debug(
f' {is_reasonably_close=}: '
f'abs({width_distance_from_std_aspect_ratio}) < ({std_width} * 0.06 = {std_width * 0.06:.0f})'
)
if is_within_bounds and is_reasonably_close:
_log.debug(' Resolution is close enough')
return resolution
height_distances_sorted = sorted(height_distances.items())
_log.debug('Picking closest resolution by normal height for %.3f: %r', closest_aspect_ratio, height_distances_sorted)
return height_distances_sorted[0][1]
[docs]
def get_scan_type(path):
"""
Return scan type of video file `path` ("i" for interlaced, "p" for progressive)
This always defaults to "p" if it cannot be determined.
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:raise ContentError: if scan type can't be determined
"""
scan_type = utils.mediainfo.lookup(path, ('Video', 'DEFAULT', 'ScanType'), default='p').lower()
if scan_type in ('interlaced', 'mbaff', 'paff'):
return 'i'
else:
return 'p'
[docs]
def get_frame_rate(path, default=NO_DEFAULT_VALUE):
"""
Return frames per second of default video track as :class:`float`
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if anything goes wrong
"""
return utils.mediainfo.lookup(
path=path,
keys=('Video', 'DEFAULT', 'FrameRate'),
default=default,
type=float,
)
[docs]
def get_bit_depth(path, default=NO_DEFAULT_VALUE):
"""
Return bit depth of default video track (e.g. ``8`` or ``10``)
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if anything goes wrong
"""
return utils.mediainfo.lookup(
path=path,
keys=('Video', 'DEFAULT', 'BitDepth'),
default=default,
type=int,
)
known_hdr_formats = {
'DV',
'HDR10+',
'HDR10',
'HDR',
}
"""Set of valid HDR format names"""
[docs]
def is_bt601(path, default=NO_DEFAULT_VALUE):
"""
Whether `path` is BT.601 (~SD) video
:param str path: Path to video file
For directories, the return value of :func:`find_main_video` is used.
:param default: Return value if `path` doesn't exist, raise :exc:`~.ContentError` if not
provided
:raise ContentError: if anything goes wrong
"""
try:
if (
_is_color_matrix(path, 'BT.601')
or _is_color_matrix(path, 'BT.470 System B/G')
):
return True
else:
# Assume BT.601 if default video is SD.
# https://rendezvois.github.io/video/screenshots/programs-choices/#color-matrix
resolution = get_resolution_int(path)
return resolution < 720
except errors.ContentError as e:
if default is NO_DEFAULT_VALUE:
raise e
else:
return default
[docs]
def is_bt709(path, default=NO_DEFAULT_VALUE):
"""
Whether `path` is BT.709 (~UHD) video
See :func:`is_bt601`.
"""
return _is_color_matrix(path, 'BT.709', default=default)
[docs]
def is_bt2020(path, default=NO_DEFAULT_VALUE):
"""
Whether `path` is BT.2020 (~UHD) video
See :func:`is_bt601`.
"""
return _is_color_matrix(path, 'BT.2020', default=default)
def _is_color_matrix(path, matrix, default=NO_DEFAULT_VALUE):
def normalize_matrix(matrix):
# Remove whitespace and convert to lower case.
return ''.join(matrix.casefold().split())
matrix = normalize_matrix(matrix)
try:
video_tracks = utils.mediainfo.lookup(path, ('Video',))
except errors.ContentError as e:
if default is NO_DEFAULT_VALUE:
raise e
else:
return default
else:
# https://rendezvois.github.io/video/screenshots/programs-choices/#color-matrix
for track in video_tracks:
if normalize_matrix(track.get('matrix_coefficients', '')).startswith(matrix):
return True
return False
_video_translations = (
('x264', {'Encoded_Library_Name': re.compile(r'^x264$')}),
('x265', {'Encoded_Library_Name': re.compile(r'^x265$')}),
('XviD', {'Encoded_Library_Name': re.compile(r'^XviD$')}),
('DivX', {'Encoded_Library_Name': re.compile(r'^DivX$')}),
('H.264', {'Format': re.compile(r'^AVC$')}),
('H.265', {'Format': re.compile(r'^HEVC$')}),
('VP9', {'Format': re.compile(r'^VP9$')}),
('VC-1', {'Format': re.compile(r'^VC-1$')}),
('AV1', {'Format': re.compile(r'^AV1$')}),
('MPEG-4', {'Format': re.compile(r'^MPEG-4')}),
('MPEG-2', {'Format': re.compile(r'^MPEG Video$')}),
)