pycmx 1.2.3__tar.gz → 1.4.0__tar.gz

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.
@@ -1,9 +1,9 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: pycmx
3
- Version: 1.2.3
3
+ Version: 1.4.0
4
4
  Summary: Python CMX 3600 Edit Decision List Parser
5
- Home-page: https://github.com/iluvcapra/pycmx
6
5
  License: MIT
6
+ License-File: LICENSE
7
7
  Keywords: parser,film,broadcast
8
8
  Author: Jamie Hardt
9
9
  Author-email: jamiehardt@me.com
@@ -17,6 +17,7 @@ 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: Programming Language :: Python :: 3.14
20
21
  Classifier: Topic :: Multimedia
21
22
  Classifier: Topic :: Multimedia :: Video
22
23
  Classifier: Topic :: Text Processing
@@ -24,6 +25,7 @@ Provides-Extra: doc
24
25
  Requires-Dist: sphinx (>=5.3.0) ; extra == "doc"
25
26
  Requires-Dist: sphinx_rtd_theme (>=1.1.1) ; extra == "doc"
26
27
  Project-URL: Documentation, https://pycmx.readthedocs.io/
28
+ Project-URL: Homepage, https://github.com/iluvcapra/pycmx
27
29
  Project-URL: Repository, https://github.com/iluvcapra/pycmx.git
28
30
  Project-URL: Tracker, https://github.com/iluvcapra/pycmx/issues
29
31
  Description-Content-Type: text/markdown
@@ -35,15 +37,20 @@ Description-Content-Type: text/markdown
35
37
 
36
38
  # pycmx
37
39
 
38
- The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and its most most common variations.
40
+ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
39
41
 
40
42
  ## Features
41
43
 
42
- * The major variations of the CMX 3600: the standard, "File32" and "File128"
43
- formats are automatically detected and properly read.
44
+ * The major variations of the CMX 3600: the standard, "File32", "File128" and
45
+ long Adobe Premiere event numbers are automatically detected and properly
46
+ read. Event number field and source name field sizes are determined
47
+ dynamically for each statement for a high level of compliance at the expense
48
+ of strictness.
44
49
  * Preserves relationship between events and individual edits/clips.
45
50
  * Remark or comment fields with common recognized forms are read and
46
51
  available to the client, including clip name and source file data.
52
+ * [ASC CDL][asc] and FRMC/VFX framecount statements are parsed and
53
+ decoded.
47
54
  * Symbolically decodes transitions and audio channels.
48
55
  * Does not parse or validate timecodes, does not enforce framerates, does not
49
56
  parameterize timecode or framerates in any way. This makes the parser more
@@ -53,6 +60,8 @@ The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and it
53
60
  list and give the client the ability to extend the package with their own
54
61
  parsing code.
55
62
 
63
+ [asc]: https://en.wikipedia.org/wiki/ASC_CDL
64
+
56
65
  ## Usage
57
66
 
58
67
  ### Opening and Parsing EDL Files
@@ -114,5 +123,3 @@ Audio channel 7 is present
114
123
  False
115
124
  ```
116
125
 
117
-
118
-
@@ -5,15 +5,20 @@
5
5
 
6
6
  # pycmx
7
7
 
8
- The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and its most most common variations.
8
+ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
9
9
 
10
10
  ## Features
11
11
 
12
- * The major variations of the CMX 3600: the standard, "File32" and "File128"
13
- formats are automatically detected and properly read.
12
+ * The major variations of the CMX 3600: the standard, "File32", "File128" and
13
+ long Adobe Premiere event numbers are automatically detected and properly
14
+ read. Event number field and source name field sizes are determined
15
+ dynamically for each statement for a high level of compliance at the expense
16
+ of strictness.
14
17
  * Preserves relationship between events and individual edits/clips.
15
18
  * Remark or comment fields with common recognized forms are read and
16
19
  available to the client, including clip name and source file data.
20
+ * [ASC CDL][asc] and FRMC/VFX framecount statements are parsed and
21
+ decoded.
17
22
  * Symbolically decodes transitions and audio channels.
18
23
  * Does not parse or validate timecodes, does not enforce framerates, does not
19
24
  parameterize timecode or framerates in any way. This makes the parser more
@@ -23,6 +28,8 @@ The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and it
23
28
  list and give the client the ability to extend the package with their own
24
29
  parsing code.
25
30
 
31
+ [asc]: https://en.wikipedia.org/wiki/ASC_CDL
32
+
26
33
  ## Usage
27
34
 
28
35
  ### Opening and Parsing EDL Files
@@ -83,5 +90,3 @@ Audio channel 7 is present
83
90
  >>> events[2].edits[0].channels.video
84
91
  False
85
92
  ```
