Skip to content

Commit 8fe8e71

Browse files
committed
IMSC reader: add support for SMPTE timecode
1 parent dc14e5a commit 8fe8e71

File tree

5 files changed

+237
-93
lines changed

5 files changed

+237
-93
lines changed

src/main/python/ttconv/imsc/attributes.py

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -425,10 +425,22 @@ def set(ttml_element, frame_rate: Fraction):
425425
f"{fps_multiplier.numerator:g} {fps_multiplier.denominator:g}"
426426
)
427427

428+
class TimeBase(Enum):
429+
clock = "clock"
430+
media = "media"
431+
smpte = "smpte"
432+
433+
class DropMode(Enum):
434+
nonDrop = "nonDrop"
435+
dropNTSC = "dropNTSC"
436+
dropPAL = "dropPAL"
437+
428438
@dataclass
429439
class TemporalAttributeParsingContext:
430440
frame_rate: Fraction = Fraction(30, 1)
431441
tick_rate: int = 1
442+
time_base: TimeBase = TimeBase.media
443+
drop_mode: DropMode = DropMode.nonDrop
432444

433445
class TimeExpressionSyntaxEnum(Enum):
434446
"""IMSC time expression configuration values"""
@@ -450,6 +462,81 @@ def to_time_format(context: TemporalAttributeWritingContext, time: Fraction) ->
450462

451463
return f"{SmpteTimeCode.from_seconds(time, context.frame_rate)}"
452464

465+
_CLOCK_TIME_FRACTION_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d(?:\.\d+)?)$")
466+
_CLOCK_TIME_FRAMES_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d):(\d{2,})$")
467+
_OFFSET_FRAME_RE = re.compile(r"^(\d+(?:\.\d+)?)f")
468+
_OFFSET_TICK_RE = re.compile(r"^(\d+(?:\.\d+)?)t$")
469+
_OFFSET_MS_RE = re.compile(r"^(\d+(?:\.\d+)?)ms$")
470+
_OFFSET_S_RE = re.compile(r"^(\d+(?:\.\d+)?)s$")
471+
_OFFSET_H_RE = re.compile(r"^(\d+(?:\.\d+)?)h$")
472+
_OFFSET_M_RE = re.compile(r"^(\d+(?:\.\d+)?)m$")
473+
474+
475+
def parse_time_expression(ctx: TemporalAttributeParsingContext, time_expr: str, strict: bool = True) -> Fraction:
476+
'''Parse a TTML time expression in a fractional number in seconds
477+
'''
478+
479+
m = _OFFSET_FRAME_RE.match(time_expr)
480+
481+
if m and ctx.frame_rate is not None:
482+
return Fraction(m.group(1)) / ctx.frame_rate
483+
484+
m = _OFFSET_TICK_RE.match(time_expr)
485+
486+
if m and ctx.tick_rate is not None:
487+
return Fraction(m.group(1)) / ctx.tick_rate
488+
489+
m = _OFFSET_MS_RE.match(time_expr)
490+
491+
if m:
492+
return Fraction(m.group(1)) / 1000
493+
494+
m = _OFFSET_S_RE.match(time_expr)
495+
496+
if m:
497+
return Fraction(m.group(1))
498+
499+
m = _OFFSET_M_RE.match(time_expr)
500+
501+
if m:
502+
return Fraction(m.group(1)) * 60
503+
504+
m = _OFFSET_H_RE.match(time_expr)
505+
506+
if m:
507+
return Fraction(m.group(1)) * 3600
508+
509+
m = _CLOCK_TIME_FRACTION_RE.match(time_expr)
510+
511+
if m:
512+
return Fraction(m.group(1)) * 3600 + \
513+
Fraction(m.group(2)) * 60 + \
514+
Fraction(m.group(3))
515+
516+
m = _CLOCK_TIME_FRAMES_RE.match(time_expr)
517+
518+
if m and ctx.frame_rate is not None:
519+
frames = int(m.group(4)) if m.group(4) else 0
520+
521+
if frames >= ctx.frame_rate:
522+
if strict:
523+
raise ValueError("Frame count exceeds frame rate")
524+
else:
525+
LOGGER.error("Frame count %s exceeds frame rate %s, rounding to frame rate minus 1", frames, ctx.frame_rate)
526+
frames = round(ctx.frame_rate) - 1
527+
528+
hh = int(m.group(1))
529+
mm = int(m.group(2))
530+
ss = int(m.group(3))
531+
532+
if ctx.time_base is TimeBase.smpte:
533+
tc = SmpteTimeCode(hh, mm, ss, frames, ctx.frame_rate, ctx.drop_mode != DropMode.nonDrop)
534+
return tc.to_temporal_offset()
535+
else:
536+
return Fraction(hh * 3600 + mm * 60 + ss) + Fraction(frames) / ctx.frame_rate
537+
538+
raise ValueError("Syntax error")
539+
453540
class BeginAttribute:
454541
'''begin attribute
455542
'''
@@ -463,7 +550,7 @@ def extract(context: TemporalAttributeParsingContext, xml_element) -> typing.Opt
463550
begin_raw = xml_element.attrib.get(BeginAttribute.qn)
464551

