pycmx 1.3.0__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.3
1
+ Metadata-Version: 2.4
2
2
  Name: pycmx
3
- Version: 1.3.0
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,17 +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
39
- its most most common variations.
40
+ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
40
41
 
41
42
  ## Features
42
43
 
43
- * The major variations of the CMX 3600: the standard, "File32", "File128" and
44
+ * The major variations of the CMX 3600: the standard, "File32", "File128" and
44
45
  long Adobe Premiere event numbers are automatically detected and properly
45
- read.
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.
46
49
  * Preserves relationship between events and individual edits/clips.
47
50
  * Remark or comment fields with common recognized forms are read and
48
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.
49
54
  * Symbolically decodes transitions and audio channels.
50
55
  * Does not parse or validate timecodes, does not enforce framerates, does not
51
56
  parameterize timecode or framerates in any way. This makes the parser more
@@ -55,6 +60,8 @@ its most most common variations.
55
60
  list and give the client the ability to extend the package with their own
56
61
  parsing code.
57
62
 
63
+ [asc]: https://en.wikipedia.org/wiki/ASC_CDL
64
+
58
65
  ## Usage
59
66
 
60
67
  ### Opening and Parsing EDL Files
@@ -5,17 +5,20 @@
5
5
 
6
6
  # pycmx
7
7
 
8
- The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and
9
- its most most common variations.
8
+ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
10
9
 
11
10
  ## Features
12
11
 
13
- * The major variations of the CMX 3600: the standard, "File32", "File128" and
12
+ * The major variations of the CMX 3600: the standard, "File32", "File128" and
14
13
  long Adobe Premiere event numbers are automatically detected and properly
15
- read.
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.
16
17
  * Preserves relationship between events and individual edits/clips.
17
18
  * Remark or comment fields with common recognized forms are read and
18
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.
19
22
  * Symbolically decodes transitions and audio channels.
20
23
  * Does not parse or validate timecodes, does not enforce framerates, does not
21
24
  parameterize timecode or framerates in any way. This makes the parser more
@@ -25,6 +28,8 @@ its most most common variations.
25
28
  list and give the client the ability to extend the package with their own
26
29
  parsing code.
27
30
 
31
+ [asc]: https://en.wikipedia.org/wiki/ASC_CDL
32
+
28
33
  ## Usage
29
34
 
30
35
  ### Opening and Parsing EDL Files
@@ -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,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
@@ -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,12 +1,12 @@
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:
@@ -16,7 +16,7 @@ class EditList:
16
16
  """
17
17
 
18
18
  def __init__(self, statements):
19
- self.title_statement = statements[0]
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
 
@@ -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
@@ -74,7 +74,10 @@ class Event:
74
74
  audio_ext_statement=e1[1],
75
75
  clip_name_statement=n1,
76
76
  source_file_statement=s1,
77
- trans_name_statement=u1)
77
+ trans_name_statement=u1,
78
+ asc_sop_statement=self._asc_sop_statement(),
79
+ asc_sat_statement=self._asc_sat_statement(),
80
+ frmc_statement=self._frmc_statement())
78
81
  for (e1, n1, s1, u1) in zip(*the_zip)]
79
82
 
80
83
  @property
@@ -106,3 +109,14 @@ class Event:
106
109
  yield (s1, s2)
107
110
  elif type(s1) is StmtEvent:
108
111
  yield (s1, None)
112
+
113
+ def _asc_sop_statement(self) -> Optional[StmtCdlSop]:
114
+ return next((s for s in self.statements if type(s) is StmtCdlSop),
115
+ None)
116
+
117
+ def _asc_sat_statement(self) -> Optional[StmtCdlSat]:
118
+ return next((s for s in self.statements if type(s) is StmtCdlSat),
119
+ None)
120
+
121
+ def _frmc_statement(self) -> Optional[StmtFrmc]:
122
+ return next((s for s in self.statements if type(s) is StmtFrmc), None)
@@ -1,13 +1,11 @@
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
10
  def parse_cmx3600(f: TextIO) -> EditList:
13
11
  """
