diff --git a/README.md b/README.md index 1c00977b..62183d5b 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ A progress bar is displayed if `progress_bar` is `true` and `log_level` is `"INF Default: `true` -### log_level +#### log_level `"log_level": "INFO" | "WARN" | "ERROR"` @@ -100,7 +100,7 @@ Logging verbosity Default: `"INFO"` -### document_lang +#### document_lang `"document_lang": ` @@ -112,7 +112,7 @@ Default: `None` ### IMSC Writer configuration (`"imsc_writer"`) -### time_format +#### time_format `"time_format": "frames" | "clock_time" | "clock_time_with_frames"` @@ -120,7 +120,7 @@ Specifies whether the TTML time expressions are in frames (`f`), `HH:MM:SS.mmm` Default: `"frames"` if `"fps"` is specified, `"clock_time"` otherwise -### fps +#### fps `"fps": "/"` @@ -132,7 +132,7 @@ Example: `--config '{"general": {"progress_bar":false, "log_level":"WARN"}, "imsc_writer": {"time_format":"clock_time_with_frames", "fps": "25/1"}}'` -### profile_signaling +#### profile_signaling `"profile_signaling": "none" | "content_profiles"` diff --git a/doc/imsc_reader.md b/doc/imsc_reader.md index c5641594..ae31a23d 100644 --- a/doc/imsc_reader.md +++ b/doc/imsc_reader.md @@ -2,10 +2,14 @@ ## Overview -The IMSC reader (`ttconv/imsc/reader.py`) converts [IMSC 1.1 Text -Profile](https://www.w3.org/TR/ttml-imsc1.1/#text-profile) documents into the [data model](./data-model.md). The objective is to +The IMSC reader (`ttconv/imsc/reader.py`) converts TTML documents into the [data model](./data-model.md). The objective is to preserve rendering fidelity but not necessarily structure, e.g. referential styling is flattened. +## Limitations + +The IMSC reader primarily converting TTML documents that conform to the [IMSC 1.1 Text Profile](https://www.w3.org/TR/ttml-imsc1.1/#text-profile), with a few exceptions: +* `ttp:timeBase="smpte"` is supported with `ttp:dropMode="dropNTSC" | "nonDrop"` (the timecode timestamp are converted to media time in the model) + ## Usage The IMSC reader accepts as input an XML document that conforms to the [ElementTree XML diff --git a/src/main/python/ttconv/imsc/attributes.py b/src/main/python/ttconv/imsc/attributes.py index f8f17d29..a31429e2 100644 --- a/src/main/python/ttconv/imsc/attributes.py +++ b/src/main/python/ttconv/imsc/attributes.py @@ -425,10 +425,22 @@ def set(ttml_element, frame_rate: Fraction): f"{fps_multiplier.numerator:g} {fps_multiplier.denominator:g}" ) +class TimeBase(Enum): + clock = "clock" + media = "media" + smpte = "smpte" + +class DropMode(Enum): + nonDrop = "nonDrop" + dropNTSC = "dropNTSC" + dropPAL = "dropPAL" + @dataclass class TemporalAttributeParsingContext: frame_rate: Fraction = Fraction(30, 1) tick_rate: int = 1 + time_base: TimeBase = TimeBase.media + drop_mode: DropMode = DropMode.nonDrop class TimeExpressionSyntaxEnum(Enum): """IMSC time expression configuration values""" @@ -450,6 +462,81 @@ def to_time_format(context: TemporalAttributeWritingContext, time: Fraction) -> return f"{SmpteTimeCode.from_seconds(time, context.frame_rate)}" +_CLOCK_TIME_FRACTION_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d(?:\.\d+)?)$") +_CLOCK_TIME_FRAMES_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d):(\d{2,})$") +_OFFSET_FRAME_RE = re.compile(r"^(\d+(?:\.\d+)?)f") +_OFFSET_TICK_RE = re.compile(r"^(\d+(?:\.\d+)?)t$") +_OFFSET_MS_RE = re.compile(r"^(\d+(?:\.\d+)?)ms$") +_OFFSET_S_RE = re.compile(r"^(\d+(?:\.\d+)?)s$") +_OFFSET_H_RE = re.compile(r"^(\d+(?:\.\d+)?)h$") +_OFFSET_M_RE = re.compile(r"^(\d+(?:\.\d+)?)m$") + + +def parse_time_expression(ctx: TemporalAttributeParsingContext, time_expr: str, strict: bool = True) -> Fraction: + '''Parse a TTML time expression in a fractional number in seconds + ''' + + m = _OFFSET_FRAME_RE.match(time_expr) + + if m and ctx.frame_rate is not None: + return Fraction(m.group(1)) / ctx.frame_rate + + m = _OFFSET_TICK_RE.match(time_expr) + + if m and ctx.tick_rate is not None: + return Fraction(m.group(1)) / ctx.tick_rate + + m = _OFFSET_MS_RE.match(time_expr) + + if m: + return Fraction(m.group(1)) / 1000 + + m = _OFFSET_S_RE.match(time_expr) + + if m: + return Fraction(m.group(1)) + + m = _OFFSET_M_RE.match(time_expr) + + if m: + return Fraction(m.group(1)) * 60 + + m = _OFFSET_H_RE.match(time_expr) + + if m: + return Fraction(m.group(1)) * 3600 + + m = _CLOCK_TIME_FRACTION_RE.match(time_expr) + + if m: + return Fraction(m.group(1)) * 3600 + \ + Fraction(m.group(2)) * 60 + \ + Fraction(m.group(3)) + + m = _CLOCK_TIME_FRAMES_RE.match(time_expr) + + if m and ctx.frame_rate is not None: + frames = int(m.group(4)) if m.group(4) else 0 + + if frames >= ctx.frame_rate: + if strict: + raise ValueError("Frame count exceeds frame rate") + else: + LOGGER.error("Frame count %s exceeds frame rate %s, rounding to frame rate minus 1", frames, ctx.frame_rate) + frames = round(ctx.frame_rate) - 1 + + hh = int(m.group(1)) + mm = int(m.group(2)) + ss = int(m.group(3)) + + if ctx.time_base is TimeBase.smpte: + tc = SmpteTimeCode(hh, mm, ss, frames, ctx.frame_rate, ctx.drop_mode != DropMode.nonDrop) + return tc.to_temporal_offset() + else: + return Fraction(hh * 3600 + mm * 60 + ss) + Fraction(frames) / ctx.frame_rate + + raise ValueError("Syntax error") + class BeginAttribute: '''begin attribute ''' @@ -463,7 +550,7 @@ def extract(context: TemporalAttributeParsingContext, xml_element) -> typing.Opt begin_raw = xml_element.attrib.get(BeginAttribute.qn) try: - return utils.parse_time_expression(context.tick_rate, context.frame_rate, begin_raw, False) if begin_raw is not None else None + return parse_time_expression(context, begin_raw, False) if begin_raw is not None else None except ValueError: LOGGER.error("Bad begin attribute value: %s", begin_raw) return None @@ -486,7 +573,7 @@ def extract(context: TemporalAttributeParsingContext, xml_element) -> typing.Opt end_raw = xml_element.attrib.get(EndAttribute.qn) try: - return utils.parse_time_expression(context.tick_rate, context.frame_rate, end_raw, False) if end_raw is not None else None + return parse_time_expression(context, end_raw, False) if end_raw is not None else None except ValueError: LOGGER.error("Bad end attribute value: %s", end_raw) return None @@ -507,7 +594,7 @@ def extract(context: TemporalAttributeParsingContext, xml_element) -> typing.Opt dur_raw = xml_element.attrib.get(DurAttribute.qn) try: - return utils.parse_time_expression(context.tick_rate, context.frame_rate, dur_raw, False) if dur_raw is not None else None + return parse_time_expression(context, dur_raw, False) if dur_raw is not None else None except ValueError: LOGGER.error("Bad dur attribute value: %s", dur_raw) return None @@ -559,3 +646,57 @@ def extract(xml_element) -> typing.List[str]: raw_value = xml_element.attrib.get(StyleAttribute.qn) return raw_value.split(" ") if raw_value is not None else [] + +class TimeBaseAttribute: + '''ttp:timeBase attribute + ''' + + qn = f"{{{ns.TTP}}}timeBase" + + @staticmethod + def extract(ttml_element) -> TimeBase: + + cr = ttml_element.attrib.get(TimeBaseAttribute.qn) + + if cr is None: + return TimeBase.media + + try: + tb = TimeBase[cr] + except KeyError: + LOGGER.error(f"Bad ttp:timeBase value '{cr}', using 'media' instead") + return TimeBase.media + + if tb is TimeBase.clock: + raise ValueError("Clock timebase not supported") + + return tb + + # We do not support writing ttp:timeBase since all model values are always in media timebase + +class DropModeAttribute: + '''ttp:dropMode attribute + ''' + + qn = f"{{{ns.TTP}}}dropMode" + + @staticmethod + def extract(ttml_element) -> DropMode: + + cr = ttml_element.attrib.get(DropModeAttribute.qn) + + if cr is None: + return DropMode.nonDrop + + try: + dm = DropMode[cr] + except KeyError: + LOGGER.error(f"Bad ttp:dropMode value '{cr}', using 'nonDrop' instead") + return DropMode.nonDrop + + if dm is DropMode.dropPAL: + raise ValueError("PAL drop frame timecode not supported") + + return dm + + # We do not support writing ttp:dropMode since all model values are always in media timebase \ No newline at end of file diff --git a/src/main/python/ttconv/imsc/elements.py b/src/main/python/ttconv/imsc/elements.py index eb197e4c..acc48207 100644 --- a/src/main/python/ttconv/imsc/elements.py +++ b/src/main/python/ttconv/imsc/elements.py @@ -155,6 +155,9 @@ def from_xml( imsc_attr.ContentProfilesAttribute.extract(xml_elem) ) + tt_ctx.temporal_context.time_base = imsc_attr.TimeBaseAttribute.extract(xml_elem) + tt_ctx.temporal_context.drop_mode = imsc_attr.DropModeAttribute.extract(xml_elem) + px_resolution = imsc_attr.ExtentAttribute.extract(xml_elem) if px_resolution is not None: diff --git a/src/main/python/ttconv/imsc/utils.py b/src/main/python/ttconv/imsc/utils.py index ea37ca62..f02edf8b 100644 --- a/src/main/python/ttconv/imsc/utils.py +++ b/src/main/python/ttconv/imsc/utils.py @@ -28,23 +28,12 @@ import logging import re import typing -from fractions import Fraction import ttconv.style_properties as styles LOGGER = logging.getLogger(__name__) _LENGTH_RE = re.compile(r"^((?:\+|\-)?\d*(?:\.\d+)?)(px|em|c|%|rh|rw)$") -_CLOCK_TIME_FRACTION_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d(?:\.\d+)?)$") -_CLOCK_TIME_FRAMES_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d):(\d{2,})$") -_OFFSET_FRAME_RE = re.compile(r"^(\d+(?:\.\d+)?)f") -_OFFSET_TICK_RE = re.compile(r"^(\d+(?:\.\d+)?)t$") -_OFFSET_MS_RE = re.compile(r"^(\d+(?:\.\d+)?)ms$") -_OFFSET_S_RE = re.compile(r"^(\d+(?:\.\d+)?)s$") -_OFFSET_H_RE = re.compile(r"^(\d+(?:\.\d+)?)h$") -_OFFSET_M_RE = re.compile(r"^(\d+(?:\.\d+)?)m$") - - def parse_length(attr_value: str) -> typing.Tuple[float, str]: '''Parses the TTML length in `attr_value` into a (length, units) tuple''' @@ -106,67 +95,6 @@ def _serialize_one_family(family): return ", ".join(map(_serialize_one_family, font_family)) - -def parse_time_expression(tick_rate: typing.Optional[int], frame_rate: typing.Optional[Fraction], time_expr: str, strict: bool = True) -> Fraction: - '''Parse a TTML time expression in a fractional number in seconds - ''' - - m = _OFFSET_FRAME_RE.match(time_expr) - - if m and frame_rate is not None: - return Fraction(m.group(1)) / frame_rate - - m = _OFFSET_TICK_RE.match(time_expr) - - if m and tick_rate is not None: - return Fraction(m.group(1)) / tick_rate - - m = _OFFSET_MS_RE.match(time_expr) - - if m: - return Fraction(m.group(1)) / 1000 - - m = _OFFSET_S_RE.match(time_expr) - - if m: - return Fraction(m.group(1)) - - m = _OFFSET_M_RE.match(time_expr) - - if m: - return Fraction(m.group(1)) * 60 - - m = _OFFSET_H_RE.match(time_expr) - - if m: - return Fraction(m.group(1)) * 3600 - - m = _CLOCK_TIME_FRACTION_RE.match(time_expr) - - if m: - return Fraction(m.group(1)) * 3600 + \ - Fraction(m.group(2)) * 60 + \ - Fraction(m.group(3)) - - m = _CLOCK_TIME_FRAMES_RE.match(time_expr) - - if m and frame_rate is not None: - frames = Fraction(m.group(4)) if m.group(4) else 0 - - if frames >= frame_rate: - if strict: - raise ValueError("Frame count exceeds frame rate") - else: - LOGGER.error("Frame count %s exceeds frame rate %s, rounding to frame rate minus 1", frames, frame_rate) - frames = round(frame_rate) - 1 - - return Fraction(m.group(1)) * 3600 + \ - Fraction(m.group(2)) * 60 + \ - Fraction(m.group(3)) + \ - frames / frame_rate - - raise ValueError("Syntax error") - def parse_position(attr_value: str) -> typing.Tuple[str, styles.LengthType, str, styles.LengthType]: '''Parse a TTML \\ value into offsets from a horizontal and vertical edge ''' diff --git a/src/test/python/test_imsc_reader.py b/src/test/python/test_imsc_reader.py index dd86f7b6..f84188ca 100644 --- a/src/test/python/test_imsc_reader.py +++ b/src/test/python/test_imsc_reader.py @@ -32,10 +32,13 @@ import os import logging from fractions import Fraction +from ttconv.imsc import namespaces +from ttconv.imsc.attributes import DropMode, DropModeAttribute, TimeBase, TimeBaseAttribute, TimeContainerAttribute import ttconv.model as model import ttconv.style_properties as styles import ttconv.imsc.reader as imsc_reader import ttconv.imsc.style_properties as imsc_styles +from ttconv.time_code import SmpteTimeCode class IMSCReaderTest(unittest.TestCase): @@ -280,5 +283,59 @@ def test_content_profiles(self): doc = imsc_reader.to_model(et.ElementTree(et.fromstring(xml_str))) self.assertSetEqual(doc.get_content_profiles(), {"http://www.w3.org/ns/ttml/profile/imsc1.1/text", "http://www.w3.org/ns/ttml/profile/imsc1/text"}) + def test_timeBase_parameter(self): + self.assertEqual(TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "media"})), TimeBase.media) + self.assertEqual(TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "smpte"})), TimeBase.smpte) + self.assertEqual(TimeBaseAttribute.extract(et.Element("tt")), TimeBase.media) + + with self.assertRaises(ValueError): + TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "clock"})) + + with self.assertLogs() as logs: + logging.getLogger().info("*****dummy*****") # dummy log + self.assertEqual(TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "x"})), TimeBase.media) + if len(logs.output) != 2: + self.fail(logs.output) + + def test_dropframe_parameter(self): + self.assertEqual(DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "nonDrop"})), DropMode.nonDrop) + self.assertEqual(DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "dropNTSC"})), DropMode.dropNTSC) + self.assertEqual(DropModeAttribute.extract(et.Element("tt")), DropMode.nonDrop) + + with self.assertRaises(ValueError): + DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "dropPAL"})) + + with self.assertLogs() as logs: + logging.getLogger().info("*****dummy*****") # dummy log + self.assertEqual(DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "x"})), DropMode.nonDrop) + if len(logs.output) != 2: + self.fail(logs.output) + + def test_smpte_tc_nondrop(self): + xml_str = """ + + + """ + doc = imsc_reader.to_model(et.ElementTree(et.fromstring(xml_str))) + body = doc.get_body() + self.assertEqual(body.get_begin(), (3723 * 30 + 20)/Fraction(30000, 1001)) + + def test_smpte_tc_drop(self): + xml_str = """ + + + """ + doc = imsc_reader.to_model(et.ElementTree(et.fromstring(xml_str))) + body = doc.get_body() + self.assertEqual(body.get_begin(), SmpteTimeCode(1, 2, 3, 20, Fraction(30000, 1001), True).to_temporal_offset()) + if __name__ == '__main__': unittest.main() diff --git a/src/test/python/test_imsc_time_expressions.py b/src/test/python/test_imsc_time_expressions.py index 830ed626..fd7886ad 100644 --- a/src/test/python/test_imsc_time_expressions.py +++ b/src/test/python/test_imsc_time_expressions.py @@ -29,42 +29,57 @@ import unittest from fractions import Fraction -from ttconv.imsc.utils import parse_time_expression + +from ttconv.imsc.attributes import DropMode, TemporalAttributeParsingContext, TimeBase, parse_time_expression +from ttconv.time_code import SmpteTimeCode class IMSCTimeExpressionsTest(unittest.TestCase): tests = [ - ("1.2s", Fraction(24000, 1001), 60, Fraction(12, 10)), - ("1.2m", Fraction(24000, 1001), 60, Fraction(72)), - ("1.2h", Fraction(24000, 1001), 60, Fraction(4320)), - ("24f", Fraction(24000, 1001), 60, Fraction(1001, 1000)), - ("120t", Fraction(24000, 1001), 60, Fraction(2)), - ("01:02:03", Fraction(24000, 1001), 60, Fraction(3723)), - ("01:02:03.235", Fraction(24000, 1001), 60, Fraction(3723235, 1000)), - ("01:02:03.2350", Fraction(24000, 1001), 60, Fraction(3723235, 1000)), - ("01:02:03:20", Fraction(24000, 1001), 60, Fraction(3723) + 20/Fraction(24000, 1001)), - ("100:00:00.1", Fraction(24000, 1001), 60, 360000 + Fraction(1, 10)), - ("100:00:00:10", Fraction(24000, 1001), 60, 360000 + 10/Fraction(24000, 1001)), - ("00:00:15:00", Fraction(24000, 1001), 60, Fraction(15, 1)) + ("1.2s", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(12, 10)), + ("1.2m", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(72)), + ("1.2h", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(4320)), + ("24f", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(1001, 1000)), + ("120t", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(2)), + ("01:02:03", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(3723)), + ("01:02:03.235", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(3723235, 1000)), + ("01:02:03.2350", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(3723235, 1000)), + ("01:02:03:20", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(3723) + 20/Fraction(24000, 1001)), + ("100:00:00.1", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), 360000 + Fraction(1, 10)), + ("100:00:00:10", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), 360000 + 10/Fraction(24000, 1001)), + ("00:00:15:00", TemporalAttributeParsingContext(Fraction(24000, 1001), 60), Fraction(15, 1)), + ( + "01:02:03:20", + TemporalAttributeParsingContext(Fraction(30000, 1001), 60, TimeBase.smpte), + (3723 * 30 + 20)/Fraction(30000, 1001) + ), + ( + "01:02:03:20", + TemporalAttributeParsingContext(Fraction(30000, 1001), 60, TimeBase.smpte, DropMode.dropNTSC), + SmpteTimeCode(1, 2, 3, 20, Fraction(30000, 1001), True).to_temporal_offset() + ), ] def test_timing_expressions(self): for test in self.tests: with self.subTest(test[0]): - c = parse_time_expression(test[2], test[1], test[0]) - self.assertEqual(c, test[3]) + c = parse_time_expression(test[1], test[0]) + self.assertEqual(c, test[2]) self.assertTrue(isinstance(c, Fraction)) def test_bad_frame_count(self): with self.assertRaises(ValueError): - parse_time_expression(None, 24, "100:00:00:100") + parse_time_expression(TemporalAttributeParsingContext(), "100:00:00:100") def test_bad_frame_count_non_strict(self): - self.assertEqual(parse_time_expression(None, 24, "100:00:00:100", False), 360000 + 23/24) + self.assertEqual( + parse_time_expression(TemporalAttributeParsingContext(Fraction(24)), "100:00:00:100", False), + 360000 + Fraction(23, 24) + ) def test_bad_syntax(self): with self.assertRaises(ValueError): - parse_time_expression(60, 24, "100:00:00;01") + parse_time_expression(TemporalAttributeParsingContext(), "100:00:00;01") if __name__ == '__main__':