86
-
87
-
@@ -2,13 +2,11 @@
2
2
  """
3
3
  pycmx is a parser for CMX 3600-style EDLs.
4
4
 
5
- This module (c) 2023 Jamie Hardt. For more information on your rights to
5
+ This module (c) 2025 Jamie Hardt. For more information on your rights to
6
6
  copy and reuse this software, refer to the LICENSE file included with the
7
7
  distribution.
8
8
  """
9
9
 
10
- __version__ = '1.2.2'
11
-
12
10
  from .parse_cmx_events import parse_cmx3600
13
11
  from .transition import Transition
14
12
  from .event import Event
@@ -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
@@ -1,26 +1,27 @@
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
6
6
 
7
+
7
8
  class ChannelMap:
8
9
  """
9
10
  Represents a set of all the channels to which an event applies.
10
11
  """
11
12
 
12
- _chan_map : Dict[str, Tuple] = {
13
- "V" : (True, False, False),
14
- "A" : (False, True, False),
15
- "A2" : (False, False, True),
16
- "AA" : (False, True, True),
17
- "B" : (True, True, False),
18
- "AA/V" : (True, True, True),
19
- "A2/V" : (True, False, True)
20
- }
13
+ _chan_map: Dict[str, Tuple] = {
14
+ "V": (True, False, False),
15
+ "A": (False, True, False),
16
+ "A2": (False, False, True),
17
+ "AA": (False, True, True),
18
+ "B": (True, True, False),
19
+ "AA/V": (True, True, True),
20
+ "A2/V": (True, False, True)
21
+ }
21
22
 
22
23
  def __init__(self, v=False, audio_channels=set()):
23
- self._audio_channel_set = audio_channels
24
+ self._audio_channel_set = audio_channels
24
25
  self.v = v
25
26
 
26
27
  @property
@@ -46,7 +47,7 @@ class ChannelMap:
46
47
 
47
48
  @a1.setter
48
49
  def a1(self, val: bool):
49
- self.set_audio_channel(1,val)
50
+ self.set_audio_channel(1, val)
50
51
 
51
52
  @property
52
53
  def a2(self) -> bool:
@@ -55,7 +56,7 @@ class ChannelMap:
55
56
 
56
57
  @a2.setter
57
58
  def a2(self, val: bool):
58
- self.set_audio_channel(2,val)
59
+ self.set_audio_channel(2, val)
59
60
 
60
61
  @property
61
62
  def a3(self) -> bool:
@@ -64,28 +65,28 @@ class ChannelMap:
64
65
 
65
66
  @a3.setter
66
67
  def a3(self, val: bool):
67
- self.set_audio_channel(3,val)
68
-
68
+ self.set_audio_channel(3, val)
69
+
69
70
  @property
70
71
  def a4(self) -> bool:
71
72
  """True if A4 is included"""
72
73
  return self.get_audio_channel(4)
73
74
 
74
75
  @a4.setter
75
- def a4(self,val: bool):
76
- self.set_audio_channel(4,val)
76
+ def a4(self, val: bool):
77
+ self.set_audio_channel(4, val)
77
78
 
78
79
  def get_audio_channel(self, chan_num) -> bool:
79
80
  """True if chan_num is included"""
80
81
  return (chan_num in self._audio_channel_set)
81
82
 
82
- def set_audio_channel(self,chan_num, enabled: bool):
83
+ def set_audio_channel(self, chan_num, enabled: bool):
83
84
  """If enabled is true, chan_num will be included"""
84
85
  if enabled:
85
86
  self._audio_channel_set.add(chan_num)
86
87
  elif self.get_audio_channel(chan_num):
87
88
  self._audio_channel_set.remove(chan_num)
88
-
89
+
89
90
  def _append_event(self, event_str):
90
91
  alt_channel_re = compile(r'^A(\d+)')
91
92
  if event_str in self._chan_map:
@@ -96,7 +97,7 @@ class ChannelMap:
96
97
  else:
97
98
  matchresult = match(alt_channel_re, event_str)
98
99
  if matchresult:
99
- self.set_audio_channel(int( matchresult.group(1)), True )
100
+ self.set_audio_channel(int(matchresult.group(1)), True)
100
101
 
101
102
  def _append_ext(self, audio_ext):
102
103
  self.a3 = audio_ext.audio3
@@ -109,5 +110,4 @@ class ChannelMap:
109
110
  out_v = self.video | other.video
110
111
  out_a = self._audio_channel_set | other._audio_channel_set
111
112
 