@@ -0,0 +1,223 @@
1
+ # pycmx
2
+ # (c) 2018-2025 Jamie Hardt
3
+
4
+ import re
5
+ from typing import TextIO, List
6
+
7
+ from .cdl import AscSopComponents, Rgb
8
+
9
+ from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
10
+ StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
11
+ StmtAudioExt, StmtClipName, StmtEffectsName,
12
+ StmtEvent, StmtSourceFile, StmtSplitEdit)
13
+ from .util import collimate
14
+
15
+
16
+ def parse_cmx3600_statements(file: TextIO) -> List[object]:
17
+ """
18
+ Return a list of every statement in the file argument.
19
+ """
20
+ lines = file.readlines()
21
+ return [_parse_cmx3600_line(line.strip(), line_number)
22
+ for (line_number, line) in enumerate(lines)]
23
+
24
+
25
+ def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
26
+ return [event_field_length, 2, source_field_length, 1,
27
+ 4, 2, # chans
28
+ 4, 1, # trans
29
+ 3, 1, # trans op
30
+ 11, 1,
31
+ 11, 1,
32
+ 11, 1,
33
+ 11]
34
+
35
+ # def _edl_m2_column_widths():
36
+ # return [2, # "M2"
37
+ # 3,3, #
38
+ # 8,8,1,4,2,1,4,13,3,1,1]
39
+
40
+
41
+ def _parse_cmx3600_line(line: str, line_number: int) -> object:
42
+ """
43
+ Parses a single CMX EDL line.
44
+
45
+ :param line: A single EDL line.
46
+ :param line_number: The index of this line in the file.
47
+ """
48
+ event_num_p = re.compile(r"^(\d+) ")
49
+ line_matcher = event_num_p.match(line)
50
+
51
+ if line.startswith("TITLE:"):
52
+ return _parse_title(line, line_number)
53
+ if line.startswith("FCM:"):
54
+ return _parse_fcm(line, line_number)
55
+ if line_matcher is not None:
56
+ event_field_len = len(line_matcher.group(1))
57
+ source_field_len = len(line) - (event_field_len + 65)
58
+ return _parse_columns_for_standard_form(line, event_field_len,
59
+ source_field_len, line_number)
60
+ if line.startswith("AUD"):
61
+ return _parse_extended_audio_channels(line, line_number)
62
+ if line.startswith("*"):
63
+ return _parse_remark(line[1:].strip(), line_number)
64
+ if line.startswith(">>> SOURCE"):
65
+ return _parse_source_umid_statement(line, line_number)
66
+ if line.startswith("EFFECTS NAME IS"):
67
+ return _parse_effects_name(line, line_number)
68
+ if line.startswith("SPLIT:"):
69
+ return _parse_split(line, line_number)
70
+ if line.startswith("M2"):
71
+ pass
72
+ # return _parse_motion_memory(line, line_number)
73
+
74
+ return _parse_unrecognized(line, line_number)
75
+
76
+
77
+ def _parse_title(line, line_num) -> StmtTitle:
78
+ title = line[6:].strip()
79
+ return StmtTitle(title=title, line_number=line_num)
80
+
81
+
82
+ def _parse_fcm(line, line_num) -> StmtFCM:
83
+ val = line[4:].strip()
84
+ if val == "DROP FRAME":
85
+ return StmtFCM(drop=True, line_number=line_num)
86
+
87
+ return StmtFCM(drop=False, line_number=line_num)
88
+
89
+
90
+ def _parse_extended_audio_channels(line, line_number):
91
+ content = line.strip()
92
+ audio3 = "3" in content
93
+ audio4 = "4" in content
94
+
95
+ if audio3 or audio4:
96
+ return StmtAudioExt(audio3, audio4, line_number)
97
+ else:
98
+ return StmtUnrecognized(line, line_number)
99
+
100
+
101
+ def _parse_remark(line, line_number) -> object:
102
+ if line.startswith("FROM CLIP NAME:"):
103
+ return StmtClipName(name=line[15:].strip(), affect="from",
104
+ line_number=line_number)
105
+ elif line.startswith("TO CLIP NAME:"):
106
+ return StmtClipName(name=line[13:].strip(), affect="to",
107
+ line_number=line_number)
108
+ elif line.startswith("SOURCE FILE:"):
109
+ return StmtSourceFile(filename=line[12:].strip(),
110
+ line_number=line_number)
111
+ elif line.startswith("ASC_SOP"):
112
+ group_patterns: list[str] = re.findall(r'\((.*?)\)', line)
113
+
114
+ v1: list[list[tuple[str, str]]] = \
115
+ [re.findall(r'(-?\d+(\.\d+)?)', a) for a in group_patterns]
116
+
117
+ v: list[list[str]] = [[a[0] for a in b] for b in v1]
118
+
119
+ if len(v) != 3 or any([len(a) != 3 for a in v]):
120
+ return StmtRemark(line, line_number)
121
+
122
+ else:
123
+ try:
124
+ return StmtCdlSop(line=line,
125
+ cdl_sop=AscSopComponents(
126
+ slope=Rgb(red=float(v[0][0]),
127
+ green=float(v[0][1]),
128
+ blue=float(v[0][2])),
129
+ offset=Rgb(red=float(v[1][0]),
130
+ green=float(v[1][1]),
131
+ blue=float(v[1][2])),
132
+ power=Rgb(red=float(v[2][0]),
133
+ green=float(v[2][1]),
134
+ blue=float(v[2][2]))
135
+ ),
136
+ line_number=line_number)
137
+
138
+ except ValueError as e:
139
+ return StmtCorruptRemark('ASC_SOP', e, line_number)
140
+
141
+ elif line.startswith("ASC_SAT"):
142
+ value = re.findall(r'(-?\d+(\.\d+)?)', line)
143
+
144
+ if len(value) != 1:
145
+ return StmtRemark(line, line_number)
146
+
147
+ else:
148
+ try:
149
+ return StmtCdlSat(value=float(value[0][0]),
150
+ line_number=line_number)
151
+
152
+ except ValueError as e:
153
+ return StmtCorruptRemark('ASC_SAT', e, line_number)
154
+
155
+ elif line.startswith("FRMC"):
156
+ match = re.match(r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)'
157
+ r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
158
+
159
+ if match is None:
160
+ return StmtCorruptRemark('FRMC', None, line_number)
161
+
162
+ else:
163
+ try:
164
+ return StmtFrmc(start=int(match.group(1)),
165
+ end=int(match.group(2)),
166
+ duration=int(match.group(3)),
167
+ line_number=line_number)
168
+ except ValueError as e:
169
+ return StmtCorruptRemark('FRMC', e, line_number)
170
+
171
+ else:
172
+ return StmtRemark(text=line, line_number=line_number)
173
+
174
+
175
+ def _parse_effects_name(line, line_number) -> StmtEffectsName:
176
+ name = line[16:].strip()
177
+ return StmtEffectsName(name=name, line_number=line_number)
178
+
179
+
180
+ def _parse_split(line: str, line_number):
181
+ split_type = line[10:21]
182
+ is_video = split_type.startswith("VIDEO")
183
+
184
+ split_delay = line[24:35]
185
+ return StmtSplitEdit(video=is_video, delay=split_delay,
186
+ line_number=line_number)
187
+
188
+
189
+ # def _parse_motion_memory(line, line_number):
190
+ # return StmtMotionMemory(source="", fps="")
191
+ #
192
+
193
+ def _parse_unrecognized(line, line_number):
194
+ return StmtUnrecognized(content=line, line_number=line_number)
195
+
196
+
197
+ def _parse_columns_for_standard_form(line: str, event_field_length: int,
198
+ source_field_length: int,
199
+ line_number: int):
200
+ col_widths = _edl_column_widths(event_field_length, source_field_length)
201
+
202
+ if sum(col_widths) > len(line):
203
+ return StmtUnrecognized(content=line, line_number=line_number)
204
+
205
+ column_strings = collimate(line, col_widths)
206
+
207
+ return StmtEvent(event=column_strings[0],
208
+ source=column_strings[2].strip(),
209
+ channels=column_strings[4].strip(),
210
+ trans=column_strings[6].strip(),
211
+ trans_op=column_strings[8].strip(),
212
+ source_in=column_strings[10].strip(),
213
+ source_out=column_strings[12].strip(),
214
+ record_in=column_strings[14].strip(),
215
+ record_out=column_strings[16].strip(),
216
+ line_number=line_number,
217
+ source_field_size=source_field_length)
218
+
219
+
220
+ def _parse_source_umid_statement(line, line_number):
221
+ # trimmed = line[3:].strip()
222
+ # return StmtSourceUMID(name=None, umid=None, line_number=line_number)
223
+ ...
@@ -0,0 +1,104 @@
1
+ # pycmx
2
+ # (c) 2025 Jamie Hardt
3
+
4
+ from typing import Any, NamedTuple
5
+
6
+ from .cdl import AscSopComponents
7
+
8
+ # type str = str
9
+
10
+
11
+ class StmtTitle(NamedTuple):
12
+ title: str
13
+ line_number: int
14
+
15
+
16
+ class StmtFCM(NamedTuple):
17
+ drop: bool
18
+ line_number: int
19
+
20
+
21
+ class StmtEvent(NamedTuple):
22
+ event: int
23
+ source: str
24
+ channels: str
25
+ trans: str
26
+ trans_op: str
27
+ source_in: str
28
+ source_out: str
29
+ record_in: str
30
+ record_out: str
31
+ source_field_size: int
32
+ line_number: int
33
+
34
+
35
+ class StmtAudioExt(NamedTuple):
36
+ audio3: bool
37
+ audio4: bool
38
+ line_number: int
39
+
40
+
41
+ class StmtClipName(NamedTuple):
42
+ name: str
43
+ affect: str
44
+ line_number: int
45
+
46
+
47
+ class StmtSourceFile(NamedTuple):
48
+ filename: str
49
+ line_number: int
50
+
51
+
52
+ class StmtCdlSop(NamedTuple):
53
+ line: str
54
+ cdl_sop: AscSopComponents[float]
55
+ line_number: int
56
+
57
+
58
+ class StmtCdlSat(NamedTuple):
59
+ value: float
60
+ line_number: int
61
+
62
+
63
+ class StmtFrmc(NamedTuple):
64
+ start: int
65
+ end: int
66
+ duration: int
67
+ line_number: int
68
+
69
+
70
+ class StmtRemark(NamedTuple):
71
+ text: str
72
+ line_number: int
73
+
74
+
75
+ class StmtEffectsName(NamedTuple):
76
+ name: str
77
+ line_number: int
78
+
79
+
80
+ class StmtSourceUMID(NamedTuple):
81
+ name: str
82
+ umid: str
83
+ line_number: int
84
+
85
+
86
+ class StmtSplitEdit(NamedTuple):
87
+ video: bool
88
+ delay: str
89
+ line_number: int
90
+
91
+
92
+ class StmtUnrecognized(NamedTuple):
93
+ content: str
94
+ line_number: int
95
+
96
+
97
+ class StmtCorruptRemark(NamedTuple):
98
+ selector: str
99
+ exception: Any
100
+ line_number: int
101
+
102
+
103
+ # StmtMotionMemory = namedtuple(
104
+ # "MotionMemory", ["source", "fps"]) # FIXME needs more fields
@@ -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,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pycmx"
3
- version = "1.3.0"
3
+ version = "1.4.0"
4
4
  description = "Python CMX 3600 Edit Decision List Parser"
