# SPDX-FileCopyrightText: Copyright © 2026 BBC
#
# SPDX-License-Identifier: BSD-3-Clause
from typing import Tuple
from src.validationLogging.validationCodes import ValidationCode
from src.validationLogging.validationLogger import ValidationLogger
from xml.etree.ElementTree import Element
from src.xmlUtils import get_unqualified_name, make_qname, \
xmlIdAttr
from .xmlCheck import XmlCheck
from .daptUtils import ns_daptm
from .ttmlUtils import ns_ttml
from src.timeExpression import TimeExpressionHandler
import traceback
timing_attr_keys = [
'begin',
'end',
'dur',
'clipBegin',
'clipEnd',
]
[docs]
class daptTimingCheck(XmlCheck):
"""
Checks timings in document
Things we need to check:
* Time expressions are well formed in begin, end, dur, clipBegin
and clipEnd attributes:
* clock-time hh:mm:ss or hh:mm:ss.sss
* offset-time nn metric or nn.nn metric where metric is h, m, s, ms, f or t
* Time expressions are well formed in
/tt/head/metadata/daptm:daptOriginTimecode and
/tt/head/metadata/ebuttm:documentStartOfProgramme:
* clock-time hh:mm:ss:ff for either
* offset-time a permitted alternative for documentStartOfProgramme
* If both daptOriginTimecode and documentStartOfProgramme are present,
but they are different, warn that the contents may be offset by delta
relative to the related media
* If f units are used, there's a ttp:frameRate on tt
* frameRate: non-negative integer
* If t units are used, there's a ttp:tickRate on tt
* tickRate: non-negative integer
* If timeContainer is present, its value is "par"
* error if a different value
* warn if present and "par"
Extra things to check:
* What's the earliest begin and latest end time, for info
* In case this is in a segment, do the times overlap the segment interval?
"""
def __init__(self,
epoch: float = 0.0,
segment_dur: float | None = None,
segment_relative_timing: bool = False):
super().__init__()
self._epoch = epoch
self._segment_dur = segment_dur
self._segment_relative_timing = segment_relative_timing
def _check_for_timeContainer(
self,
el: Element,
validation_results: ValidationLogger
) -> bool:
valid = True
if 'timeContainer' in el.keys():
tcv = el.get('timeContainer', '')
if tcv != 'par':
valid = False
validation_results.error(
location='{} element xml:id {}'.format(
el.tag,
el.get(xmlIdAttr, 'omitted')),
message='timeContainer {} prohibited'
.format(tcv),
code=ValidationCode.dapt_timing_timecontainer
)
else:
validation_results.warn(
location='{} element xml:id {}'.format(
el.tag,
el.get(xmlIdAttr, 'omitted')),
message='timeContainer present but should be omitted',
code=ValidationCode.dapt_timing_timecontainer
)
return valid
def _safe_get_timing_attr_seconds(
self,
te: TimeExpressionHandler,
el: Element,
attr_key: str,
default: float | None,
validation_results: ValidationLogger
) -> Tuple[float | None, bool]:
valid = True
rv = default
# print('getting seconds for '+attr_key)
if attr_key in el.keys():
value, in_valid = self._safe_get_timing_seconds_from_str(
te=te,
val=el.get(attr_key, ''),
validation_results=validation_results,
location='{} element xml:id {}, {} attribute'
.format(
el.tag,
el.get(xmlIdAttr, 'omitted'),
attr_key)
)
valid = in_valid
if valid:
rv = value
return rv, valid
def _safe_get_timing_seconds_from_str(
self,
te: TimeExpressionHandler,
val: str,
validation_results: ValidationLogger,
location: str
) -> Tuple[float | None, bool]:
valid = True
rv = None
try:
rv = te.seconds(val)
except ValueError as ve:
valid = False
validation_results.error(
location=location,
message=str(ve),
code=ValidationCode.ttml_timing_attribute_syntax
)
return rv, valid
def _collect_timed_elements(
self,
te: TimeExpressionHandler,
el: Element,
epoch_s: float,
parent_end: float | None,
begin_defined: bool,
end_defined: bool,
time_el_map: dict[float, list[tuple[Element, float]]],
validation_results: ValidationLogger,
# depth: int = 0
) -> tuple[bool, float, float]:
# prefix = ' ' * depth
# print('{}_collect_timed_elements for {}, epoch: {}s, begin_defined: {}'.
# format(prefix, el.tag, epoch_s, begin_defined))
valid = True
for timing_attr in timing_attr_keys:
if timing_attr in el.keys():
time_val = el.get(timing_attr, "")
if not te.isOffsetTime(time_expression=time_val) \
and not te.isNonFrameClockTime(time_expression=time_val):
valid = False
validation_results.error(
location='{} element xml:id {}'.format(
el.tag,
el.get(xmlIdAttr, 'omitted')),
message='{}={} is not a valid time expression'.format(
timing_attr,
el.get(timing_attr)),
code=ValidationCode.dapt_timing_attribute_constraint
)
if not self._frameRateSpecified \
and te.usesFrames(time_expression=time_val):
valid = False
validation_results.error(
location='{} element xml:id {}'.format(
el.tag,
el.get(xmlIdAttr, 'omitted')),
message='{} attribute {} uses frames but '
'frame rate not specified on tt element'
.format(timing_attr, time_val),
code=ValidationCode.dapt_timing_framerate
)
if not self._tickRateSpecified \
and te.usesTicks(time_expression=time_val):
valid = False
validation_results.error(
location='{} element xml:id {}'.format(
el.tag,
el.get(xmlIdAttr, 'omitted')),
message='{} attribute {} uses ticks but '
'tick rate not specified on tt element'
.format(timing_attr, time_val),
code=ValidationCode.dapt_timing_tickrate
)
valid &= self._check_for_timeContainer(
el=el,
validation_results=validation_results)
this_begin, begin_valid = self._safe_get_timing_attr_seconds(
te=te, el=el, attr_key='begin',
default=0,
validation_results=validation_results)
valid &= begin_valid
if 'begin' in el.keys():
begin_defined = True
# print('{}begin is defined by this element'.format(prefix))
this_epoch_s = epoch_s + this_begin
this_end, end_valid = self._safe_get_timing_attr_seconds(
te=te, el=el, attr_key='end',
default=parent_end,
validation_results=validation_results)
valid &= end_valid
if 'end' in el.keys():
end_defined = True
if parent_end is not None and this_end is not None:
this_end = min(parent_end, this_end)
this_dur, dur_valid = self._safe_get_timing_attr_seconds(
te=te, el=el, attr_key='dur',
default=None,
validation_results=validation_results)
valid &= dur_valid
if 'dur' in el.keys() and this_dur is not None:
end_defined = True
dur_end = this_epoch_s + this_dur
if this_end is not None:
this_end = min(this_end, dur_end)
else:
this_end = dur_end
child_begins = []
child_ends = []
timed_elements = ['div', 'p', 'span', 'audio']
for child_el in el:
# br and metadata elements cannot have begin attributes
if get_unqualified_name(child_el.tag) in timed_elements:
(child_valid, child_begin, child_end) = \
self._collect_timed_elements(
te=te,
el=child_el,
epoch_s=this_epoch_s,
parent_end=this_end,
begin_defined=begin_defined,
end_defined=end_defined,
time_el_map=time_el_map,
validation_results=validation_results,
# depth=depth + 1
)
valid &= child_valid
child_begins.append(child_begin)
child_ends.append(child_end)
child_begins.sort()
if not begin_defined and len(child_begins) > 0:
# print(prefix+'setting epoch for {} to {}'.format(el.tag, child_begins[0]))
this_epoch_s = child_begins[0]
# elif begin_defined:
# print(prefix+'for {}, begin is defined, not setting epoch'.format(el.tag))
# else:
# print(prefix+'for {}, begin not defined but no child begins'.format(el.tag))
if not end_defined and len(child_ends) > 0:
this_end = child_ends[-1]
el_list = time_el_map.get(this_epoch_s, [])
el_list.append((el, this_end))
time_el_map[this_epoch_s] = el_list
return (valid, this_epoch_s, this_end)
def _makeTimeExpressionHandler(
self,
tt: Element,
tt_ns: str,
) -> TimeExpressionHandler:
ttp_ns = tt_ns + '#parameter'
preferredFrameRateKey = make_qname(ttp_ns, 'frameRate')
frameRateKey = preferredFrameRateKey \
if preferredFrameRateKey in tt.keys() \
else 'frameRate'
preferredFrameRateMultiplierKey = \
make_qname(ttp_ns, 'frameRateMultiplier')
frameRateMultiplierKey = preferredFrameRateMultiplierKey \
if preferredFrameRateMultiplierKey in tt.keys() \
else 'frameRateMultiplier'
preferredTickRateKey = make_qname(ttp_ns, 'tickRate')
tickRateKey = preferredTickRateKey \
if preferredTickRateKey in tt.keys() \
else 'tickRate'
self._frameRateSpecified = frameRateKey in tt.keys()
self._tickRateSpecified = tickRateKey in tt.keys()
return TimeExpressionHandler(
framerate=tt.get(frameRateKey),
framerate_multiplier=tt.get(frameRateMultiplierKey),
tickrate=tt.get(tickRateKey)
)
def _checkTimedContentOverlapsSegment(
self,
doc_begin: float,
doc_end: float,
validation_results: ValidationLogger) -> bool:
valid = True
if self._segment_dur is not None:
epoch = 0 if self._segment_relative_timing else self._epoch
max_end = epoch + self._segment_dur
if doc_begin > max_end or \
(doc_end is not None and doc_end <= epoch):
valid = False
validation_results.error(
location='Timed content',
message='Document content is timed outside the segment '
'interval [{}s..{}s)'.format(epoch, max_end),
code=ValidationCode.dapt_timing_segment_overlap
)
else:
validation_results.good(
location='Timed content',
message='Document content overlaps the segment '
'interval [{}s..{}s)'.format(epoch, max_end),
code=ValidationCode.dapt_timing_segment_overlap
)
return valid
@classmethod
def _dot_dsop_paths(cls, tt_ns: str) -> tuple[str, str]:
metadata_path = './{}/{}'.format(
make_qname(namespace=tt_ns, name='head'),
make_qname(namespace=tt_ns, name='metadata')
)
dot_path = '{}/{}'.format(
metadata_path,
make_qname(
namespace=ns_daptm,
name='daptOriginTimecode')
)
dsop_path = '{}/{}'.format(
metadata_path,
make_qname(
namespace='urn:ebu:tt:metadata',
name='documentStartOfProgramme'
)
)
return (dot_path, dsop_path)
def _checkOriginAndStartOfProgramme(
self,
tt: Element,
tt_ns: str,
te: TimeExpressionHandler,
validation_results: ValidationLogger) -> bool:
"""
* Time expressions are well formed in
/tt/head/metadata/daptm:daptOriginTimecode and
/tt/head/metadata/ebuttm:documentStartOfProgramme:
* clock-time hh:mm:ss:ff permissible in either
* offset-time a permitted alternative for documentStartOfProgramme
* If both daptOriginTimecode and documentStartOfProgramme are present,
but they are different, warn that the contents may be offset by
delta relative to the related media
"""
valid = True
dot_path, dsop_path = daptTimingCheck._dot_dsop_paths(tt_ns=tt_ns)
dots = tt.findall(dot_path)
dsops = tt.findall(dsop_path)
dot = None
dsop = None
if len(dots) > 1:
valid = False
validation_results.error(
location=dot_path,
message='Expected max 1 daptOriginTimecode, found {}'
.format(len(dots)),
code=ValidationCode.dapt_timing_origin_timecode
)
if len(dsops) > 1:
valid = False
validation_results.error(
location=dsop_path,
message='Expected max 1 documentStartOfProgramme, found {}'
.format(len(dsops)),
code=ValidationCode.dapt_timing_origin_timecode
)
if len(dots) == 1:
dot = dots[0].text if dots[0].text else ''
if not te.isFrameClockTime(time_expression=dot):
valid = False
validation_results.error(
location=dot_path,
message='daptOriginTimecode value "{}" is '
'not a valid format'
.format(dot),
code=ValidationCode.dapt_timing_origin_timecode
)
else:
if not self._frameRateSpecified:
valid = False
validation_results.error(
location=dsop_path,
message='daptOriginTimecode uses frames '
'but frame rate not specified on tt element.',
code=ValidationCode.dapt_timing_framerate
)
if len(dsops) == 1:
dsop = dsops[0].text if dsops[0].text else ''
if not te.isFrameClockTime(time_expression=dsop) \
and not te.isOffsetTime(time_expression=dsop):
valid = False
validation_results.error(
location=dsop_path,
message='documentStartOfProgramme value "{}" is '
'not a valid format'
.format(dsop),
code=ValidationCode.dapt_timing_start_of_programme_timecode
)
else:
if te.usesFrames(time_expression=dsop) \
and not self._frameRateSpecified:
valid = False
validation_results.error(
location=dsop_path,
message='documentStartOfProgramme uses frames '
'but frame rate not specified on tt element.',
code=ValidationCode.dapt_timing_framerate
)
if te.usesTicks(time_expression=dsop) \
and not self._tickRateSpecified:
valid = False
validation_results.error(
location=dsop_path,
message='documentStartOfProgramme uses ticks '
'but tick rate not specified on tt element.',
code=ValidationCode.dapt_timing_tickrate
)
(dot_secs, dot_valid) = self._safe_get_timing_seconds_from_str(
te=te, val=dot, validation_results=validation_results,
location=dot_path) \
if dot \
else (None, True)
(dsop_secs, dsop_valid) = self._safe_get_timing_seconds_from_str(
te=te, val=dsop, validation_results=validation_results,
location=dsop_path) \
if dsop \
else (None, True)
if not dot_valid or not dsop_valid:
valid = False
elif dot_secs is not None and dsop_secs is not None:
delta = dot_secs - dsop_secs
if delta != 0:
validation_results.warn(
location='Timecode-related metadata',
message='Non-zero delta between daptOriginTimecode {} '
'and documentStartOfProgramme {} suggests '
'document times may need to be offset by {:.3f}s'
.format(dot, dsop, delta),
code=ValidationCode.dapt_timing_timecode_offset
)
return valid
[docs]
def run(
self,
input: Element,
context: dict,
validation_results: ValidationLogger) -> bool:
tt_ns = \
context.get('root_ns', ns_ttml)
valid = True
time_expression_handler = self._makeTimeExpressionHandler(
tt=input,
tt_ns=tt_ns
)
valid &= self._checkOriginAndStartOfProgramme(
tt=input,
tt_ns=tt_ns,
te=time_expression_handler,
validation_results=validation_results
)
time_el_map = {}
body_el_key = make_qname(namespace=tt_ns, name='body')
body_el = input.find('./'+body_el_key)
if body_el is None:
body_el = input.find('./{*}body')
if body_el is None:
validation_results.skip(
location='{} element'.format(input.tag),
message='No body element found, skipping timing tests',
code=ValidationCode.ttml_document_timing
)
return valid
try:
(te_valid, doc_begin, doc_end) = self._collect_timed_elements(
te=time_expression_handler,
el=body_el,
epoch_s=0,
parent_end=None,
begin_defined=False,
end_defined=False,
time_el_map=time_el_map,
validation_results=validation_results)
valid &= te_valid
valid &= self._checkTimedContentOverlapsSegment(
doc_begin=doc_begin,
doc_end=doc_end,
validation_results=validation_results
)
validation_results.info(
location='Document',
message='First text appears at {}s, end of doc is {}'.format(
doc_begin,
'undefined' if doc_end is None else '{}s'.format(doc_end)
),
code=ValidationCode.ttml_document_timing
)
except Exception as e:
valid = False
validation_results.error(
location='body element or descendants',
message='Exception encountered while trying to compute times:'
' {}, trace: {}'
.format(
str(e),
''.join(traceback.format_exception(e))),
code=ValidationCode.ttml_document_timing
)
return valid