465552
try:
466-
return utils.parse_time_expression(context.tick_rate, context.frame_rate, begin_raw, False) if begin_raw is not None else None
553+
return parse_time_expression(context, begin_raw, False) if begin_raw is not None else None
467554
except ValueError:
468555
LOGGER.error("Bad begin attribute value: %s", begin_raw)
469556
return None
@@ -486,7 +573,7 @@ def extract(context: TemporalAttributeParsingContext, xml_element) -> typing.Opt
486573
end_raw = xml_element.attrib.get(EndAttribute.qn)
487574

488575
try:
489-
return utils.parse_time_expression(context.tick_rate, context.frame_rate, end_raw, False) if end_raw is not None else None
576+
return parse_time_expression(context, end_raw, False) if end_raw is not None else None
490577
except ValueError:
491578
LOGGER.error("Bad end attribute value: %s", end_raw)
492579
return None
@@ -507,7 +594,7 @@ def extract(context: TemporalAttributeParsingContext, xml_element) -> typing.Opt
507594
dur_raw = xml_element.attrib.get(DurAttribute.qn)
508595

509596
try:
510-
return utils.parse_time_expression(context.tick_rate, context.frame_rate, dur_raw, False) if dur_raw is not None else None
597+
return parse_time_expression(context, dur_raw, False) if dur_raw is not None else None
511598
except ValueError:
512599
LOGGER.error("Bad dur attribute value: %s", dur_raw)
513600
return None
@@ -559,3 +646,57 @@ def extract(xml_element) -> typing.List[str]:
559646
raw_value = xml_element.attrib.get(StyleAttribute.qn)
560647

561648
return raw_value.split(" ") if raw_value is not None else []
649+
650+
class TimeBaseAttribute:
651+
'''ttp:timeBase attribute
652+
'''
653+
654+
qn = f"{{{ns.TTP}}}timeBase"
655+
656+
@staticmethod
657+
def extract(ttml_element) -> TimeBase:
658+
659+
cr = ttml_element.attrib.get(TimeBaseAttribute.qn)
660+
661+
if cr is None:
662+
return TimeBase.media
663+
664+
try:
665+
tb = TimeBase[cr]
666+
except KeyError:
667+
LOGGER.error(f"Bad ttp:timeBase value '{cr}', using 'media' instead")
668+
return TimeBase.media
669+
670+
if tb is TimeBase.clock:
671+
raise ValueError("Clock timebase not supported")
672+
673+
return tb
674+
675+
# We do not support writing ttp:timeBase since all model values are always in media timebase
676+
677+
class DropModeAttribute:
678+
'''ttp:dropMode attribute
679+
'''
680+
681+
qn = f"{{{ns.TTP}}}dropMode"
682+
683+
@staticmethod
684+
def extract(ttml_element) -> DropMode:
685+
686+
cr = ttml_element.attrib.get(DropModeAttribute.qn)
687+
688+
if cr is None:
689+
return DropMode.nonDrop
690+
691+
try:
692+
dm = DropMode[cr]
693+
except KeyError:
694+
LOGGER.error(f"Bad ttp:dropMode value '{cr}', using 'nonDrop' instead")
695+
return DropMode.nonDrop
696+
697+
if dm is DropMode.dropPAL:
698+
raise ValueError("PAL drop frame timecode not supported")
699+
700+
return dm
701+
702+
# We do not support writing ttp:dropMode since all model values are always in media timebase

