diff --git a/pycmx/cdl.py b/pycmx/cdl.py new file mode 100644 index 0000000..db03ab2 --- /dev/null +++ b/pycmx/cdl.py @@ -0,0 +1,31 @@ +# pycmx +# (c) 2025 Jamie Hardt + +from typing import Generic, NamedTuple, TypeVar + +T = TypeVar('T') + + +class Rgb(NamedTuple, Generic[T]): + red: T + green: T + blue: T + + +class AscSopComponents(NamedTuple, Generic[T]): + """ + Fields in an ASC SOP (Slope-Offset-Power) color transfer function + statement + """ + slope: Rgb[T] + offset: Rgb[T] + power: Rgb[T] + + +class FramecountTriple(NamedTuple): + """ + Fields in an FRMC statement + """ + start: int + end: int + duration: int diff --git a/pycmx/edit.py b/pycmx/edit.py index e68f411..40df8e4 100644 --- a/pycmx/edit.py +++ b/pycmx/edit.py @@ -1,6 +1,7 @@ # pycmx # (c) 2018-2025 Jamie Hardt +from pycmx.cdl import AscSopComponents, FramecountTriple, Rgb from pycmx.statements import StmtCdlSat, StmtCdlSop, StmtFrmc from .transition import Transition from .channel_map import ChannelMap @@ -19,14 +20,14 @@ class Edit: trans_name_statement=None, asc_sop_statement=None, asc_sat_statement=None, frmc_statement=None): - self.edit_statement = edit_statement - self.audio_ext = audio_ext_statement + self._edit_statement = edit_statement + self._audio_ext = audio_ext_statement self.clip_name_statement = clip_name_statement self.source_file_statement = source_file_statement self.trans_name_statement = trans_name_statement - self.asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement - self.asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement - self.frmc_statement: Optional[StmtFrmc] = frmc_statement + self._asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement + self._asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement + self._frmc_statement: Optional[StmtFrmc] = frmc_statement @property def line_number(self) -> int: @@ -35,7 +36,7 @@ class Edit: this edit. Line numbers a zero-indexed, such that the "TITLE:" record is line zero. """ - return self.edit_statement.line_number + return self._edit_statement.line_number @property def channels(self) -> ChannelMap: @@ -43,9 +44,9 @@ class Edit: Get the :obj:`ChannelMap` object associated with this Edit. """ cm = ChannelMap() - cm._append_event(self.edit_statement.channels) - if self.audio_ext is not None: - cm._append_ext(self.audio_ext) + cm._append_event(self._edit_statement.channels) + if self._audio_ext is not None: + cm._append_ext(self._audio_ext) return cm @property @@ -54,19 +55,19 @@ class Edit: Get the :obj:`Transition` object associated with this edit. """ if self.trans_name_statement: - return Transition(self.edit_statement.trans, - self.edit_statement.trans_op, + return Transition(self._edit_statement.trans, + self._edit_statement.trans_op, self.trans_name_statement.name) else: - return Transition(self.edit_statement.trans, - self.edit_statement.trans_op, None) + return Transition(self._edit_statement.trans, + self._edit_statement.trans_op, None) @property def source_in(self) -> str: """ Get the source in timecode. """ - return self.edit_statement.source_in + return self._edit_statement.source_in @property def source_out(self) -> str: @@ -74,7 +75,7 @@ class Edit: Get the source out timecode. """ - return self.edit_statement.source_out + return self._edit_statement.source_out @property def record_in(self) -> str: @@ -82,7 +83,7 @@ class Edit: Get the record in timecode. """ - return self.edit_statement.record_in + return self._edit_statement.record_in @property def record_out(self) -> str: @@ -90,7 +91,7 @@ class Edit: Get the record out timecode. """ - return self.edit_statement.record_out + return self._edit_statement.record_out @property def source(self) -> str: @@ -98,7 +99,7 @@ class Edit: Get the source column. This is the 8, 32 or 128-character string on the event record line, this usually references the tape name of the source. """ - return self.edit_statement.source + return self._edit_statement.source @property def black(self) -> bool: @@ -138,22 +139,40 @@ class Edit: return self.clip_name_statement.name @property - def asc_sop(self) -> Optional[StmtCdlSop]: + def asc_sop(self) -> Optional[AscSopComponents[str]]: """ Get ASC CDL Slope-Offset-Power transfer function for clip, if present """ - return self.asc_sop_statement + if self._asc_sop_statement is None: + return None + + s = self._asc_sop_statement + + return AscSopComponents( + slope=Rgb(red=s.slope_r, green=s.slope_g, + blue=s.slope_b), + offset=Rgb(red=s.offset_r, green=s.offset_g, + blue=s.offset_g), + power=Rgb(red=s.power_r, green=s.power_g, + blue=s.power_b) + ) @property - def asc_sat(self) -> Optional[StmtCdlSat]: + def asc_sat(self) -> Optional[str]: """ Get ASC CDL saturation value for clip, if present """ - return self.asc_sat_statement + if self._asc_sat_statement is None: + return None + + return self._asc_sat_statement.value @property - def frmc(self) -> Optional[StmtFrmc]: + def frmc(self) -> Optional[FramecountTriple]: """ Get FRMC data """ - return self.frmc_statement + if not self._frmc_statement: + return None + + return FramecountTriple(int()) diff --git a/pycmx/parse_cmx_statements.py b/pycmx/parse_cmx_statements.py index 58d4077..991aa28 100644 --- a/pycmx/parse_cmx_statements.py +++ b/pycmx/parse_cmx_statements.py @@ -141,8 +141,13 @@ def _parse_remark(line, line_number) -> object: return StmtRemark(line, line_number) else: - return StmtFrmc(start=match.group(1), end=match.group(2), - duration=match.group(3), line_number=line_number) + try: + return StmtFrmc(start=int(match.group(1)), + end=int(match.group(2)), + duration=int(match.group(3)), + line_number=line_number) + except ValueError: + return StmtRemark(line, line_number) else: return StmtRemark(text=line, line_number=line_number) diff --git a/pycmx/statements.py b/pycmx/statements.py index 278c93e..5043056 100644 --- a/pycmx/statements.py +++ b/pycmx/statements.py @@ -66,9 +66,9 @@ class StmtCdlSat(NamedTuple): class StmtFrmc(NamedTuple): - start: str - end: str - duration: str + start: int + end: int + duration: int line_number: int diff --git a/tests/test_parse.py b/tests/test_parse.py index 598e0d7..97bf4e0 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -143,36 +143,36 @@ class TestParse(TestCase): edl = pycmx.parse_cmx3600(f) for event in edl.events: if event.number == 1: - sop = event.edits[0].asc_sop_statement + sop = event.edits[0].asc_sop self.assertIsNotNone(sop) assert sop - self.assertEqual(sop.slope_r, "0.9405") - self.assertEqual(sop.offset_g, "-0.0276") + self.assertEqual(sop.slope.red, "0.9405") + self.assertEqual(sop.offset.green, "-0.0276") - sat = event.edits[0].asc_sat_statement + sat = event.edits[0].asc_sat self.assertIsNotNone(sat) assert sat - self.assertEqual(sat.value, '0.9640') + self.assertEqual(sat, '0.9640') def test_frmc(self): with open("tests/edls/cdl_frmc_example01.edl", "r") as f: edl = pycmx.parse_cmx3600(f) for event in edl.events: if event.number == 1: - frmc = event.edits[0].frmc_statement + frmc = event.edits[0]._frmc_statement self.assertIsNotNone(frmc) assert frmc - self.assertEqual(frmc.start, "1001") - self.assertEqual(frmc.end, "1102") - self.assertEqual(frmc.duration, "102") + self.assertEqual(frmc.start, 1001) + self.assertEqual(frmc.end, 1102) + self.assertEqual(frmc.duration, 102) with open("tests/edls/cdl_frmc_example02.edl", "r") as f: edl = pycmx.parse_cmx3600(f) for event in edl.events: if event.number == 6: - frmc = event.edits[0].frmc_statement + frmc = event.edits[0]._frmc_statement self.assertIsNotNone(frmc) assert frmc - self.assertEqual(frmc.start, "1001") - self.assertEqual(frmc.end, "1486") - self.assertEqual(frmc.duration, "486") + self.assertEqual(frmc.start, 1001) + self.assertEqual(frmc.end, 1486) + self.assertEqual(frmc.duration, 486)