pycmx 1.3.0__py3-none-any.whl → 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pycmx/__init__.py CHANGED
@@ -11,3 +11,5 @@ from .parse_cmx_events import parse_cmx3600
11
11
  from .transition import Transition
12
12
  from .event import Event
13
13
  from .edit import Edit
14
+
15
+ __all__ = ("parse_cmx3600", "Transition", "Event", "Edit")
pycmx/cdl.py ADDED
@@ -0,0 +1,48 @@
1
+ # pycmx
2
+ # (c) 2025 Jamie Hardt
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Generic, NamedTuple, TypeVar
6
+
7
+ T = TypeVar('T')
8
+
9
+
10
+ @dataclass
11
+ class Rgb(Generic[T]):
12
+ """
13
+ A tuple of three `T`s, where each is the respective red, green and blue
14
+ values of interest.
15
+ """
16
+
17
+ red: T # : Red component
18
+ green: T # : Green component
19
+ blue: T # : Blue component
20
+
21
+
22
+ @dataclass
23
+ class AscSopComponents(Generic[T]):
24
+ """
25
+ Fields in an ASC SOP (Slope-Offset-Power) color transfer function
26
+ statement.
27
+
28
+ The ASC SOP is a transfer function of the form:
29
+
30
+ :math:`y_{color} = (ax_{color} + b)^p`
31
+
32
+ for each color component the source, where the `slope` is `a`, `offset`
33
+ is `b` and `power` is `p`.
34
+ """
35
+
36
+ slope: Rgb[T] # : The linear/slope component `a`
37
+ offset: Rgb[T] # : The constant/offset component `b`
38
+ power: Rgb[T] # : The exponential/power component `p`
39
+
40
+
41
+ class FramecountTriple(NamedTuple):
42
+ """
43
+ Fields in an FRMC statement
44
+ """
45
+
46
+ start: int
47
+ end: int
48
+ duration: int
pycmx/channel_map.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
2
+ # (c) 2018-2025 Jamie Hardt
3
3
 
4
4
  from re import (compile, match)
5
5
  from typing import Dict, Tuple, Generator
pycmx/edit.py CHANGED
@@ -1,9 +1,19 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
3
-
2
+ # (c) 2018-2025 Jamie Hardt
3
+
4
+ from .cdl import AscSopComponents, FramecountTriple
5
+ from .statements import (
6
+ StmtCdlSat,
7
+ StmtCdlSop,
8
+ StmtFrmc,
9
+ StmtEvent,
10
+ StmtAudioExt,
11
+ StmtClipName,
12
+ StmtSourceFile,
13
+ StmtEffectsName,
14
+ )
4
15
  from .transition import Transition
5
16
  from .channel_map import ChannelMap
6
- # from .parse_cmx_statements import StmtEffectsName
7
17
 
8
18
  from typing import Optional
9
19
 
@@ -14,23 +24,36 @@ class Edit:
14
24
  recorder timecode in and out, a transition and channels.
15
25
  """
16
26
 
17
- def __init__(self, edit_statement, audio_ext_statement,
18
- clip_name_statement, source_file_statement,
19
- trans_name_statement=None):
20
- self.edit_statement = edit_statement
21
- self.audio_ext = audio_ext_statement
22
- self.clip_name_statement = clip_name_statement
23
- self.source_file_statement = source_file_statement
24
- self.trans_name_statement = trans_name_statement
27
+ def __init__(
28
+ self,
29
+ edit_statement: StmtEvent,
30
+ audio_ext_statement: Optional[StmtAudioExt],
31
+ clip_name_statement: Optional[StmtClipName],
32
+ source_file_statement: Optional[StmtSourceFile],
33
+ trans_name_statement: Optional[StmtEffectsName] = None,
34
+ asc_sop_statement: Optional[StmtCdlSop] = None,
35
+ asc_sat_statement: Optional[StmtCdlSat] = None,
36
+ frmc_statement: Optional[StmtFrmc] = None,
37
+ ) -> None:
38
+ self._edit_statement: StmtEvent = edit_statement
39
+ self._audio_ext: Optional[StmtAudioExt] = audio_ext_statement
40
+ self._clip_name_statement: Optional[StmtClipName] = clip_name_statement
41
+ self._source_file_statement: Optional[StmtSourceFile] = \
42
+ source_file_statement
43
+ self._trans_name_statement: Optional[StmtEffectsName] = \
44
+ trans_name_statement
45
+ self._asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement
46
+ self._asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement
47
+ self._frmc_statement: Optional[StmtFrmc] = frmc_statement
25
48
 
26
49
  @property
27
50
  def line_number(self) -> int:
28
51
  """
29
52
  Get the line number for the "standard form" statement associated with
30
- this edit. Line numbers a zero-indexed, such that the
31
- "TITLE:" record is line zero.
53
+ this edit. Line numbers a zero-indexed, such that the "TITLE:" record
54
+ is line zero.
32
55
  """
33
- return self.edit_statement.line_number
56
+ return self._edit_statement.line_number
34
57
 
35
58
  @property
36
59
  def channels(self) -> ChannelMap:
@@ -38,30 +61,33 @@ class Edit:
38
61
  Get the :obj:`ChannelMap` object associated with this Edit.
39
62
  """
40
63
  cm = ChannelMap()
41
- cm._append_event(self.edit_statement.channels)
42
- if self.audio_ext is not None:
43
- cm._append_ext(self.audio_ext)
64
+ cm._append_event(self._edit_statement.channels)
65
+ if self._audio_ext is not None:
66
+ cm._append_ext(self._audio_ext)
44
67
  return cm
45
68
 
46
69
  @property
47
70
  def transition(self) -> Transition:
48
71
  """
49
- Get the :obj:`Transition` object associated with this edit.
72
+ Get the :obj:`Transition` that initiates this edit.
50
73
  """