112
- return ChannelMap(v=out_v,audio_channels = out_a)
113
-
113
+ return ChannelMap(v=out_v, audio_channels=out_a)
@@ -0,0 +1,214 @@
1
+ # pycmx
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
+ )
15
+ from .transition import Transition
16
+ from .channel_map import ChannelMap
17
+
18
+ from typing import Optional
19
+
20
+
21
+ class Edit:
22
+ """
23
+ An individual source-to-record operation, with a source roll, source and
24
+ recorder timecode in and out, a transition and channels.
25
+ """
26
+
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
+ # Assigning types for the attributes explicitly
39
+ self._edit_statement: StmtEvent = edit_statement
40
+ self._audio_ext: Optional[StmtAudioExt] = audio_ext_statement
41
+ self._clip_name_statement: Optional[StmtClipName] = clip_name_statement
42
+ self._source_file_statement: Optional[StmtSourceFile] = \
43
+ source_file_statement
44
+ self._trans_name_statement: Optional[StmtEffectsName] = \
45
+ trans_name_statement
46
+ self._asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement
47
+ self._asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement
48
+ self._frmc_statement: Optional[StmtFrmc] = frmc_statement
49
+
50
+ @property
51
+ def line_number(self) -> int:
52
+ """
53
+ Get the line number for the "standard form" statement associated with
54
+ this edit. Line numbers a zero-indexed, such that the
55
+ "TITLE:" record is line zero.
56
+ """
57
+ return self._edit_statement.line_number
58
+
59
+ @property
60
+ def channels(self) -> ChannelMap:
61
+ """
62
+ Get the :obj:`ChannelMap` object associated with this Edit.
63
+ """
64
+ cm = ChannelMap()
65
+ cm._append_event(self._edit_statement.channels)
66
+ if self._audio_ext is not None:
67
+ cm._append_ext(self._audio_ext)
68
+ return cm
69
+
70
+ @property
71
+ def transition(self) -> Transition:
72
+ """
73
+ Get the :obj:`Transition` that initiates this edit.
74
+ """
75
+ if self._trans_name_statement:
76
+ return Transition(
77
+ self._edit_statement.trans,
78
+ self._edit_statement.trans_op,
79
+ self._trans_name_statement.name,
80
+ )
81
+ else:
82
+ return Transition(
83
+ self._edit_statement.trans, self._edit_statement.trans_op, None
84
+ )
85
+
86
+ @property
87
+ def source_in(self) -> str:
88
+ """
89
+ Get the source in timecode.
90
+ """
91
+ return self._edit_statement.source_in
92
+
93
+ @property
94
+ def source_out(self) -> str:
95
+ """
96
+ Get the source out timecode.
97
+ """
98
+
99
+ return self._edit_statement.source_out
100
+
101
+ @property
102
+ def record_in(self) -> str:
103
+ """
104
+ Get the record in timecode.
105
+ """
106
+
107
+ return self._edit_statement.record_in
108
+
109
+ @property
110
+ def record_out(self) -> str:
111
+ """
112
+ Get the record out timecode.
113
+ """
114
+
115
+ return self._edit_statement.record_out
116
+
117
+ @property
118
+ def source(self) -> str:
119
+ """
120
+ Get the source column. This is the 8, 32 or 128-character string on the
121
+ event record line, this usually references the tape name of the source.
122
+ """
123
+ return self._edit_statement.source
124
+
125
+ @property
126
+ def black(self) -> bool:
127
+ """
128
+ The source field for thie edit was "BL". Black video or silence should
129
+ be used as the source for this event.
130
+ """
131
+ return self.source == "BL"
132
+
133
+ @property
134
+ def aux_source(self) -> bool:
135
+ """
136
+ The source field for this edit was "AX". An auxiliary source is the
137
+ source for this event.
138
+ """
139
+ return self.source == "AX"
140
+
141
+ @property
142
+ def source_file(self) -> Optional[str]:
143
+ """
144
+ Get the source file, as attested by a "* SOURCE FILE" remark on the
145
+ EDL. This will return None if the information is not present.
146
+ """
147
+ if self._source_file_statement is None:
148
+ return None
149
+ else:
150
+ return self._source_file_statement.filename
151
+
152
+ @property
153
+ def clip_name(self) -> Optional[str]:
154
+ """
155
+ Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP
156
+ NAME" remark on the EDL. This will return None if the information is
157
+ not present.
158
+ """
159
+ if self._clip_name_statement is None:
160
+ return None
161
+ else:
162
+ return self._clip_name_statement.name
163
+
164
+ @property
165
+ def asc_sop(self) -> Optional[AscSopComponents[float]]:
166
+ """
167
+ Get ASC CDL Slope-Offset-Power color transfer function for the edit,
168
+ if present. The ASC SOP is a transfer function of the form:
169
+
170
+ :math:`y = (ax + b)^p`
171
+
172
+ for each color component the source, where the `slope` is `a`, `offset`
173
+ is `b` and `power` is `p`.
174
+ """
175
+ if self._asc_sop_statement is None:
176
+ return None
177
+
178
+ return self._asc_sop_statement.cdl_sop
179
+
180
+ @property
181
+ def asc_sop_raw(self) -> Optional[str]:
182
+ """
183
+ ASC CDL Slope-Offset-Power statement raw line
184
+ """
185
+ if self._asc_sop_statement is None:
186
+ return None
187
+
188
+ return self._asc_sop_statement.line
189
+
190
+ @property
191
+ def asc_sat(self) -> Optional[float]:
192
+ """
193
+ Get ASC CDL saturation value for clip, if present
194
+ """
195
+ if self._asc_sat_statement is None:
196
+ return None
197
+
198
+ return self._asc_sat_statement.value
199
+
200
+ @property
201
+ def framecounts(self) -> Optional[FramecountTriple]:
202
+ """
203
+ Get frame count offset data, if it exists. If an FRMC statement exists
204
+ in the EDL for the event it will give an integer frame count for the
205
+ edit's source in and out times.
206
+ """
207
+ if not self._frmc_statement:
208
+ return None
209
+
210
+ return FramecountTriple(
211
+ start=self._frmc_statement.start,
212
+ end=self._frmc_statement.end,
213
+ duration=self._frmc_statement.duration,
214
+ )
@@ -1,18 +1,22 @@
1
1
  # pycmx
2
- # (c) 2018 Jamie Hardt
2
+ # (c) 2018-2025 Jamie Hardt
3
3
 