src/main/python/ttconv/imsc/elements.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,9 @@ def from_xml(
155155
imsc_attr.ContentProfilesAttribute.extract(xml_elem)
156156
)
157157

158+
tt_ctx.temporal_context.time_base = imsc_attr.TimeBaseAttribute.extract(xml_elem)
159+
tt_ctx.temporal_context.drop_mode = imsc_attr.DropModeAttribute.extract(xml_elem)
160+
158161
px_resolution = imsc_attr.ExtentAttribute.extract(xml_elem)
159162

160163
if px_resolution is not None:

src/main/python/ttconv/imsc/utils.py

Lines changed: 0 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,12 @@
2828
import logging
2929
import re
3030
import typing
31-
from fractions import Fraction
3231
import ttconv.style_properties as styles
3332

3433
LOGGER = logging.getLogger(__name__)
3534

3635
_LENGTH_RE = re.compile(r"^((?:\+|\-)?\d*(?:\.\d+)?)(px|em|c|%|rh|rw)$")
3736

38-
_CLOCK_TIME_FRACTION_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d(?:\.\d+)?)$")
39-
_CLOCK_TIME_FRAMES_RE = re.compile(r"^(\d{2,}):(\d\d):(\d\d):(\d{2,})$")
40-
_OFFSET_FRAME_RE = re.compile(r"^(\d+(?:\.\d+)?)f")
41-
_OFFSET_TICK_RE = re.compile(r"^(\d+(?:\.\d+)?)t$")
42-
_OFFSET_MS_RE = re.compile(r"^(\d+(?:\.\d+)?)ms$")
43-
_OFFSET_S_RE = re.compile(r"^(\d+(?:\.\d+)?)s$")
44-
_OFFSET_H_RE = re.compile(r"^(\d+(?:\.\d+)?)h$")
45-
_OFFSET_M_RE = re.compile(r"^(\d+(?:\.\d+)?)m$")
46-
47-
4837
def parse_length(attr_value: str) -> typing.Tuple[float, str]:
4938
'''Parses the TTML length in `attr_value` into a (length, units) tuple'''
5039

@@ -106,67 +95,6 @@ def _serialize_one_family(family):
10695

10796
return ", ".join(map(_serialize_one_family, font_family))
10897

109-
110-
def parse_time_expression(tick_rate: typing.Optional[int], frame_rate: typing.Optional[Fraction], time_expr: str, strict: bool = True) -> Fraction:
111-
'''Parse a TTML time expression in a fractional number in seconds
112-
'''
113-
114-
m = _OFFSET_FRAME_RE.match(time_expr)
115-
116-
if m and frame_rate is not None:
117-
return Fraction(m.group(1)) / frame_rate
118-
119-
m = _OFFSET_TICK_RE.match(time_expr)
120-
121-
if m and tick_rate is not None:
122-
return Fraction(m.group(1)) / tick_rate
123-
124-
m = _OFFSET_MS_RE.match(time_expr)
125-
126-
if m:
127-
return Fraction(m.group(1)) / 1000
128-
129-
m = _OFFSET_S_RE.match(time_expr)
130-
131-
if m:
132-
return Fraction(m.group(1))
133-
134-
m = _OFFSET_M_RE.match(time_expr)
135-
136-
if m:
137-
return Fraction(m.group(1)) * 60
138-
139-
m = _OFFSET_H_RE.match(time_expr)
140-
141-
if m:
142-
return Fraction(m.group(1)) * 3600
143-
144-
m = _CLOCK_TIME_FRACTION_RE.match(time_expr)
145-
146-
if m:
147-
return Fraction(m.group(1)) * 3600 + \
148-
Fraction(m.group(2)) * 60 + \
149-
Fraction(m.group(3))
150-
151-
m = _CLOCK_TIME_FRAMES_RE.match(time_expr)
152-
153-
if m and frame_rate is not None:
154-
frames = Fraction(m.group(4)) if m.group(4) else 0
155-
156-
if frames >= frame_rate:
157-
if strict:
158-
raise ValueError("Frame count exceeds frame rate")
159-
else:
160-
LOGGER.error("Frame count %s exceeds frame rate %s, rounding to frame rate minus 1", frames, frame_rate)
161-
frames = round(frame_rate) - 1
162-
163-
return Fraction(m.group(1)) * 3600 + \
164-
Fraction(m.group(2)) * 60 + \
165-
Fraction(m.group(3)) + \
166-
frames / frame_rate
167-
168-
raise ValueError("Syntax error")
169-
17098
def parse_position(attr_value: str) -> typing.Tuple[str, styles.LengthType, str, styles.LengthType]:
17199
'''Parse a TTML \\<position\\> value into offsets from a horizontal and vertical edge
172100
'''