51
- if self.trans_name_statement:
52
- return Transition(self.edit_statement.trans,
53
- self.edit_statement.trans_op,
54
- self.trans_name_statement.name)
74
+ if self._trans_name_statement:
75
+ return Transition(
76
+ self._edit_statement.trans,
77
+ self._edit_statement.trans_op,
78
+ self._trans_name_statement.name,
79
+ )
55
80
  else:
56
- return Transition(self.edit_statement.trans,
57
- self.edit_statement.trans_op, None)
81
+ return Transition(
82
+ self._edit_statement.trans, self._edit_statement.trans_op, None
83
+ )
58
84
 
59
85
  @property
60
86
  def source_in(self) -> str:
61
87
  """
62
88
  Get the source in timecode.
63
89
  """
64
- return self.edit_statement.source_in
90
+ return self._edit_statement.source_in
65
91
 
66
92
  @property
67
93
  def source_out(self) -> str:
@@ -69,7 +95,7 @@ class Edit:
69
95
  Get the source out timecode.
70
96
  """
71
97
 
72
- return self.edit_statement.source_out
98
+ return self._edit_statement.source_out
73
99
 
74
100
  @property
75
101
  def record_in(self) -> str:
@@ -77,7 +103,7 @@ class Edit:
77
103
  Get the record in timecode.
78
104
  """
79
105
 
80
- return self.edit_statement.record_in
106
+ return self._edit_statement.record_in
81
107
 
82
108
  @property
83
109
  def record_out(self) -> str:
@@ -85,7 +111,7 @@ class Edit:
85
111
  Get the record out timecode.
86
112
  """
87
113
 
88
- return self.edit_statement.record_out
114
+ return self._edit_statement.record_out
89
115
 
90
116
  @property
91
117
  def source(self) -> str:
@@ -93,19 +119,21 @@ class Edit:
93
119
  Get the source column. This is the 8, 32 or 128-character string on the
94
120
  event record line, this usually references the tape name of the source.
95
121
  """
96
- return self.edit_statement.source
122
+ return self._edit_statement.source
97
123
 
98
124
  @property
99
125
  def black(self) -> bool:
100
126
  """
101
- Black video or silence should be used as the source for this event.
127
+ The source field for thie edit was "BL". Black video or silence should
128
+ be used as the source for this event.
102
129
  """
103
130
  return self.source == "BL"
104
131
 
105
132
  @property
106
133
  def aux_source(self) -> bool:
107
134
  """
108
- An auxiliary source is the source of this event.
135
+ The source field for this edit was "AX". An auxiliary source is the
136
+ source for this event.
109
137
  """
110
138
  return self.source == "AX"
111
139
 
@@ -115,10 +143,10 @@ class Edit:
115
143
  Get the source file, as attested by a "* SOURCE FILE" remark on the
116
144
  EDL. This will return None if the information is not present.
117
145
  """
118
- if self.source_file_statement is None:
146
+ if self._source_file_statement is None:
119
147
  return None
120
148
  else:
121
- return self.source_file_statement.filename
149
+ return self._source_file_statement.filename
122
150
 
123
151
  @property
124
152
  def clip_name(self) -> Optional[str]:
@@ -127,7 +155,59 @@ class Edit:
127
155
  NAME" remark on the EDL. This will return None if the information is
128
156
  not present.
129
157
  """
130
- if self.clip_name_statement is None:
158
+ if self._clip_name_statement is None:
131
159
  return None
132
160
  else:
133
- return self.clip_name_statement.name
161
+ return self._clip_name_statement.name
162
+
163
+ @property
164
+ def asc_sop(self) -> Optional[AscSopComponents[float]]:
165
+ """
166
+ Get ASC CDL Slope-Offset-Power color transfer function for the edit,
167
+ if present. The ASC SOP is a transfer function of the form:
168
+
169
+ :math:`y = (ax + b)^p`
170
+
171
+ for each color component the source, where the `slope` is `a`, `offset`
172
+ is `b` and `power` is `p`.
173
+ """
174
+ if self._asc_sop_statement is None:
175
+ return None
176
+
177
+ return self._asc_sop_statement.cdl_sop
178
+
179
+ @property
180
+ def asc_sop_raw(self) -> Optional[str]:
181
+ """
182
+ ASC CDL Slope-Offset-Power statement raw line.
183
+ """
184
+ if self._asc_sop_statement is None:
185
+ return None
186
+
187
+ return self._asc_sop_statement.line
188
+
189
+ @property
190
+ def asc_sat(self) -> Optional[float]:
191
+ """
192
+ Get ASC CDL saturation value for clip, if present.
193
+ """
194
+ if self._asc_sat_statement is None:
195
+ return None
196
+
197
+ return self._asc_sat_statement.value
198
+
199
+ @property
200
+ def framecounts(self) -> Optional[FramecountTriple]:
201
+ """
202
+ Get frame count offset data, if it exists. If an FRMC statement exists
203
+ in the EDL for the event it will give an integer frame count for the
204
+ edit's source in and out times.
205
+ """
206
+ if not self._frmc_statement:
207
+ return None
208
+
209
+ return FramecountTriple(
210
+ start=self._frmc_statement.start,
211
+ end=self._frmc_statement.end,
212
+ duration=self._frmc_statement.duration,
213
+ )
pycmx/edit_list.py CHANGED
@@ -1,22 +1,22 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
2
+ # (c) 2018-2025 Jamie Hardt
3
3
 
4
- from .parse_cmx_statements import (
5
- StmtUnrecognized, StmtEvent, StmtSourceUMID)
4
+ from .statements import (StmtCorruptRemark, StmtTitle, StmtEvent,
5
+ StmtUnrecognized, StmtSourceUMID)
6
6
  from .event import Event
7
7
  from .channel_map import ChannelMap
8
8
 
9
- from typing import Generator
9
+ from typing import Any, Generator
10
10
 
11
11
 
12
12
  class EditList:
13
13
  """
14
14
  Represents an entire edit decision list as returned by
15
- :func:`~pycmx.parse_cmx3600()`.
15
+ :func:`~pycmx.parse_cmx_events.parse_cmx3600()`.
16
16
  """
17
17
 
18
- def __init__(self, statements):
19
- self.title_statement = statements[0]
18
+ def __init__(self, statements: list):
19
+ self.title_statement: StmtTitle = statements[0]
20
20
  self.event_statements = statements[1:]
21
21
 
22
22
  @property
@@ -31,11 +31,11 @@ class EditList:
31
31
  (s for s in self.event_statements if type(s) is StmtEvent), None)
32
32
 
33
33
  if first_event:
34
- if first_event.format == 8:
34
+ if first_event.source_field_size == 8:
35
35
  return '3600'
36
- elif first_event.format == 32:
36
+ elif first_event.source_field_size == 32:
37
37
  return 'File32'
38
- elif first_event.format == 128:
38
+ elif first_event.source_field_size == 128:
39
39
  return 'File128'
40
40
  else:
41
41
  return 'unknown'
@@ -63,13 +63,16 @@ class EditList:
63
63
  return self.title_statement.title
64
64
 
65
65
  @property
66
- def unrecognized_statements(self) -> Generator[StmtUnrecognized,
67
- None, None]:
66
+ def unrecognized_statements(self) -> Generator[Any, None, None]:
68
67
  """
69
- A generator for all the unrecognized statements in the list.
68
+ A generator for all the unrecognized statements and
69
+ corrupt remarks in the list.
70
+
71
+ :yields: either a :class:`StmtUnrecognized` or
72
+ :class:`StmtCorruptRemark`
70
73
  """
71
74
  for s in self.event_statements:
72
- if type(s) is StmtUnrecognized:
75
+ if type(s) is StmtUnrecognized or type(s) in StmtCorruptRemark:
73
76
  yield s
74
77
 
75
78
  @property
@@ -90,8 +93,6 @@ class EditList:
90
93
  else:
91
94
  event_statements.append(stmt)
92
95
 
93
- elif type(stmt) is StmtSourceUMID:
94
- break
95
96
  else:
96
97
  event_statements.append(stmt)
97
98
 
pycmx/event.py CHANGED
@@ -1,9 +1,9 @@
1
1
  # pycmx
2
- # (c) 2023 Jamie Hardt
2
+ # (c) 2023-2025 Jamie Hardt
3
3
 
4
- from .parse_cmx_statements import (
5
- StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized,
6
- StmtEffectsName)
4
+ from .statements import (StmtFrmc, StmtEvent, StmtClipName, StmtSourceFile,
5
+ StmtAudioExt, StmtUnrecognized, StmtEffectsName,
6
+ StmtCdlSop, StmtCdlSat)
7
7
  from .edit import Edit
8
8
 
9
9
  from typing import List, Generator, Optional, Tuple, Any
@@ -31,12 +31,27 @@ class Event:
31
31
  will have multiple edits when a dissolve, wipe or key transition needs
32
32
  to be performed.
33
33
  """
34
+
35
+ # FTR this is a totall bonkers way of doing this, I wrote this when
36
+ # I was still learning Python and I'm sure there's easier ways to do
37
+ # it. The job is complicated because multiple edits can occur in one
38
+ # event and then other statements can modify the event in different
39
+ # ways.
40
+
34
41
  edits_audio = list(self._statements_with_audio_ext())
35
42
  clip_names = self._clip_name_statements()
36
43
  source_files = self._source_file_statements()
37
44
 
45
+ # We first get the edit events combined with their extra audio
46
+ # channel statements, if any.
47
+
48
+ # The list the_zip contains one element for each initialization
49
+ # parameter in Edit()
38
50
  the_zip: List[List[Any]] = [edits_audio]
39
51
 
52
+ # If there are two Clip Name statements and two edits, we look for
53
+ # "FROM" and "TO" clip name lines. Otherwise we just look for on
54
+ # each per edit.
40
55
  if len(edits_audio) == 2:
41
56
  start_name: Optional[StmtClipName] = None
42
57
  end_name: Optional[StmtClipName] = None
@@ -54,6 +69,10 @@ class Event:
54
69
  else:
55
70
  the_zip.append([None] * len(edits_audio))
56
71
 
72
+ # if there's one source file statemnent per clip, we allocate them to
73
+ # each edit in order. Otherwise if there's only one, we assign the one
74
+ # to all the edits. If there's no source_file statements, we provide
75
+ # None.
57
76
  if len(edits_audio) == len(source_files):
58
77
  the_zip.append(source_files)
59
78
  elif len(source_files) == 1:
@@ -61,7 +80,7 @@ class Event:
61
80
  else:
62
81
  the_zip.append([None] * len(edits_audio))
63
82
 
64
- # attach trans name to last event
83
+ # attach effects name to last event
65
84
  try:
66
85
  trans_statement = self._trans_name_statements()[0]
67
86
  trans_names: List[Optional[Any]] = [None] * (len(edits_audio) - 1)
@@ -74,7 +93,10 @@ class Event:
74
93
  audio_ext_statement=e1[1],
75
94
  clip_name_statement=n1,
76
95
  source_file_statement=s1,
77
- trans_name_statement=u1)
96
+ trans_name_statement=u1,
97
+ asc_sop_statement=self._asc_sop_statement(),
98
+ asc_sat_statement=self._asc_sat_statement(),
99
+ frmc_statement=self._frmc_statement())
78
100
  for (e1, n1, s1, u1) in zip(*the_zip)]
79
101
 
80
102
  @property
@@ -101,8 +123,24 @@ class Event:
101
123
 
102
124
  def _statements_with_audio_ext(self) -> Generator[
103
125
  Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]:
104
- for (s1, s2) in zip(self.statements, self.statements[1:]):
105
- if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
106
- yield (s1, s2)
107
- elif type(s1) is StmtEvent:
108
- yield (s1, None)
126
+
127
+ if len(self.statements) == 1 and type(self.statements[0]) is StmtEvent:
128
+ yield (self.statements[0], None)
129
+
130
+ else:
131
+ for (s1, s2) in zip(self.statements, self.statements[1:]):
132
+ if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
133
+ yield (s1, s2)
134
+ elif type(s1) is StmtEvent:
135
+ yield (s1, None)
136
+
137
+ def _asc_sop_statement(self) -> Optional[StmtCdlSop]:
138
+ return next((s for s in self.statements if type(s) is StmtCdlSop),
139
+ None)
140
+
141
+ def _asc_sat_statement(self) -> Optional[StmtCdlSat]:
142
+ return next((s for s in self.statements if type(s) is StmtCdlSat),
143
+ None)
144
+
145
+ def _frmc_statement(self) -> Optional[StmtFrmc]:
146
+ return next((s for s in self.statements if type(s) is StmtFrmc), None)
pycmx/parse_cmx_events.py CHANGED
@@ -1,20 +1,20 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
2
+ # (c) 2018-2025 Jamie Hardt
3
3
 
4
- # from collections import namedtuple
4
+ from typing import TextIO
5
5
 
6
6
  from .parse_cmx_statements import (parse_cmx3600_statements)
7
7
  from .edit_list import EditList
8
8
 
9
- from typing import TextIO
10
-
11
9
 
12
- def parse_cmx3600(f: TextIO) -> EditList:
10
+ def parse_cmx3600(f: TextIO, tolerant: bool = False) -> EditList:
13
11
  """