4
- from .parse_cmx_statements import (StmtUnrecognized, StmtFCM, StmtEvent, StmtSourceUMID)
4
+ from .statements import (StmtCorruptRemark, StmtTitle, StmtEvent,
5
+ StmtUnrecognized, StmtSourceUMID)
5
6
  from .event import Event
6
7
  from .channel_map import ChannelMap
7
8
 
8
- from typing import Generator
9
+ from typing import Any, Generator
10
+
9
11
 
10
12
  class EditList:
11
13
  """
12
- Represents an entire edit decision list as returned by :func:`~pycmx.parse_cmx3600()`.
14
+ Represents an entire edit decision list as returned by
15
+ :func:`~pycmx.parse_cmx3600()`.
13
16
  """
17
+
14
18
  def __init__(self, statements):
15
- self.title_statement = statements[0]
19
+ self.title_statement: StmtTitle = statements[0]
16
20
  self.event_statements = statements[1:]
17
21
 
18
22
  @property
@@ -20,21 +24,23 @@ class EditList:
20
24
  """
21
25
  The detected format of the EDL. Possible values are: "3600", "File32",
22
26
  "File128", and "unknown".
27
+
28
+ Adobe EDLs with more than 999 events will be reported as "3600".
23
29
  """
24
- first_event = next( (s for s in self.event_statements if type(s) is StmtEvent), None)
30
+ first_event = next(
31
+ (s for s in self.event_statements if type(s) is StmtEvent), None)
25
32
 
26
33
  if first_event:
27
- if first_event.format == 8:
34
+ if first_event.source_field_size == 8:
28
35
  return '3600'
29
- elif first_event.format == 32:
36
+ elif first_event.source_field_size == 32:
30
37
  return 'File32'
31
- elif first_event.format == 128:
38
+ elif first_event.source_field_size == 128:
32
39
  return 'File128'
33
40
  else:
34
41
  return 'unknown'
35
42
  else:
36
43
  return 'unknown'
37
-
38
44
 
39
45
  @property
40
46
  def channels(self) -> ChannelMap:
@@ -48,7 +54,6 @@ class EditList:
48
54
  retval = retval | edit.channels
49
55
 
50
56
  return retval
51
-
52
57
 
53
58
  @property
54
59
  def title(self) -> str:
@@ -57,27 +62,26 @@ class EditList:
57
62
  """
58
63
  return self.title_statement.title
59
64
 
60
-
61
65
  @property
62
- def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, None]:
66
+ def unrecognized_statements(self) -> Generator[Any, None, None]:
63
67
  """
64
- 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`
65
73
  """
66
74
  for s in self.event_statements:
67
- if type(s) is StmtUnrecognized:
75
+ if type(s) is StmtUnrecognized or type(s) in StmtCorruptRemark:
68
76
  yield s
69
-
70
77
 
71
78
  @property
72
79
  def events(self) -> Generator[Event, None, None]:
73
80
  'A generator for all the events in the edit list'
74
- is_drop = None
75
81
  current_event_num = None
76
82
  event_statements = []
77
83
  for stmt in self.event_statements:
78
- if type(stmt) is StmtFCM:
79
- is_drop = stmt.drop
80
- elif type(stmt) is StmtEvent:
84
+ if type(stmt) is StmtEvent:
81
85
  if current_event_num is None:
82
86
  current_event_num = stmt.event
83
87
  event_statements.append(stmt)
@@ -89,8 +93,6 @@ class EditList:
89
93
  else:
90
94
  event_statements.append(stmt)
91
95
 
92
- elif type(stmt) is StmtSourceUMID:
93
- break
94
96
  else:
95
97
  event_statements.append(stmt)
96
98
 
@@ -101,9 +103,7 @@ class EditList:
101
103
  """
102
104
  A generator for all of the sources in the list
103
105
  """
104
-
106
+
105
107
  for stmt in self.event_statements:
106
108
  if type(stmt) is StmtSourceUMID:
107
109
  yield stmt
108
-
109
-