src/test/python/test_imsc_reader.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,13 @@
3232
import os
3333
import logging
3434
from fractions import Fraction
35+
from ttconv.imsc import namespaces
36+
from ttconv.imsc.attributes import DropMode, DropModeAttribute, TimeBase, TimeBaseAttribute, TimeContainerAttribute
3537
import ttconv.model as model
3638
import ttconv.style_properties as styles
3739
import ttconv.imsc.reader as imsc_reader
3840
import ttconv.imsc.style_properties as imsc_styles
41+
from ttconv.time_code import SmpteTimeCode
3942

4043
class IMSCReaderTest(unittest.TestCase):
4144

@@ -280,5 +283,59 @@ def test_content_profiles(self):
280283
doc = imsc_reader.to_model(et.ElementTree(et.fromstring(xml_str)))
281284
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"})
282285

286+
def test_timeBase_parameter(self):
287+
self.assertEqual(TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "media"})), TimeBase.media)
288+
self.assertEqual(TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "smpte"})), TimeBase.smpte)
289+
self.assertEqual(TimeBaseAttribute.extract(et.Element("tt")), TimeBase.media)
290+
291+
with self.assertRaises(ValueError):
292+
TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "clock"}))
293+
294+
with self.assertLogs() as logs:
295+
logging.getLogger().info("*****dummy*****") # dummy log
296+
self.assertEqual(TimeBaseAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}timeBase": "x"})), TimeBase.media)
297+
if len(logs.output) != 2:
298+
self.fail(logs.output)
299+
300+
def test_dropframe_parameter(self):
301+
self.assertEqual(DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "nonDrop"})), DropMode.nonDrop)
302+
self.assertEqual(DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "dropNTSC"})), DropMode.dropNTSC)
303+
self.assertEqual(DropModeAttribute.extract(et.Element("tt")), DropMode.nonDrop)
304+
305+
with self.assertRaises(ValueError):
306+
DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "dropPAL"}))
307+
308+
with self.assertLogs() as logs:
309+
logging.getLogger().info("*****dummy*****") # dummy log
310+
self.assertEqual(DropModeAttribute.extract(et.Element("tt", {f"{{{namespaces.TTP}}}dropMode": "x"})), DropMode.nonDrop)
311+
if len(logs.output) != 2:
312+
self.fail(logs.output)
313+
314+
def test_smpte_tc_nondrop(self):
315+
xml_str = """<?xml version="1.0" encoding="UTF-8"?>
316+
<tt xml:lang="en"
317+
xmlns="http://www.w3.org/ns/ttml"
318+
xmlns:ttp="http://www.w3.org/ns/ttml#parameter"
319+
ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:timeBase="smpte"
320+
>
321+
<body begin="01:02:03:20"/>
322+
</tt>"""
323+
doc = imsc_reader.to_model(et.ElementTree(et.fromstring(xml_str)))
324+
body = doc.get_body()
325+
self.assertEqual(body.get_begin(), (3723 * 30 + 20)/Fraction(30000, 1001))
326+
327+
def test_smpte_tc_drop(self):
328+
xml_str = """<?xml version="1.0" encoding="UTF-8"?>
329+
<tt xml:lang="en"
330+
xmlns="http://www.w3.org/ns/ttml"
331+
xmlns:ttp="http://www.w3.org/ns/ttml#parameter"
332+
ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:timeBase="smpte" ttp:dropMode="dropNTSC"
333+
>
334+
<body begin="01:02:03:20"/>
335+
</tt>"""
336+
doc = imsc_reader.to_model(et.ElementTree(et.fromstring(xml_str)))
337+
body = doc.get_body()
338+
self.assertEqual(body.get_begin(), SmpteTimeCode(1, 2, 3, 20, Fraction(30000, 1001), True).to_temporal_offset())
339+
283340
if __name__ == '__main__':
284341
unittest.main()

0 commit comments

Comments
 (0)