14
12
  Parse a CMX 3600 EDL.
15
13
 
16
14
  :param TextIO f: a file-like object, an opened CMX 3600 .EDL file.
15
+ :param bool tolerant: If `True`, a relaxed event line parsing method will
16
+ be used, in the case the default method fails.
17
17
  :returns: An :class:`pycmx.edit_list.EditList`.
18
18
  """
19
- statements = parse_cmx3600_statements(f)
19
+ statements = parse_cmx3600_statements(f, tolerant)
20
20
  return EditList(statements)
@@ -1,37 +1,25 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
2
+ # (c) 2018-2025 Jamie Hardt
3
3
 
4
4
  import re
5
- from collections import namedtuple
6
5
  from typing import TextIO, List
7
6
 
7
+ from .cdl import AscSopComponents, Rgb
8
8
 
9
+ from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
10
+ StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
11
+ StmtAudioExt, StmtClipName, StmtEffectsName,
12
+ StmtEvent, StmtSourceFile, StmtSplitEdit)
9
13
  from .util import collimate
10
14
 
11
- StmtTitle = namedtuple("Title", ["title", "line_number"])
12
- StmtFCM = namedtuple("FCM", ["drop", "line_number"])
13
- StmtEvent = namedtuple("Event", ["event", "source", "channels", "trans",
14
- "trans_op", "source_in", "source_out",
15
- "record_in", "record_out", "format",
16
- "line_number"])
17
- StmtAudioExt = namedtuple("AudioExt", ["audio3", "audio4", "line_number"])
18
- StmtClipName = namedtuple("ClipName", ["name", "affect", "line_number"])
19
- StmtSourceFile = namedtuple("SourceFile", ["filename", "line_number"])
20
- StmtRemark = namedtuple("Remark", ["text", "line_number"])
21
- StmtEffectsName = namedtuple("EffectsName", ["name", "line_number"])
22
- StmtSourceUMID = namedtuple("Source", ["name", "umid", "line_number"])
23
- StmtSplitEdit = namedtuple("SplitEdit", ["video", "magnitude", "line_number"])
24
- StmtMotionMemory = namedtuple(
25
- "MotionMemory", ["source", "fps"]) # FIXME needs more fields
26
- StmtUnrecognized = namedtuple("Unrecognized", ["content", "line_number"])
27
-
28
-
29
- def parse_cmx3600_statements(file: TextIO) -> List[object]:
15
+
16
+ def parse_cmx3600_statements(file: TextIO,
17
+ tolerant: bool = False) -> List[object]:
30
18
  """
31
19
  Return a list of every statement in the file argument.
32
20
  """
33
21
  lines = file.readlines()
34
- return [_parse_cmx3600_line(line.strip(), line_number)
22
+ return [_parse_cmx3600_line(line.strip(), line_number, tolerant)
35
23
  for (line_number, line) in enumerate(lines)]
36
24
 
37
25
 
@@ -51,48 +39,51 @@ def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
51
39
  # 8,8,1,4,2,1,4,13,3,1,1]
52
40
 
53
41
 
54
- def _parse_cmx3600_line(line: str, line_number: int) -> object:
42
+ def _parse_cmx3600_line(line: str, line_number: int,
43
+ tolerant: bool = False) -> object:
55
44
  """
56
45
  Parses a single CMX EDL line.
57
46
 
58
47
  :param line: A single EDL line.
59
48
  :param line_number: The index of this line in the file.
