Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,15 @@ 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"`

Logging verbosity

Default: `"INFO"`

### document_lang
#### document_lang

`"document_lang": <RFC 5646 language tag>`

Expand All @@ -112,15 +112,15 @@ Default: `None`

### IMSC Writer configuration (`"imsc_writer"`)

### time_format
#### time_format

`"time_format": "frames" | "clock_time" | "clock_time_with_frames"`

Specifies whether the TTML time expressions are in frames (`f`), `HH:MM:SS.mmm` or `HH:MM:SS:FF`

Default: `"frames"` if `"fps"` is specified, `"clock_time"` otherwise

### fps
#### fps

`"fps": "<num>/<denom>"`

Expand All @@ -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"`

Expand Down
8 changes: 6 additions & 2 deletions doc/imsc_reader.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
147 changes: 144 additions & 3 deletions src/main/python/ttconv/imsc/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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
'''
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions src/main/python/ttconv/imsc/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 0 additions & 72 deletions src/main/python/ttconv/imsc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'''

Expand Down Expand Up @@ -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 \\<position\\> value into offsets from a horizontal and vertical edge
'''
Expand Down
Loading