5
5
  authors = ["Jamie Hardt <jamiehardt@me.com>"]
6
6
  license = "MIT"
pycmx-1.3.0/pycmx/edit.py DELETED
@@ -1,133 +0,0 @@
1
- # pycmx
2
- # (c) 2018 Jamie Hardt
3
-
4
- from .transition import Transition
5
- from .channel_map import ChannelMap
6
- # from .parse_cmx_statements import StmtEffectsName
7
-
8
- from typing import Optional
9
-
10
-
11
- class Edit:
12
- """
13
- An individual source-to-record operation, with a source roll, source and
14
- recorder timecode in and out, a transition and channels.
15
- """
16
-
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
25
-
26
- @property
27
- def line_number(self) -> int:
28
- """
29
- 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.
32
- """
33
- return self.edit_statement.line_number
34
-
35
- @property
36
- def channels(self) -> ChannelMap:
37
- """
38
- Get the :obj:`ChannelMap` object associated with this Edit.
39
- """
40
- 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)
44
- return cm
45
-
46
- @property
47
- def transition(self) -> Transition:
48
- """
49
- Get the :obj:`Transition` object associated with this edit.
50
- """
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)
55
- else:
56
- return Transition(self.edit_statement.trans,
57
- self.edit_statement.trans_op, None)
58
-
59
- @property
60
- def source_in(self) -> str:
61
- """
62
- Get the source in timecode.
63
- """
64
- return self.edit_statement.source_in
65
-
66
- @property
67
- def source_out(self) -> str:
68
- """
69
- Get the source out timecode.
70
- """
71
-
72
- return self.edit_statement.source_out
73
-
74
- @property
75
- def record_in(self) -> str:
76
- """
77
- Get the record in timecode.
78
- """
79
-
80
- return self.edit_statement.record_in
81
-
82
- @property
83
- def record_out(self) -> str:
84
- """
85
- Get the record out timecode.
86
- """
87
-
88
- return self.edit_statement.record_out
89
-
90
- @property
91
- def source(self) -> str:
92
- """
93
- Get the source column. This is the 8, 32 or 128-character string on the
94
- event record line, this usually references the tape name of the source.
95
- """
96
- return self.edit_statement.source
97
-
98
- @property
99
- def black(self) -> bool:
100
- """
101
- Black video or silence should be used as the source for this event.
102
- """
103
- return self.source == "BL"
104
-
105
- @property
106
- def aux_source(self) -> bool:
107
- """
108
- An auxiliary source is the source of this event.
109
- """
110
- return self.source == "AX"
111
-
112
- @property
113
- def source_file(self) -> Optional[str]:
114
- """
115
- Get the source file, as attested by a "* SOURCE FILE" remark on the
116
- EDL. This will return None if the information is not present.
117
- """
118
- if self.source_file_statement is None:
119
- return None
120
- else:
121
- return self.source_file_statement.filename
122
-
123
- @property
124
- def clip_name(self) -> Optional[str]:
125
- """
126
- Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP
127
- NAME" remark on the EDL. This will return None if the information is
128
- not present.
129
- """
130
- if self.clip_name_statement is None:
131
- return None
132
- else:
133
- return self.clip_name_statement.name
@@ -1,193 +0,0 @@
1
- # pycmx
2
- # (c) 2018 Jamie Hardt
3
-
4
- import re
5
- from collections import namedtuple
6
- from typing import TextIO, List
7
-
8
-
9
- from .util import collimate
10
-
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]:
30
- """
31
- Return a list of every statement in the file argument.
32
- """
33
- lines = file.readlines()
34
- return [_parse_cmx3600_line(line.strip(), line_number)
35
- for (line_number, line) in enumerate(lines)]
36
-
37
-
38
- def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
39
- return [event_field_length, 2, source_field_length, 1,
40
- 4, 2, # chans
41
- 4, 1, # trans
42
- 3, 1, # trans op
43
- 11, 1,
44
- 11, 1,
45
- 11, 1,
46
- 11]
47
-
48
- # def _edl_m2_column_widths():
49
- # return [2, # "M2"
50
- # 3,3, #
51
- # 8,8,1,4,2,1,4,13,3,1,1]
52
-
53
-
54
- def _parse_cmx3600_line(line: str, line_number: int) -> object:
55
- """
56
- Parses a single CMX EDL line.
57
-
58
- :param line: A single EDL line.
59
- :param line_number: The index of this line in the file.
60
- """
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}) ")
64
-
65
- if line.startswith("TITLE:"):
66
- return _parse_title(line, line_number)
67
- elif line.startswith("FCM:"):
68
- 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"):
83
- return _parse_extended_audio_channels(line, line_number)
84
- elif line.startswith("*"):
85
- return _parse_remark(line[1:].strip(), line_number)
86
- elif line.startswith(">>> SOURCE"):
87
- return _parse_source_umid_statement(line, line_number)
88
- elif line.startswith("EFFECTS NAME IS"):
89
- return _parse_effects_name(line, line_number)
90
- elif line.startswith("SPLIT:"):
91
- 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)
96
-
97
-
98
- def _parse_title(line, line_num) -> StmtTitle:
99
- title = line[6:].strip()
100
- return StmtTitle(title=title, line_number=line_num)
101
-
102
-
103
- def _parse_fcm(line, line_num) -> StmtFCM:
104
- val = line[4:].strip()
105
- if val == "DROP FRAME":
106
- 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
-
116
- def _parse_standard_form(line, line_number):
117
- return _parse_columns_for_standard_form(line, 3, 8, line_number)
118
-
119
-
120
- def _parse_extended_audio_channels(line, line_number):
121
- 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)
128
- else:
129
- return StmtUnrecognized(content=line, line_number=line_number)
130
-
131
-
132
- def _parse_remark(line, line_number) -> object:
133
- if line.startswith("FROM CLIP NAME:"):
134
- return StmtClipName(name=line[15:].strip(), affect="from",
135
- line_number=line_number)
136
- elif line.startswith("TO CLIP NAME:"):
137
- return StmtClipName(name=line[13:].strip(), affect="to",
138
- line_number=line_number)
139
- elif line.startswith("SOURCE FILE:"):
140
- return StmtSourceFile(filename=line[12:].strip(),
141
- line_number=line_number)
142
- else:
143
- return StmtRemark(text=line, line_number=line_number)
144
-
145
-
146
- def _parse_effects_name(line, line_number) -> StmtEffectsName:
147
- name = line[16:].strip()
148
- return StmtEffectsName(name=name, line_number=line_number)
149
-
150
-
151
- def _parse_split(line, line_number):
152
- split_type = line[10:21]
153
- is_video = False
154
- if split_type.startswith("VIDEO"):
155
- is_video = True
156
-
157
- split_mag = line[24:35]
158
- return StmtSplitEdit(video=is_video, magnitude=split_mag,
159
- line_number=line_number)
160
-
161
-
162
- def _parse_motion_memory(line, line_number):
163
- return StmtMotionMemory(source="", fps="")
164
-
165
-
166
- def _parse_unrecognized(line, line_number):
167
- return StmtUnrecognized(content=line, line_number=line_number)
168
-
169
-
170
- def _parse_columns_for_standard_form(line, event_field_length,
171
- source_field_length, line_number):
172
- col_widths = _edl_column_widths(event_field_length, source_field_length)
173
-
174
- if sum(col_widths) > len(line):
175
- return StmtUnrecognized(content=line, line_number=line_number)
176
-
177
- column_strings = collimate(line, col_widths)
178
-
179
- return StmtEvent(event=column_strings[0],
180
- source=column_strings[2].strip(),
181
- channels=column_strings[4].strip(),
182
- trans=column_strings[6].strip(),
183
- trans_op=column_strings[8].strip(),
184
- source_in=column_strings[10].strip(),
185
- source_out=column_strings[12].strip(),
186
- record_in=column_strings[14].strip(),
187
- record_out=column_strings[16].strip(),
188
- line_number=line_number, format=source_field_length)
189
-
190
-
191
- def _parse_source_umid_statement(line, line_number):
192
- # trimmed = line[3:].strip()
193
- return StmtSourceUMID(name=None, umid=None, line_number=line_number)
File without changes
File without changes
File without changes