60
49
  """
61
- long_event_num_p = re.compile("^[0-9]{6} ")
62
- short_event_num_p = re.compile("^[0-9]{3} ")
63
- x_event_form_p = re.compile("^([0-9]{4,5}) ")
50
+ event_num_p = re.compile(r"^(\d+) ")
51
+ line_matcher = event_num_p.match(line)
64
52
 
65
53
  if line.startswith("TITLE:"):
66
54
  return _parse_title(line, line_number)
67
- elif line.startswith("FCM:"):
55
+ if line.startswith("FCM:"):
68
56
  return _parse_fcm(line, line_number)
69
- elif long_event_num_p.match(line) is not None:
70
- length_file_128 = sum(_edl_column_widths(6, 128))
71
- if len(line) < length_file_128:
72
- return _parse_long_standard_form(line, 32, line_number)
73
- else:
74
- return _parse_long_standard_form(line, 128, line_number)
75
- elif (m := x_event_form_p.match(line)) is not None:
76
- assert m is not None
77
- event_field_length = len(m[1])
78
- return _parse_columns_for_standard_form(line, event_field_length,
79
- 8, line_number)
80
- elif short_event_num_p.match(line) is not None:
81
- return _parse_standard_form(line, line_number)
82
- elif line.startswith("AUD"):
57
+ if line_matcher is not None:
58
+ event_field_len = len(line_matcher.group(1))
59
+
60
+ source_field_len = len(line) - (event_field_len + 65)
61
+
62
+ try:
63
+ return _parse_columns_for_standard_form(
64
+ line, event_field_len, source_field_len, line_number)
65
+
66
+ except EventFormError:
67
+ if tolerant:
68
+ return _parse_columns_tolerant(line, line_number)
69
+ else:
70
+ return StmtUnrecognized(line, line_number)
71
+
72
+ if line.startswith("AUD"):
83
73
  return _parse_extended_audio_channels(line, line_number)
84
- elif line.startswith("*"):
74
+ if line.startswith("*"):
85
75
  return _parse_remark(line[1:].strip(), line_number)
86
- elif line.startswith(">>> SOURCE"):
76
+ if line.startswith(">>> SOURCE"):
87
77
  return _parse_source_umid_statement(line, line_number)
88
- elif line.startswith("EFFECTS NAME IS"):
78
+ if line.startswith("EFFECTS NAME IS"):
89
79
  return _parse_effects_name(line, line_number)
90
- elif line.startswith("SPLIT:"):
80
+ if line.startswith("SPLIT:"):
91
81
  return _parse_split(line, line_number)
92
- elif line.startswith("M2"):
93
- return _parse_motion_memory(line, line_number)
94
- else:
95
- return _parse_unrecognized(line, line_number)
82
+ if line.startswith("M2"):
83
+ pass
84
+ # return _parse_motion_memory(line, line_number)
85
+
86
+ return _parse_unrecognized(line, line_number)
96
87
 
97
88
 
98
89
  def _parse_title(line, line_num) -> StmtTitle:
@@ -104,29 +95,19 @@ def _parse_fcm(line, line_num) -> StmtFCM:
104
95
  val = line[4:].strip()
105
96
  if val == "DROP FRAME":
106
97
  return StmtFCM(drop=True, line_number=line_num)
107
- else:
108
- return StmtFCM(drop=False, line_number=line_num)
109
-
110
-
111
- def _parse_long_standard_form(line, source_field_length, line_number):
112
- return _parse_columns_for_standard_form(line, 6, source_field_length,
113
- line_number)
114
-
115
98
 
116
- def _parse_standard_form(line, line_number):
117
- return _parse_columns_for_standard_form(line, 3, 8, line_number)
99
+ return StmtFCM(drop=False, line_number=line_num)
118
100
 
119
101
 
120
102
  def _parse_extended_audio_channels(line, line_number):
121
103
  content = line.strip()
122
- if content == "AUD 3":
123
- return StmtAudioExt(audio3=True, audio4=False, line_number=line_number)
124
- elif content == "AUD 4":
125
- return StmtAudioExt(audio3=False, audio4=True, line_number=line_number)
126
- elif content == "AUD 3 4":
127
- return StmtAudioExt(audio3=True, audio4=True, line_number=line_number)
104
+ audio3 = "3" in content
105
+ audio4 = "4" in content
106
+
107
+ if audio3 or audio4:
108
+ return StmtAudioExt(audio3, audio4, line_number)
128
109
  else:
129
- return StmtUnrecognized(content=line, line_number=line_number)
110
+ return StmtUnrecognized(line, line_number)
130
111
 
131
112
 
132
113
  def _parse_remark(line, line_number) -> object:
@@ -139,6 +120,66 @@ def _parse_remark(line, line_number) -> object:
139
120
  elif line.startswith("SOURCE FILE:"):
140
121
  return StmtSourceFile(filename=line[12:].strip(),
141
122
  line_number=line_number)
123
+ elif line.startswith("ASC_SOP"):
124
+ group_patterns: list[str] = re.findall(r'\((.*?)\)', line)
125
+
126
+ v1: list[list[tuple[str, str]]] = \
127
+ [re.findall(r'(-?\d+(\.\d+)?)', a) for a in group_patterns]
128
+
129
+ v: list[list[str]] = [[a[0] for a in b] for b in v1]
130
+
131
+ if len(v) != 3 or any([len(a) != 3 for a in v]):
132
+ return StmtRemark(line, line_number)
133
+
134
+ else:
135
+ try:
136
+ return StmtCdlSop(line=line,
137
+ cdl_sop=AscSopComponents(
138
+ slope=Rgb(red=float(v[0][0]),
139
+ green=float(v[0][1]),
140
+ blue=float(v[0][2])),
141
+ offset=Rgb(red=float(v[1][0]),
142
+ green=float(v[1][1]),
143
+ blue=float(v[1][2])),
144
+ power=Rgb(red=float(v[2][0]),
145
+ green=float(v[2][1]),
146
+ blue=float(v[2][2]))
147
+ ),
148
+ line_number=line_number)
149
+
150
+ except ValueError as e:
151
+ return StmtCorruptRemark('ASC_SOP', e, line_number)
152
+
153
+ elif line.startswith("ASC_SAT"):
154
+ value = re.findall(r'(-?\d+(\.\d+)?)', line)
155
+
156
+ if len(value) != 1:
157
+ return StmtRemark(line, line_number)
158
+
159
+ else:
160
+ try:
161
+ return StmtCdlSat(value=float(value[0][0]),
162
+ line_number=line_number)
163
+
164
+ except ValueError as e:
165
+ return StmtCorruptRemark('ASC_SAT', e, line_number)
166
+
167
+ elif line.startswith("FRMC"):
168
+ match = re.match(r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)'
169
+ r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
170
+
171
+ if match is None:
172
+ return StmtCorruptRemark('FRMC', None, line_number)
173
+
174
+ else:
175
+ try:
176
+ return StmtFrmc(start=int(match.group(1)),
177
+ end=int(match.group(2)),
178
+ duration=int(match.group(3)),
179
+ line_number=line_number)
180
+ except ValueError as e:
181
+ return StmtCorruptRemark('FRMC', e, line_number)
182
+
142
183
  else:
143
184
  return StmtRemark(text=line, line_number=line_number)
144
185
 
@@ -148,46 +189,78 @@ def _parse_effects_name(line, line_number) -> StmtEffectsName:
148
189
  return StmtEffectsName(name=name, line_number=line_number)
149
190
 
150
191
 
151
- def _parse_split(line, line_number):
192
+ def _parse_split(line: str, line_number):
152
193
  split_type = line[10:21]
153
- is_video = False
154
- if split_type.startswith("VIDEO"):
155
- is_video = True
194
+ is_video = split_type.startswith("VIDEO")
156
195
 
157
- split_mag = line[24:35]
158
- return StmtSplitEdit(video=is_video, magnitude=split_mag,
196
+ split_delay = line[24:35]
197
+ return StmtSplitEdit(video=is_video, delay=split_delay,
159
198
  line_number=line_number)
160
199
 
161
200
 
162
- def _parse_motion_memory(line, line_number):
163
- return StmtMotionMemory(source="", fps="")
201
+ # def _parse_motion_memory(line, line_number):
202
+ # return StmtMotionMemory(source="", fps="")
203
+ #
204
+
205
+ class EventFormError(RuntimeError):
206
+ pass
164
207
 
165
208
 
166
209
  def _parse_unrecognized(line, line_number):
167
210
  return StmtUnrecognized(content=line, line_number=line_number)
168
211
 
169
212
 
170
- def _parse_columns_for_standard_form(line, event_field_length,
171
- source_field_length, line_number):
213
+ def _parse_columns_for_standard_form(line: str, event_field_length: int,
214
+ source_field_length: int,
215
+ line_number: int):
216
+ # breakpoint()
172
217
  col_widths = _edl_column_widths(event_field_length, source_field_length)
173
218
 
174
219
  if sum(col_widths) > len(line):
175
- return StmtUnrecognized(content=line, line_number=line_number)
220
+ raise EventFormError()
176
221
 
177
222
  column_strings = collimate(line, col_widths)
178
223
 
224
+ channels = column_strings[4].strip()
225
+ trans = column_strings[6].strip()
226
+
227
+ if len(channels) == 0 or len(trans) == 0:
228
+ raise EventFormError()
229
+
179
230
  return StmtEvent(event=column_strings[0],
180
231
  source=column_strings[2].strip(),
181
- channels=column_strings[4].strip(),
182
- trans=column_strings[6].strip(),
232
+ channels=channels,
233
+ trans=trans,
183
234
  trans_op=column_strings[8].strip(),
184
235
  source_in=column_strings[10].strip(),
185
236
  source_out=column_strings[12].strip(),
186
237
  record_in=column_strings[14].strip(),
187
238
  record_out=column_strings[16].strip(),
188
- line_number=line_number, format=source_field_length)
239
+ line_number=line_number,
240
+ source_field_size=source_field_length)
241
+
242
+
243
+ def _parse_columns_tolerant(line: str, line_number: int):
244
+ pattern = re.compile(r'^\s*(\d+)\s+(.{8,128}?)\s+'
245
+ r'(V|A|A2|AA|NONE|AA/V|A2/V|B)\s+'
246
+ r'(C|D|W|KB|K|KO)\s+(\d*)\s+(\d\d.\d\d.\d\d.\d\d)\s'
247
+ r'(\d\d.\d\d.\d\d.\d\d)\s(\d\d.\d\d.\d\d.\d\d)\s'
248
+ r'(\d\d.\d\d.\d\d.\d\d)'
249
+ )
250
+
251
+ match = pattern.match(line)
252
+ if match:
253
+ return StmtEvent(event=int(match.group(1)), source=match.group(2),
254
+ channels=match.group(3), trans=match.group(4),
255
+ trans_op=match.group(5), source_in=match.group(6),
256
+ source_out=match.group(7), record_in=match.group(8),
257
+ record_out=match.group(9), line_number=line_number,
258
+ source_field_size=len(match.group(2)))
259
+ else:
260
+ return StmtUnrecognized(line, line_number)
189
261
 
190
262
 
191
263
  def _parse_source_umid_statement(line, line_number):
192
264
  # trimmed = line[3:].strip()
193
- return StmtSourceUMID(name=None, umid=None, line_number=line_number)
265
+ # return StmtSourceUMID(name=None, umid=None, line_number=line_number)
266
+ ...
pycmx/statements.py ADDED
@@ -0,0 +1,102 @@
1
+ # pycmx
2
+ # (c) 2025 Jamie Hardt
3
+
4
+ from typing import Any, NamedTuple
5
+
6
+ from .cdl import AscSopComponents
7
+
8
+
9
+ class StmtTitle(NamedTuple):
10
+ title: str
11
+ line_number: int
12
+
13
+
14
+ class StmtFCM(NamedTuple):
15
+ drop: bool
16
+ line_number: int
17
+
18
+
19
+ class StmtEvent(NamedTuple):
20
+ event: int
21
+ source: str
22
+ channels: str
23
+ trans: str
24
+ trans_op: str
25
+ source_in: str
26
+ source_out: str
27
+ record_in: str
28
+ record_out: str
29
+ source_field_size: int
30
+ line_number: int
31
+
32
+
33
+ class StmtAudioExt(NamedTuple):
34
+ audio3: bool
35
+ audio4: bool
36
+ line_number: int
37
+
38
+
39
+ class StmtClipName(NamedTuple):
40
+ name: str
41
+ affect: str
42
+ line_number: int
43
+
44
+
45
+ class StmtSourceFile(NamedTuple):
46
+ filename: str
47
+ line_number: int
48
+
49
+
50
+ class StmtCdlSop(NamedTuple):
51
+ line: str
52
+ cdl_sop: AscSopComponents[float]
53
+ line_number: int
54
+
55
+
56
+ class StmtCdlSat(NamedTuple):
57
+ value: float
58
+ line_number: int
59
+
60
+
61
+ class StmtFrmc(NamedTuple):
62
+ start: int
63
+ end: int
64
+ duration: int
65
+ line_number: int
66
+
67
+
68
+ class StmtRemark(NamedTuple):
69
+ text: str
70
+ line_number: int
71
+
72
+
73
+ class StmtEffectsName(NamedTuple):
74
+ name: str
75
+ line_number: int
76
+
77
+
78
+ class StmtSourceUMID(NamedTuple):
79
+ name: str
80
+ umid: str
81
+ line_number: int
82
+
83
+
84
+ class StmtSplitEdit(NamedTuple):
85
+ video: bool
86
+ delay: str
87
+ line_number: int
88
+
89
+
90
+ class StmtUnrecognized(NamedTuple):
91
+ content: str
92
+ line_number: int
93
+
94
+
95
+ class StmtCorruptRemark(NamedTuple):
96
+ selector: str
97
+ exception: Any
98
+ line_number: int
99
+
100
+
101
+ # StmtMotionMemory = namedtuple(
102
+ # "MotionMemory", ["source", "fps"]) # FIXME needs more fields
pycmx/transition.py CHANGED
@@ -24,7 +24,7 @@ class Transition:
24
24
  @property
25
25
  def kind(self) -> Optional[str]:
26
26
  """
