diff --git a/clairmeta/dcp.py b/clairmeta/dcp.py index d1b17ec..95c0a8f 100644 --- a/clairmeta/dcp.py +++ b/clairmeta/dcp.py @@ -293,9 +293,7 @@ def check( ov_path (str, optional): Absolute path of OriginalVersion DCP. Returns: - Tuple (boolean, dict) of DCP check status and report. Report - is a Dictionary where check failure are grouped by - criticity. + Tuple (boolean, CheckReport) of DCP check status and report. """ if not self._parsed or not self._probeb: diff --git a/clairmeta/dcp_check.py b/clairmeta/dcp_check.py index 6ae5a18..6d8d3e5 100644 --- a/clairmeta/dcp_check.py +++ b/clairmeta/dcp_check.py @@ -52,11 +52,11 @@ def load_modules(self): try: module_path = 'clairmeta.' + prefix + k module_vol = importlib.import_module(module_path) - checker = module_vol.Checker(self.dcp, self.check_profile) + checker = module_vol.Checker(self.dcp, self.profile) checker.hash_callback = self.hash_callback self.check_modules[v] = checker except (ImportError, Exception) as e: - self.check_log.critical("Import error {} : {}".format( + self.log.critical("Import error {} : {}".format( module_path, str(e))) def check(self): @@ -64,7 +64,7 @@ def check(self): self.run_checks() self.make_report() self.dump_report() - return self.get_valid(), self.check_report + return self.report.valid(), self.report def list_checks(self): """ List all available checks. """ @@ -92,7 +92,7 @@ def list_checks(self): def run_checks(self): """ Execute all checks. """ - self.check_log.info("Checking DCP : {}".format(self.dcp.path)) + self.log.info("Checking DCP : {}".format(self.dcp.path)) # Run own tests dcp_checks = self.find_check('dcp') @@ -101,7 +101,7 @@ def run_checks(self): # Run external modules tests for _, checker in six.iteritems(self.check_modules): - self.check_executions += checker.run_checks() + self.checks += checker.run_checks() def check_dcp_empty_dir(self): """ Empty directory detection. diff --git a/clairmeta/dcp_check_am.py b/clairmeta/dcp_check_am.py index 3282c25..811265c 100644 --- a/clairmeta/dcp_check_am.py +++ b/clairmeta/dcp_check_am.py @@ -28,7 +28,7 @@ def run_checks(self): for asset in list_am_assets(source) for check in asset_checks] - return self.check_executions + return self.checks def check_am_xml(self, am): """ AssetMap XML syntax and structure check. diff --git a/clairmeta/dcp_check_atmos.py b/clairmeta/dcp_check_atmos.py index 4852972..1767bbf 100644 --- a/clairmeta/dcp_check_atmos.py +++ b/clairmeta/dcp_check_atmos.py @@ -23,10 +23,10 @@ def run_checks(self): if asset[1]['Schema'] == 'Atmos' for check in asset_checks] - return self.check_executions + return self.checks def check_atmos_cpl_essence_encoding(self, playlist, asset): - """ Atmos encoding. + """ Atmos data essence coding universal label. Reference : SMPTE 429-18-2019 11 diff --git a/clairmeta/dcp_check_base.py b/clairmeta/dcp_check_base.py index a29c588..ff787fd 100644 --- a/clairmeta/dcp_check_base.py +++ b/clairmeta/dcp_check_base.py @@ -5,9 +5,10 @@ import time import inspect import traceback +from datetime import datetime from clairmeta.logger import get_log -from clairmeta.utils.file import human_size +from clairmeta.dcp_check_report import CheckReport class CheckException(Exception): @@ -17,17 +18,29 @@ def __init__(self, msg): class CheckExecution(object): - """ Check execution with status and time elapsed. """ + """ Check execution with status and related metadatas. """ - def __init__(self, name): - self.name = name - self.doc = "" + def __init__(self, func): + """ Constructor for CheckExecution. + + Args: + func (function): Check function. + + """ + self.name = func.__name__ + self.doc = func.__doc__ self.message = "" self.valid = False + self.bypass = False self.seconds_elapsed = 0 self.asset_stack = [] self.criticality = "" + def short_desc(self): + """ Returns first line of the docstring or function name. """ + docstring_lines = self.doc.split('\n') + return docstring_lines[0].strip() if docstring_lines else c.name + class CheckerBase(object): """ Base class for check module, provide check discover and run utilities. @@ -45,10 +58,10 @@ def __init__(self, dcp, profile): """ self.dcp = dcp - self.check_profile = profile - self.check_log = get_log() - self.check_executions = [] - self.check_report = {} + self.profile = profile + self.log = get_log() + self.checks = [] + self.report = None self.hash_callback = None def find_check_criticality(self, name): @@ -61,7 +74,7 @@ def find_check_criticality(self, name): Criticality level string. """ - check_level = self.check_profile['criticality'] + check_level = self.profile['criticality'] default = check_level.get('default', 'ERROR') score_profile = { 0: default @@ -85,7 +98,7 @@ def find_check(self, prefix): """ member_list = inspect.getmembers(self, predicate=inspect.ismethod) - bypass = self.check_profile['bypass'] + bypass = self.profile['bypass'] checks = [] for k, v in member_list: @@ -94,18 +107,18 @@ def find_check(self, prefix): if check_prefix and not check_bypass: checks.append(v) + elif check_bypass: + check_exec = CheckExecution(v) + check_exec.bypass = True + self.checks.append(check_exec) return checks - def find_check_failed(self): - """ Returns a list of all failed checks. """ - return [c for c in self.check_executions if not c.valid] - def run_check(self, check, *args, **kwargs): """ Execute a check. Args: - check (tuple): Tuple (function name, function). + check (function): Check function. *args: Variable list of check function arguments. **kwargs: Variable list of keywords arguments. error_prefix (str): error message prefix @@ -114,171 +127,42 @@ def run_check(self, check, *args, **kwargs): Tuple (status, return_value) """ - start = time.time() - name, func = check.__name__, check - check_exec = CheckExecution(name) - check_exec.doc = check.__doc__ - check_res = None + check_exec = CheckExecution(check) + check_exec.criticality = self.find_check_criticality(check_exec.name) + check_exec.valid = False try: - check_res = func(*args) - check_exec.valid = True - check_exec.msg = "Check valid" + start = time.time() + check_res = None + check_res = check(*args) except CheckException as e: - if kwargs.get('error_prefix'): - msg = "{}\n\t{}".format(kwargs.get('error_prefix'), str(e)) - else: - msg = str(e) - check_exec.msg = msg - check_exec.criticality = self.find_check_criticality(name) + prefix = kwargs.get('error_prefix', '') + check_exec.message = "{}{}".format( + prefix + "\n\t" if prefix else '', str(e)) except Exception as e: - check_exec.msg = "Check unknown error\n{}".format( + check_exec.message = "Check unknown error\n{}".format( traceback.format_exc()) check_exec.criticality = "ERROR" - self.check_log.error(check_exec.msg) + self.log.error(check.msg) + else: + check_exec.valid = True finally: check_exec.asset_stack = kwargs.get('stack', [self.dcp.path]) check_exec.seconds_elapsed = time.time() - start - self.check_executions.append(check_exec) + self.checks.append(check_exec) return check_exec.valid, check_res def make_report(self): """ Check report generation. """ - self.check_report = { - 'ERROR': [], - 'WARNING': [], - 'INFO': [], - 'SILENT': [] - } - - for c in self.find_check_failed(): - self.check_report[c.criticality].append((c.name, c.msg)) - - check_unique = set([c.name for c in self.check_executions]) - self.check_elapsed = {} - self.total_time = 0 - self.total_check = len(check_unique) + report = CheckReport() + report.dcp = self.dcp + report.checks = self.checks + report.profile = self.profile + report.date = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + report.duration = sum([c.seconds_elapsed for c in self.checks]) - for name in check_unique: - execs = [c.seconds_elapsed - for c in self.check_executions if c.name == name] - elapsed = sum(execs) - self.total_time += elapsed - self.check_elapsed[name] = elapsed + self.report = report def dump_report(self): """ Dump check report. """ - valid_str = 'Success' if self.get_valid() else 'Fail' - - pretty_status = { - 'ERROR': 'Error(s)', - 'WARNING': 'Warning(s)', - 'INFO': 'Info(s)', - 'SILENT': 'Supressed(s)', - 'BYPASS': 'Bypass(s)', - } - - map_status = { - 'ERROR': {}, - 'WARNING': {}, - 'INFO': {}, - 'SILENT': {}, - } - - # Accumulate all failed check and stack them by asset - for c in self.find_check_failed(): - node = map_status[c.criticality] - for filename in c.asset_stack: - if filename not in node: - node[filename] = {} - - node = node[filename] - if 'messages' not in node: - node['messages'] = [] - - docstring_lines = c.doc.split('\n') - desc = docstring_lines[0] if docstring_lines else c.name - node['messages'] += ['.' + desc + '\n' + c.msg] - - self.check_log.info("DCP : {}".format(self.dcp.path)) - self.check_log.info("Size : {}".format(human_size(self.dcp.size))) - - for status, vals in six.iteritems(map_status): - out_stack = [] - for k, v in six.iteritems(vals): - out_stack += [self.dump_stack("", k, v, indent_level=0)] - if out_stack: - self.check_log.info("{}\n{}".format( - pretty_status[status] + ':', - "\n".join(out_stack))) - - if self.check_profile['bypass']: - checks_str = ' ' + '\n '.join(self.check_profile['bypass']) - self.check_log.info("{}\n{}".format( - pretty_status['BYPASS'] + ':', checks_str)) - - self.check_log.info("Total check : {}".format(self.total_check)) - self.check_log.info("Total time : {:.2f} sec".format(self.total_time)) - self.check_log.info("Validation : {}\n".format(valid_str)) - - def dump_stack(self, out_str, key, values, indent_level): - """ Recursively iterate through the error message stack. - - Args: - out_str (str): Accumulate messages to ``out_str`` - key (str): Filename of the current asset. - values (dict): Message stack to dump. - indent_level (int): Current indentation level. - - Returns: - Output error message string - - """ - indent_offset = 2 - indent_step = 2 - indent_char = ' ' - ind = indent_offset + indent_level - - filename = key - desc = self.title_from_filename(filename) - messages = values.pop('messages', []) - - out_str = '' if indent_level == 0 else '\n' - out_str += indent_char * ind + '+ ' - out_str += filename - out_str += ' ' + desc if desc else '' - - ind += indent_step - for m in messages: - out_str += "\n" - out_str += indent_char * ind - # Correct indentation for multi-lines messages - out_str += ("\n" + indent_char * (ind + 2)).join(m.split("\n")) - ind -= indent_step - - for k, v in six.iteritems(values): - out_str += self.dump_stack( - out_str, k, v, indent_level + indent_step) - - return out_str - - def title_from_filename(self, filename): - """ Returns a human friendly title for the given file. """ - for cpl in self.dcp._list_cpl: - if cpl['FileName'] == filename: - desc = "({})".format( - cpl['Info']['CompositionPlaylist'].get( - 'ContentTitleText', '')) - return desc - - for pkl in self.dcp._list_pkl: - if pkl['FileName'] == filename: - desc = "({})".format( - pkl['Info']['PackingList'].get('AnnotationText', '')) - return desc - - return '' - - def get_valid(self): - """ Check status is valid. """ - return self.check_report['ERROR'] == [] + self.log.info("Check report:\n\n" + self.report.pretty_str()) diff --git a/clairmeta/dcp_check_cpl.py b/clairmeta/dcp_check_cpl.py index ada4872..36f5d4f 100644 --- a/clairmeta/dcp_check_cpl.py +++ b/clairmeta/dcp_check_cpl.py @@ -37,7 +37,7 @@ def run_checks(self): for asset in list_cpl_assets(source) for check in asset_checks] - return self.check_executions + return self.checks def metadata_cmp_pair( self, diff --git a/clairmeta/dcp_check_isdcf_dcnc.py b/clairmeta/dcp_check_isdcf_dcnc.py index c415a45..c7bc512 100644 --- a/clairmeta/dcp_check_isdcf_dcnc.py +++ b/clairmeta/dcp_check_isdcf_dcnc.py @@ -24,7 +24,7 @@ def run_checks(self): [self.run_check(check, source, self.fields, stack=asset_stack) for check in checks] - return self.check_executions + return self.checks def check_dcnc_compliance(self, playlist): """ Digital Cinema Naming Convention compliance (9.3). """ @@ -61,10 +61,18 @@ def check_dcnc_field_aspect_ratio(self, playlist, fields): def check_dcnc_field_date(self, playlist, fields): """ Composition Date validation. """ date_str = fields['Date'].get('Value') - date = datetime.strptime(date_str, '%Y%m%d') - now = datetime.now() - if date > now: - raise CheckException("Date suggest a composition from the future") + if date_str: + try: + date = datetime.strptime(date_str, '%Y%m%d') + except ValueError as e: + raise CheckException( + "Date can't be parsed, expecting YYYYMMDD : {}" + .format(date_str)) + + now = datetime.now() + if date > now: + raise CheckException( + "Date suggest a composition from the future") def check_dcnc_field_package_type(self, playlist, fields): """ Version qualifier is forbidden for OV package. """ @@ -119,7 +127,10 @@ def check_dcnc_field_claim_subtitle(self, playlist, fields): hasSub = fields['Language'].get('Subtitle', False) hasBurnedSub = fields['Language'].get('BurnedSubtitle', False) - if not hasBurnedSub and hasSub != cpl_node['Subtitle']: + cplHasSub = cpl_node['Subtitle'] + cplHasCaption = cpl_node['OpenCaption'] or cpl_node['ClosedCaption'] + captionLanguage = hasSub and not cplHasSub and cplHasCaption + if not hasBurnedSub and hasSub != cplHasSub and not captionLanguage: msg_map = { True: 'Subtitle', False: 'No Subtitle' @@ -128,7 +139,27 @@ def check_dcnc_field_claim_subtitle(self, playlist, fields): raise CheckException( "ContentTitle suggest {} but CPL contains {}".format( msg_map[hasSub], - msg_map[cpl_node['Subtitle']])) + msg_map[cplHasSub])) + + + def check_dcnc_field_claim_caption(self, playlist, fields): + """ Captions (presence) from CPL and ContentTitleText shall match. """ + cpl_node = playlist['Info']['CompositionPlaylist'] + + titleCaption = fields['Language'].get('Caption', '') + if titleCaption is False: + titleCaption = '' + + if 'OCAP' in titleCaption and not cpl_node['OpenCaption']: + raise CheckException("ContentTitle suggest OCAP but CPL has none") + if 'CCAP' in titleCaption and not cpl_node['ClosedCaption']: + raise CheckException("ContentTitle suggest CCAP but CPL has none") + if titleCaption == '' and cpl_node['OpenCaption']: + raise CheckException( + "ContentTitle suggest no caption but CPL has OCAP") + if titleCaption == '' and cpl_node['ClosedCaption']: + raise CheckException( + "ContentTitle suggest no caption but CPL has CCAP") def check_dcnc_field_claim_audio(self, playlist, fields): """ Audio format from CPL and ContentTitleText shall match. """ diff --git a/clairmeta/dcp_check_picture.py b/clairmeta/dcp_check_picture.py index e3b83f1..50fb9da 100644 --- a/clairmeta/dcp_check_picture.py +++ b/clairmeta/dcp_check_picture.py @@ -28,7 +28,7 @@ def run_checks(self): for asset in list_cpl_assets(source, filters='Picture') for check in asset_checks] - return self.check_executions + return self.checks def get_picture_max_bitrate(self, playlist, asset): bitrate_map = { diff --git a/clairmeta/dcp_check_pkl.py b/clairmeta/dcp_check_pkl.py index c3dee4d..f8b2fc3 100644 --- a/clairmeta/dcp_check_pkl.py +++ b/clairmeta/dcp_check_pkl.py @@ -31,7 +31,7 @@ def run_checks(self): for asset in list_pkl_assets(source) for check in asset_checks] - return self.check_executions + return self.checks def check_pkl_xml(self, pkl): """ PKL XML syntax and structure check. """ diff --git a/clairmeta/dcp_check_report.py b/clairmeta/dcp_check_report.py new file mode 100644 index 0000000..494252f --- /dev/null +++ b/clairmeta/dcp_check_report.py @@ -0,0 +1,150 @@ +# Clairmeta - (C) YMAGIS S.A. +# See LICENSE for more information + +import six +from collections import defaultdict + +from clairmeta.utils.file import human_size + + +class CheckReport(object): + """ Check report listing all checks executions. """ + + pretty_status = { + 'ERROR': 'Error(s)', + 'WARNING': 'Warning(s)', + 'INFO': 'Info(s)', + 'SILENT': 'Supressed(s)', + 'BYPASS': 'Bypass(s)', + } + + def __init(self): + """ Constructor for CheckReport. """ + self.dcp = None + self.checks = [] + self.profile = "" + self.date = "" + self.duration = -1 + + def checks_count(self): + """ Return the number of different checks executed. """ + check_unique = set([c.name for c in self.checks if not c.bypass]) + return len(check_unique) + + def checks_failed(self): + """ Returns a list of all failed checks. """ + return [c for c in self.checks if not c.valid and not c.bypass] + + def checks_failed_by_status(self, status): + """ Returns a list of failed checks with ``status``. """ + return [ + c for c in self.checks + if not c.valid and not c.bypass and c.criticality == status] + + def checks_succeeded(self): + """ Returns a list of all succeeded checks. """ + return [c for c in self.checks if c.valid and not c.bypass] + + def checks_bypassed(self): + """ Returns a set of all bypassed unique checks. """ + return [c for c in self.checks if c.bypass] + + def valid(self): + """ Returns validity of checked DCP. """ + return not any([c.criticality == "ERROR" for c in self.checks if not c.valid]) + + def pretty_str(self): + """ Format the report in a human friendly way. """ + report = "" + report += "Status : {}\n".format('Success' if self.valid() else 'Fail') + report += "Path : {}\n".format(self.dcp.path) + report += "Size : {}\n".format(human_size(self.dcp.size)) + report += "Total check : {}\n".format(self.checks_count()) + report += "Total time : {:.2f} sec\n".format(self.duration) + report += "\n" + + nested_dict = lambda: defaultdict(nested_dict) + status_map = nested_dict() + + # Accumulate all failed check and stack them by asset + for c in self.checks_failed(): + asset = status_map[c.criticality] + for filename in c.asset_stack: + asset = asset[filename] + + asset['msg'] = (asset.get('msg', []) + + ['. ' + c.short_desc() + '\n' + c.message]) + + for status, vals in six.iteritems(status_map): + out_stack = [] + for k, v in six.iteritems(vals): + out_stack += [self._dump_stack("", k, v, indent_level=0)] + if out_stack: + report += "{}\n{}\n".format( + self.pretty_status[status] + ':', + "\n".join(out_stack)) + + bypassed = "\n".join(set( + [' . ' + c.short_desc() for c in self.checks_bypassed()])) + if bypassed: + report += "{}\n{}\n".format(self.pretty_status['BYPASS'] + ':', bypassed) + + return report + + def _dump_stack(self, out_str, key, values, indent_level): + """ Recursively iterate through the error message stack. + + Args: + out_str (str): Accumulate messages to ``out_str`` + key (str): Filename of the current asset. + values (dict): Message stack to dump. + indent_level (int): Current indentation level. + + Returns: + Output error message string + + """ + indent_offset = 2 + indent_step = 2 + indent_char = ' ' + ind = indent_offset + indent_level + + filename = key + desc = self._title_from_filename(filename) + messages = values.pop('msg', []) + + out_str = '' if indent_level == 0 else '\n' + out_str += indent_char * ind + '+ ' + out_str += filename + out_str += ' ' + desc if desc else '' + + ind += indent_step + for m in messages: + out_str += "\n" + out_str += indent_char * ind + # Correct indentation for multi-lines messages + out_str += ("\n" + indent_char * (ind + 2)).join(m.split("\n")) + ind -= indent_step + + for k, v in six.iteritems(values): + out_str += self._dump_stack( + out_str, k, v, indent_level + indent_step) + + return out_str + + def _title_from_filename(self, filename): + """ Returns a human friendly title for the given file. """ + for cpl in self.dcp._list_cpl: + if cpl['FileName'] == filename: + desc = "({})".format( + cpl['Info']['CompositionPlaylist'].get( + 'ContentTitleText', '')) + return desc + + for pkl in self.dcp._list_pkl: + if pkl['FileName'] == filename: + desc = "({})".format( + pkl['Info']['PackingList'].get('AnnotationText', '')) + return desc + + return '' diff --git a/clairmeta/dcp_check_sign.py b/clairmeta/dcp_check_sign.py index aff23c6..537ffa3 100644 --- a/clairmeta/dcp_check_sign.py +++ b/clairmeta/dcp_check_sign.py @@ -149,7 +149,7 @@ def run_checks(self): source['FilePath'], stack=asset_stack) for check in checks] - return self.check_executions + return self.checks def check_certif_version(self, cert, index): """ Certificate version check (X509 v3). diff --git a/clairmeta/dcp_check_sound.py b/clairmeta/dcp_check_sound.py index 99ab240..a083c0b 100644 --- a/clairmeta/dcp_check_sound.py +++ b/clairmeta/dcp_check_sound.py @@ -23,7 +23,7 @@ def run_checks(self): source, filters='Sound', required_keys=['Probe']) for check in asset_checks] - return self.check_executions + return self.checks def check_sound_cpl_channels(self, playlist, asset): """ Sound max channels count. diff --git a/clairmeta/dcp_check_subtitle.py b/clairmeta/dcp_check_subtitle.py index a2c3815..4851a60 100644 --- a/clairmeta/dcp_check_subtitle.py +++ b/clairmeta/dcp_check_subtitle.py @@ -160,7 +160,7 @@ def extract_subtitle_text(self, node): if 'Text' in node: text = self.extract_subtitle_text(node['Text']) else: - text = [node] + text = [str(node)] return text @@ -174,7 +174,8 @@ def __init__(self, dcp, profile): def run_checks(self): for cpl in self.dcp._list_cpl: assets = list_cpl_assets( - cpl, filters='Subtitle', required_keys=['Path']) + cpl, filters=['Subtitle', 'ClosedCaption'], + required_keys=['Path']) for asset in assets: stack = [cpl['FileName'], asset[1].get('Path', asset[1]['Id'])] @@ -185,7 +186,7 @@ def run_checks(self): checks = self.find_check('subtitle_cpl') self.run_checks_prepare(checks, cpl, asset) - return self.check_executions + return self.checks def run_checks_prepare(self, checks, cpl, asset): _, asset_node = asset diff --git a/clairmeta/dcp_check_utils.py b/clairmeta/dcp_check_utils.py index 98f20a6..4e11ad3 100644 --- a/clairmeta/dcp_check_utils.py +++ b/clairmeta/dcp_check_utils.py @@ -36,7 +36,9 @@ def check_xml(xml_path, xml_ns, schema_type, schema_dcp): except LookupError as e: get_log().info("Schema validation skipped : {}".format(xml_path)) except Exception as e: - raise CheckException("Schema validation error : {}".format(str(e))) + raise CheckException( + "Schema validation error : {}\n" + "Using schema : {}".format(str(e), schema_id)) def check_issuedate(date): diff --git a/clairmeta/dcp_check_vol.py b/clairmeta/dcp_check_vol.py index 6b65ba8..8c3efbb 100644 --- a/clairmeta/dcp_check_vol.py +++ b/clairmeta/dcp_check_vol.py @@ -15,7 +15,7 @@ def run_checks(self): [self.run_check(check, source, stack=[source['FileName']]) for check in checks] - return self.check_executions + return self.checks def check_vol_xml(self, vol): """ VolIndex XML syntax and structure check. diff --git a/clairmeta/dcp_parse.py b/clairmeta/dcp_parse.py index 5e001cf..3d0539f 100644 --- a/clairmeta/dcp_parse.py +++ b/clairmeta/dcp_parse.py @@ -12,6 +12,12 @@ from clairmeta.logger import get_log +class ProbeException(Exception): + """ Raised when probing a DCP fails. """ + def __init__(self, msg): + super(ProbeException, self).__init__(six.ensure_str(msg)) + + def discover_schema(node): """ Assign file Schema using detected namespace """ xmlns = node.get('__xmlns__', None) @@ -151,6 +157,7 @@ def cpl_reels_parse(cpl_node): 'MainSubtitle': 'Subtitle', 'MainMarkers': 'Markers', 'CompositionMetadataAsset': 'Metadata', + 'MainCaption' : 'OpenCaption', 'ClosedCaption': 'ClosedCaption', 'MainClosedCaption': 'ClosedCaption', } @@ -163,7 +170,7 @@ def cpl_reels_parse(cpl_node): asset = out_reel['Assets'][val] # Duplicated assets is a fatal error if isinstance(asset, list): - raise Exception( + raise ProbeException( "Duplicated {} asset in CPL {}, Reel {}".format( key, cpl_node.get('ContentTitleText', ''), diff --git a/clairmeta/dcp_utils.py b/clairmeta/dcp_utils.py index 333190c..244cb38 100644 --- a/clairmeta/dcp_utils.py +++ b/clairmeta/dcp_utils.py @@ -54,7 +54,10 @@ def list_pkl_assets(packinglist): def list_cpl_assets( cpl, - filters=['Picture', 'Sound', 'AuxData', 'Subtitle'], + filters=[ + 'Picture', 'Sound', 'AuxData', 'Subtitle', + 'OpenCaption', 'ClosedCaption' + ], required_keys=[] ): """ Iterator on CompositionPlayList assets. @@ -69,7 +72,7 @@ def list_cpl_assets( """ for reel in cpl['Info']['CompositionPlaylist']['ReelList']: - assets = reel.get('Assets', []) + assets = reel.get('Assets', {}) assets = {k: v for k, v in six.iteritems(assets) if k in filters} if required_keys: @@ -193,6 +196,7 @@ def cpl_extract_characteristics(cpl): essence_keys = { 'Sound.Language': [], 'Subtitle.Language': [], + 'OpenCaption.Language': [], 'ClosedCaption.Language': [], } @@ -201,8 +205,11 @@ def cpl_extract_characteristics(cpl): 'Picture': [], 'Sound': [], 'Subtitle': [], + 'OpenCaption': [], + 'ClosedCaption': [], 'AuxData': [], 'Markers': [], + 'Metadata': [], } for reel in cpl['ReelList']: diff --git a/tests/test_dcp_check.py b/tests/test_dcp_check.py index 2620acb..a96b695 100644 --- a/tests/test_dcp_check.py +++ b/tests/test_dcp_check.py @@ -3,6 +3,7 @@ import unittest import os +from datetime import datetime from tests import DCP_MAP, KDM_MAP, KEY from clairmeta.logger import disable_log @@ -46,7 +47,7 @@ def has_succeeded(self): return self.status def has_failed(self, check_name): - failed = self.dcp._checker.find_check_failed() + failed = self.report.checks_failed() return check_name in [c.name for c in failed] @@ -145,5 +146,60 @@ def test_multi_pkl(self): self.assertTrue(self.check(47)) +class DCPCheckReportTest(CheckerTestBase): + + def __init__(self, *args, **kwargs): + super(DCPCheckReportTest, self).__init__(*args, **kwargs) + self.profile['bypass'] = ['check_assets_pkl_hash'] + self.check(25) + + def test_report_metadata(self): + self.assertTrue(isinstance(self.report.profile, dict)) + self.assertTrue(datetime.strptime(self.report.date, "%d/%m/%Y %H:%M:%S")) + self.assertGreaterEqual(self.report.duration, 0) + + def test_report_checks(self): + self.assertGreaterEqual( + len(self.report.checks), self.report.checks_count()) + + failed = self.report.checks_failed() + success = self.report.checks_succeeded() + bypass = self.report.checks_bypassed() + + all_names = [] + for checks in [failed, success, bypass]: + all_names += [c.name for c in checks] + self.assertEqual(sorted(all_names), sorted([c.name for c in self.report.checks])) + self.assertEqual( + len(failed) + len(success) + len(bypass), + len(self.report.checks)) + + errors = self.report.checks_failed_by_status('ERROR') + self.assertEqual(3, len(self.report.checks_failed())) + self.assertEqual(1, len(self.report.checks_failed_by_status('ERROR'))) + self.assertEqual(2, len(self.report.checks_failed_by_status('WARNING'))) + + error = errors[0] + self.assertEqual(error.name, "check_picture_cpl_max_bitrate") + self.assertEqual( + error.short_desc(), + "Picture maximum bitrate DCI compliance.") + self.assertEqual( + error.message, + "Exceed DCI maximum bitrate (250.05 Mb/s) : 358.25 Mb/s") + self.assertFalse(error.valid) + self.assertFalse(error.bypass) + self.assertGreaterEqual(error.seconds_elapsed, 0) + self.assertEqual(error.asset_stack, [ + 'CPL_ECL25SingleCPL_TST-48-600_S_EN-XX_UK-U_51_2K_DI_20180301_ECL_SMPTE_OV.xml', + 'ECL25SingleCPL_TST-48-600_S_EN-XX_UK-U_51_2K_DI_20180301_ECL_SMPTE_OV_01.mxf']) + self.assertTrue(error.criticality == "ERROR") + + def test_report_status(self): + self.assertEqual(False, self.report.valid()) + + report = self.report.pretty_str() + self.assertTrue("Picture maximum bitrate DCI compliance." in report) + if __name__ == '__main__': unittest.main()