27
- Return the kind of transition: Cut, Wipe, etc
27
+ Return the kind of transition: Cut, Wipe, etc.
28
28
  """
29
29
  if self.cut:
30
30
  return Transition.Cut
@@ -56,7 +56,8 @@ class Transition:
56
56
 
57
57
  @property
58
58
  def effect_duration(self) -> int:
59
- """The duration of this transition, in frames of the record target.
59
+ """
60
+ The duration of this transition, in frames of the record target.
60
61
 
61
62
  In the event of a key event, this is the duration of the fade in.
62
63
  """
pycmx/util.py CHANGED
@@ -1,5 +1,5 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
2
+ # (c) 2018-2025 Jamie Hardt
3
3
 
4
4
  # Utility functions
5
5
 
@@ -1,31 +1,34 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: pycmx
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: Python CMX 3600 Edit Decision List Parser
5
- Home-page: https://github.com/iluvcapra/pycmx
6
- License: MIT
7
5
  Keywords: parser,film,broadcast
8
6
  Author: Jamie Hardt
9
- Author-email: jamiehardt@me.com
10
- Requires-Python: >=3.8,<4.0
7
+ Author-email: Jamie Hardt <jamiehardt@me.com>
8
+ License-File: LICENSE
11
9
  Classifier: Development Status :: 5 - Production/Stable
12
10
  Classifier: License :: OSI Approved :: MIT License
13
- Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia
12
+ Classifier: Topic :: Multimedia :: Video
13
+ Classifier: Topic :: Text Processing
14
14
  Classifier: Programming Language :: Python :: 3.8
15
15
  Classifier: Programming Language :: Python :: 3.9
16
16
  Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
- Classifier: Topic :: Multimedia
21
- Classifier: Topic :: Multimedia :: Video
22
- Classifier: Topic :: Text Processing
23
- Provides-Extra: doc
24
- Requires-Dist: sphinx (>=5.3.0) ; extra == "doc"
25
- Requires-Dist: sphinx_rtd_theme (>=1.1.1) ; extra == "doc"
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Requires-Dist: pytest ; extra == 'dev'
22
+ Requires-Dist: ruff>=0.14.10 ; extra == 'dev'
23
+ Requires-Dist: sphinx>=5.3.0 ; extra == 'doc'
24
+ Requires-Dist: sphinx-rtd-theme>=1.1.1 ; extra == 'doc'
25
+ Requires-Python: >3.8
26
+ Project-URL: Homepage, https://github.com/iluvcapra/pycmx
26
27
  Project-URL: Documentation, https://pycmx.readthedocs.io/
27
28
  Project-URL: Repository, https://github.com/iluvcapra/pycmx.git
28
29
  Project-URL: Tracker, https://github.com/iluvcapra/pycmx/issues
30
+ Provides-Extra: dev
31
+ Provides-Extra: doc
29
32
  Description-Content-Type: text/markdown
30
33
 
31
34
  [![Documentation Status](https://readthedocs.org/projects/pycmx/badge/?version=latest)](https://pycmx.readthedocs.io/en/latest/?badge=latest) ![](https://img.shields.io/github/license/iluvcapra/pycmx.svg) ![](https://img.shields.io/pypi/pyversions/pycmx.svg) [![](https://img.shields.io/pypi/v/pycmx.svg)](https://pypi.org/project/pycmx/) ![](https://img.shields.io/pypi/wheel/pycmx.svg)
@@ -35,17 +38,22 @@ Description-Content-Type: text/markdown
35
38
 
36
39
  # pycmx
37
40
 
38
- The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and
39
- its most most common variations.
41
+ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
40
42
 
41
43
  ## Features
42
44
 
43
- * The major variations of the CMX 3600: the standard, "File32", "File128" and
45
+ * The major variations of the CMX 3600: the standard, "File32", "File128" and
44
46
  long Adobe Premiere event numbers are automatically detected and properly
45
- read.
47
+ read. Event number field and source name field sizes are determined
48
+ dynamically for each statement for a high level of compliance at the expense
49
+ of strictness.
50
+ * A more relaxed "tolerant" mode allows parsing of an EDL file where columns
51
+ use non-standard widths.
46
52
  * Preserves relationship between events and individual edits/clips.
47
53
  * Remark or comment fields with common recognized forms are read and
48
54
  available to the client, including clip name and source file data.
55
+ * [ASC CDL][asc] and FRMC/VFX framecount statements are parsed and
56
+ decoded.
49
57
  * Symbolically decodes transitions and audio channels.
50
58
  * Does not parse or validate timecodes, does not enforce framerates, does not
51
59
  parameterize timecode or framerates in any way. This makes the parser more
@@ -55,12 +63,14 @@ its most most common variations.
55
63
  list and give the client the ability to extend the package with their own
56
64
  parsing code.
57
65
 
66
+ [asc]: https://en.wikipedia.org/wiki/ASC_CDL
67
+
58
68
  ## Usage
59
69
 
60
70
  ### Opening and Parsing EDL Files
61
71
  ```
62
72
  >>> import pycmx
63
- >>> with open("tests/edls/TEST.edl") as f
73
+ >>> with open("tests/edls/TEST.edl") as f:
64
74
  ... edl = pycmx.parse_cmx3600(f)
65
75
  ...
66
76
  >>> edl.title
@@ -115,4 +125,3 @@ Audio channel 7 is present
115
125
  >>> events[2].edits[0].channels.video
116
126
  False
117
127
  ```
118
-
@@ -0,0 +1,15 @@
1
+ pycmx/__init__.py,sha256=u1bKgizXyjI-Hz1YC-hPki_pBVKY-g4zsUajwLitO9s,425
2
+ pycmx/cdl.py,sha256=zONbFbvyETNs4u4mi7V-vePhO5VmQ_UE7wthbX0kigo,1041
3
+ pycmx/channel_map.py,sha256=_PIzTudMebzUfMWKNTA3i1egvmp4EsXqLPamCzjIUjk,3174
4
+ pycmx/edit.py,sha256=IPq5piUcWkovw5UfhMQnsHofQI7FNBXfuS_3JjFzClg,6392
5
+ pycmx/edit_list.py,sha256=eZVUEHrKghxL772QSm_ZfhQSjMtodWmlX5lyjTZn1PM,3309
6
+ pycmx/event.py,sha256=CYzenUjH_ONh_MTyL9AY6TN2Gmoe-0C0VC68ptNAkCU,5646
7
+ pycmx/parse_cmx_events.py,sha256=Z6Zjp8WZwKgqk7ntYXmzQ92D6vLIEIGxczqdvdK8JnI,611
8
+ pycmx/parse_cmx_statements.py,sha256=UVxOtBZ-WSSMjncpzYJPNJLNcz-zFSh-mg6kauSg0Q4,9402
9
+ pycmx/statements.py,sha256=55Ic5t1gxTVu6fK1SNax-dHsa4s76hkh5OnwJ4enEf0,1580
10
+ pycmx/transition.py,sha256=c4eetD2C7Fpi35Zi_ElQed72kCZ82QcBAwB0ubr-QRA,2374
11
+ pycmx/util.py,sha256=K1FVj2qptBtX-5RgBivc0RZl3JGdxAQ8x8WNhlBhY2Q,787
12
+ pycmx-1.5.0.dist-info/licenses/LICENSE,sha256=JS087ZFloGaV7LHygeUaXmT-fGLx0VF30W62wDTLRCc,1056
13
+ pycmx-1.5.0.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
14
+ pycmx-1.5.0.dist-info/METADATA,sha256=k0iRJdNHzdmNcFDFj2zCdxFRTmaMBw8LdnrNK65T-8s,4510
15
+ pycmx-1.5.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.0.0
2
+ Generator: uv 0.9.29
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,13 +0,0 @@
1
- pycmx/__init__.py,sha256=u_VU3f3WltGoZzg_iZBLQZUtFqGFVZgBX3trqM7v3D4,365
2
- pycmx/channel_map.py,sha256=vkGonW2CY5F_TCbcJm9jFp6O7JsNUW4hew5q8KiVWLo,3169
3
- pycmx/edit.py,sha256=6AfQAHh8f4aRFs211d2-Jmkv-a8CmoIduOFL3N2lrQg,3893
4
- pycmx/edit_list.py,sha256=hjOsCxrGwQemjpmUGdfnJa0_rSYs-LSYkfOrjlqU-gw,3170
5
- pycmx/event.py,sha256=6fwbCjo1mX8Zkmbd6Maw4-msWm2zYCUGRIGSeqfFWdw,3809
6
- pycmx/parse_cmx_events.py,sha256=9d0jCvOXCt1RPSEBLZhLScSsGq3oDVa2bBDar8x4j5U,477
7
- pycmx/parse_cmx_statements.py,sha256=Wi0wjlJsphR3WXWXcJn7fPRDKANitZpQwvOSZ84HY2A,7242
8
- pycmx/transition.py,sha256=IlVz2X3Z7CttJccqzzjhPPUNY2Pe8ZmVMIBnCrVLIG0,2364
9
- pycmx/util.py,sha256=z1QYA4qUxiaCeKPhCQEmgIyhr5MHxSknW2xjl6qs1kA,782
10
- pycmx-1.3.0.dist-info/LICENSE,sha256=JS087ZFloGaV7LHygeUaXmT-fGLx0VF30W62wDTLRCc,1056
11
- pycmx-1.3.0.dist-info/METADATA,sha256=drKnB4Am-o2JUh_dFsDItdFV5JQYarvgOfl88icsBKY,4027
12
- pycmx-1.3.0.dist-info/WHEEL,sha256=RaoafKOydTQ7I_I3JTrPCg6kUmTgtm4BornzOqyEfJ8,88
13
- pycmx-1.3.0.dist-info/RECORD,,