pycmx 1.2.3__py3-none-any.whl → 1.4.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 +1 -3
- pycmx/cdl.py +48 -0
- pycmx/channel_map.py +22 -22
- pycmx/edit.py +118 -32
- pycmx/edit_list.py +25 -25
- pycmx/event.py +48 -30
- pycmx/parse_cmx_events.py +4 -6
- pycmx/parse_cmx_statements.py +171 -117
- pycmx/statements.py +104 -0
- pycmx/transition.py +4 -5
- pycmx/util.py +10 -11
- {pycmx-1.2.3.dist-info → pycmx-1.4.0.dist-info}/METADATA +15 -8
- pycmx-1.4.0.dist-info/RECORD +15 -0
- {pycmx-1.2.3.dist-info → pycmx-1.4.0.dist-info}/WHEEL +1 -1
- pycmx-1.2.3.dist-info/RECORD +0 -13
- {pycmx-1.2.3.dist-info → pycmx-1.4.0.dist-info/licenses}/LICENSE +0 -0
pycmx/__init__.py
CHANGED
|
@@ -2,13 +2,11 @@
|
|
|
2
2
|
"""
|
|
3
3
|
pycmx is a parser for CMX 3600-style EDLs.
|
|
4
4
|
|
|
5
|
-
This module (c)
|
|
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
|
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,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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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(
|
|
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
|
|
113
|
-
|
|
113
|
+
return ChannelMap(v=out_v, audio_channels=out_a)
|
pycmx/edit.py
CHANGED
|
@@ -1,32 +1,60 @@
|
|
|
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
|
|
|
20
|
+
|
|
10
21
|
class Edit:
|
|
11
22
|
"""
|
|
12
|
-
An individual source-to-record operation, with a source roll, source and
|
|
23
|
+
An individual source-to-record operation, with a source roll, source and
|
|
13
24
|
recorder timecode in and out, a transition and channels.
|
|
14
25
|
"""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
self
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
21
49
|
|
|
22
50
|
@property
|
|
23
51
|
def line_number(self) -> int:
|
|
24
52
|
"""
|
|
25
53
|
Get the line number for the "standard form" statement associated with
|
|
26
|
-
this edit. Line numbers a zero-indexed, such that the
|
|
54
|
+
this edit. Line numbers a zero-indexed, such that the
|
|
27
55
|
"TITLE:" record is line zero.
|
|
28
56
|
"""
|
|
29
|
-
return self.
|
|
57
|
+
return self._edit_statement.line_number
|
|
30
58
|
|
|
31
59
|
@property
|
|
32
60
|
def channels(self) -> ChannelMap:
|
|
@@ -34,27 +62,33 @@ class Edit:
|
|
|
34
62
|
Get the :obj:`ChannelMap` object associated with this Edit.
|
|
35
63
|
"""
|
|
36
64
|
cm = ChannelMap()
|
|
37
|
-
cm._append_event(self.
|
|
38
|
-
if self.
|
|
39
|
-
cm._append_ext(self.
|
|
65
|
+
cm._append_event(self._edit_statement.channels)
|
|
66
|
+
if self._audio_ext is not None:
|
|
67
|
+
cm._append_ext(self._audio_ext)
|
|
40
68
|
return cm
|
|
41
69
|
|
|
42
70
|
@property
|
|
43
71
|
def transition(self) -> Transition:
|
|
44
72
|
"""
|
|
45
|
-
Get the :obj:`Transition`
|
|
73
|
+
Get the :obj:`Transition` that initiates this edit.
|
|
46
74
|
"""
|
|
47
|
-
if self.
|
|
48
|
-
return Transition(
|
|
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
|
+
)
|
|
49
81
|
else:
|
|
50
|
-
return Transition(
|
|
51
|
-
|
|
82
|
+
return Transition(
|
|
83
|
+
self._edit_statement.trans, self._edit_statement.trans_op, None
|
|
84
|
+
)
|
|
85
|
+
|
|
52
86
|
@property
|
|
53
87
|
def source_in(self) -> str:
|
|
54
88
|
"""
|
|
55
89
|
Get the source in timecode.
|
|
56
90
|
"""
|
|
57
|
-
return self.
|
|
91
|
+
return self._edit_statement.source_in
|
|
58
92
|
|
|
59
93
|
@property
|
|
60
94
|
def source_out(self) -> str:
|
|
@@ -62,7 +96,7 @@ class Edit:
|
|
|
62
96
|
Get the source out timecode.
|
|
63
97
|
"""
|
|
64
98
|
|
|
65
|
-
return self.
|
|
99
|
+
return self._edit_statement.source_out
|
|
66
100
|
|
|
67
101
|
@property
|
|
68
102
|
def record_in(self) -> str:
|
|
@@ -70,7 +104,7 @@ class Edit:
|
|
|
70
104
|
Get the record in timecode.
|
|
71
105
|
"""
|
|
72
106
|
|
|
73
|
-
return self.
|
|
107
|
+
return self._edit_statement.record_in
|
|
74
108
|
|
|
75
109
|
@property
|
|
76
110
|
def record_out(self) -> str:
|
|
@@ -78,7 +112,7 @@ class Edit:
|
|
|
78
112
|
Get the record out timecode.
|
|
79
113
|
"""
|
|
80
114
|
|
|
81
|
-
return self.
|
|
115
|
+
return self._edit_statement.record_out
|
|
82
116
|
|
|
83
117
|
@property
|
|
84
118
|
def source(self) -> str:
|
|
@@ -86,19 +120,21 @@ class Edit:
|
|
|
86
120
|
Get the source column. This is the 8, 32 or 128-character string on the
|
|
87
121
|
event record line, this usually references the tape name of the source.
|
|
88
122
|
"""
|
|
89
|
-
return self.
|
|
123
|
+
return self._edit_statement.source
|
|
90
124
|
|
|
91
125
|
@property
|
|
92
126
|
def black(self) -> bool:
|
|
93
127
|
"""
|
|
94
|
-
|
|
128
|
+
The source field for thie edit was "BL". Black video or silence should
|
|
129
|
+
be used as the source for this event.
|
|
95
130
|
"""
|
|
96
131
|
return self.source == "BL"
|
|
97
132
|
|
|
98
133
|
@property
|
|
99
134
|
def aux_source(self) -> bool:
|
|
100
135
|
"""
|
|
101
|
-
An auxiliary source is the
|
|
136
|
+
The source field for this edit was "AX". An auxiliary source is the
|
|
137
|
+
source for this event.
|
|
102
138
|
"""
|
|
103
139
|
return self.source == "AX"
|
|
104
140
|
|
|
@@ -108,21 +144,71 @@ class Edit:
|
|
|
108
144
|
Get the source file, as attested by a "* SOURCE FILE" remark on the
|
|
109
145
|
EDL. This will return None if the information is not present.
|
|
110
146
|
"""
|
|
111
|
-
if self.
|
|
147
|
+
if self._source_file_statement is None:
|
|
112
148
|
return None
|
|
113
149
|
else:
|
|
114
|
-
return self.
|
|
150
|
+
return self._source_file_statement.filename
|
|
115
151
|
|
|
116
152
|
@property
|
|
117
153
|
def clip_name(self) -> Optional[str]:
|
|
118
154
|
"""
|
|
119
|
-
Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP
|
|
155
|
+
Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP
|
|
120
156
|
NAME" remark on the EDL. This will return None if the information is
|
|
121
157
|
not present.
|
|
122
158
|
"""
|
|
123
|
-
if self.
|
|
159
|
+
if self._clip_name_statement is None:
|
|
124
160
|
return None
|
|
125
161
|
else:
|
|
126
|
-
return self.
|
|
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
|
|
127
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
|
|
128
209
|
|
|
210
|
+
return FramecountTriple(
|
|
211
|
+
start=self._frmc_statement.start,
|
|
212
|
+
end=self._frmc_statement.end,
|
|
213
|
+
duration=self._frmc_statement.duration,
|
|
214
|
+
)
|
pycmx/edit_list.py
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# pycmx
|
|
2
|
-
# (c) 2018 Jamie Hardt
|
|
2
|
+
# (c) 2018-2025 Jamie Hardt
|
|
3
3
|
|
|
4
|
-
from .
|
|
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
|
|
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(
|
|
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.
|
|
34
|
+
if first_event.source_field_size == 8:
|
|
28
35
|
return '3600'
|
|
29
|
-
elif first_event.
|
|
36
|
+
elif first_event.source_field_size == 32:
|
|
30
37
|
return 'File32'
|
|
31
|
-
elif first_event.
|
|
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[
|
|
66
|
+
def unrecognized_statements(self) -> Generator[Any, None, None]:
|
|
63
67
|
"""
|
|
64
|
-
A generator for all the unrecognized statements
|
|
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
|
|
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
|
-
|
pycmx/event.py
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
# pycmx
|
|
2
|
-
# (c) 2023 Jamie Hardt
|
|
2
|
+
# (c) 2023-2025 Jamie Hardt
|
|
3
3
|
|
|
4
|
-
from .
|
|
5
|
-
|
|
4
|
+
from .statements import (StmtFrmc, StmtEvent, StmtClipName, StmtSourceFile,
|
|
5
|
+
StmtAudioExt, StmtUnrecognized, StmtEffectsName,
|
|
6
|
+
StmtCdlSop, StmtCdlSat)
|
|
7
|
+
from .edit import Edit
|
|
6
8
|
|
|
7
9
|
from typing import List, Generator, Optional, Tuple, Any
|
|
8
10
|
|
|
11
|
+
|
|
9
12
|
class Event:
|
|
10
13
|
"""
|
|
11
|
-
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
|
|
12
|
-
"""
|
|
14
|
+
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
|
|
15
|
+
event number. """
|
|
13
16
|
|
|
14
17
|
def __init__(self, statements):
|
|
15
18
|
self.statements = statements
|
|
16
|
-
|
|
19
|
+
|
|
17
20
|
@property
|
|
18
21
|
def number(self) -> int:
|
|
19
22
|
"""
|
|
@@ -28,10 +31,10 @@ class Event:
|
|
|
28
31
|
will have multiple edits when a dissolve, wipe or key transition needs
|
|
29
32
|
to be performed.
|
|
30
33
|
"""
|
|
31
|
-
edits_audio = list(
|
|
32
|
-
clip_names
|
|
33
|
-
source_files= self._source_file_statements()
|
|
34
|
-
|
|
34
|
+
edits_audio = list(self._statements_with_audio_ext())
|
|
35
|
+
clip_names = self._clip_name_statements()
|
|
36
|
+
source_files = self._source_file_statements()
|
|
37
|
+
|
|
35
38
|
the_zip: List[List[Any]] = [edits_audio]
|
|
36
39
|
|
|
37
40
|
if len(edits_audio) == 2:
|
|
@@ -45,19 +48,19 @@ class Event:
|
|
|
45
48
|
end_name = clip_name
|
|
46
49
|
|
|
47
50
|
the_zip.append([start_name, end_name])
|
|
48
|
-
else:
|
|
51
|
+
else:
|
|
49
52
|
if len(edits_audio) == len(clip_names):
|
|
50
53
|
the_zip.append(clip_names)
|
|
51
54
|
else:
|
|
52
|
-
the_zip.append([None] * len(edits_audio)
|
|
55
|
+
the_zip.append([None] * len(edits_audio))
|
|
53
56
|
|
|
54
57
|
if len(edits_audio) == len(source_files):
|
|
55
58
|
the_zip.append(source_files)
|
|
56
59
|
elif len(source_files) == 1:
|
|
57
|
-
the_zip.append(
|
|
60
|
+
the_zip.append(source_files * len(edits_audio))
|
|
58
61
|
else:
|
|
59
|
-
the_zip.append([None] * len(edits_audio)
|
|
60
|
-
|
|
62
|
+
the_zip.append([None] * len(edits_audio))
|
|
63
|
+
|
|
61
64
|
# attach trans name to last event
|
|
62
65
|
try:
|
|
63
66
|
trans_statement = self._trans_name_statements()[0]
|
|
@@ -65,23 +68,28 @@ class Event:
|
|
|
65
68
|
trans_names.append(trans_statement)
|
|
66
69
|
the_zip.append(trans_names)
|
|
67
70
|
except IndexError:
|
|
68
|
-
the_zip.append([None] * len(edits_audio)
|
|
69
|
-
|
|
70
|
-
return [
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
the_zip.append([None] * len(edits_audio))
|
|
72
|
+
|
|
73
|
+
return [Edit(edit_statement=e1[0],
|
|
74
|
+
audio_ext_statement=e1[1],
|
|
75
|
+
clip_name_statement=n1,
|
|
76
|
+
source_file_statement=s1,
|
|
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())
|
|
81
|
+
for (e1, n1, s1, u1) in zip(*the_zip)]
|
|
82
|
+
|
|
76
83
|
@property
|
|
77
|
-
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None,
|
|
84
|
+
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None,
|
|
85
|
+
None]:
|
|
78
86
|
"""
|
|
79
87
|
A generator for all the unrecognized statements in the event.
|
|
80
88
|
"""
|
|
81
89
|
for s in self.statements:
|
|
82
90
|
if type(s) is StmtUnrecognized:
|
|
83
91
|
yield s
|
|
84
|
-
|
|
92
|
+
|
|
85
93
|
def _trans_name_statements(self) -> List[StmtEffectsName]:
|
|
86
94
|
return [s for s in self.statements if type(s) is StmtEffectsName]
|
|
87
95
|
|
|
@@ -90,15 +98,25 @@ class Event:
|
|
|
90
98
|
|
|
91
99
|
def _clip_name_statements(self) -> List[StmtClipName]:
|
|
92
100
|
return [s for s in self.statements if type(s) is StmtClipName]
|
|
93
|
-
|
|
101
|
+
|
|
94
102
|
def _source_file_statements(self) -> List[StmtSourceFile]:
|
|
95
103
|
return [s for s in self.statements if type(s) is StmtSourceFile]
|
|
96
|
-
|
|
97
|
-
def _statements_with_audio_ext(self) -> Generator[
|
|
104
|
+
|
|
105
|
+
def _statements_with_audio_ext(self) -> Generator[
|
|
106
|
+
Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]:
|
|
98
107
|
for (s1, s2) in zip(self.statements, self.statements[1:]):
|
|
99
108
|
if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
|
|
100
|
-
yield (s1,s2)
|
|
109
|
+
yield (s1, s2)
|
|
101
110
|
elif type(s1) is StmtEvent:
|
|
102
111
|
yield (s1, None)
|
|
103
112
|
|
|
104
|
-
|
|
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)
|
pycmx/parse_cmx_events.py
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
# pycmx
|
|
2
|
-
# (c) 2018 Jamie Hardt
|
|
2
|
+
# (c) 2018-2025 Jamie Hardt
|
|
3
3
|
|
|
4
|
-
|
|
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
9
|
|
|
11
|
-
def parse_cmx3600(f: TextIO):
|
|
10
|
+
def parse_cmx3600(f: TextIO) -> EditList:
|
|
12
11
|
"""
|
|
13
12
|
Parse a CMX 3600 EDL.
|
|
14
13
|
|
|
15
|
-
:param TextIO f: a file-like object,
|
|
14
|
+
:param TextIO f: a file-like object, an opened CMX 3600 .EDL file.
|
|
16
15
|
:returns: An :class:`pycmx.edit_list.EditList`.
|
|
17
16
|
"""
|
|
18
17
|
statements = parse_cmx3600_statements(f)
|
|
19
18
|
return EditList(statements)
|
|
20
|
-
|
pycmx/parse_cmx_statements.py
CHANGED
|
@@ -1,169 +1,223 @@
|
|
|
1
1
|
# pycmx
|
|
2
|
-
# (c) 2018 Jamie Hardt
|
|
2
|
+
# (c) 2018-2025 Jamie Hardt
|
|
3
3
|
|
|
4
4
|
import re
|
|
5
|
-
import sys
|
|
6
|
-
from collections import namedtuple
|
|
7
|
-
from itertools import count
|
|
8
5
|
from typing import TextIO, List
|
|
9
6
|
|
|
7
|
+
from .cdl import AscSopComponents, Rgb
|
|
10
8
|
|
|
9
|
+
from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
|
|
10
|
+
StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
|
|
11
|
+
StmtAudioExt, StmtClipName, StmtEffectsName,
|
|
12
|
+
StmtEvent, StmtSourceFile, StmtSplitEdit)
|
|
11
13
|
from .util import collimate
|
|
12
14
|
|
|
13
|
-
StmtTitle = namedtuple("Title",["title","line_number"])
|
|
14
|
-
StmtFCM = namedtuple("FCM",["drop","line_number"])
|
|
15
|
-
StmtEvent = namedtuple("Event",["event","source","channels","trans",\
|
|
16
|
-
"trans_op","source_in","source_out","record_in","record_out","format","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("MotionMemory",["source","fps"]) # FIXME needs more fields
|
|
25
|
-
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
|
|
26
|
-
|
|
27
15
|
|
|
28
16
|
def parse_cmx3600_statements(file: TextIO) -> List[object]:
|
|
29
17
|
"""
|
|
30
18
|
Return a list of every statement in the file argument.
|
|
31
19
|
"""
|
|
32
20
|
lines = file.readlines()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
def _edl_column_widths(event_field_length, source_field_length):
|
|
38
|
-
return [event_field_length,2, source_field_length,1,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _edl_m2_column_widths():
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def _parse_cmx3600_line(line, line_number):
|
|
54
|
-
long_event_num_p = re.compile("^[0-9]{6} ")
|
|
55
|
-
short_event_num_p = re.compile("^[0-9]{3} ")
|
|
56
|
-
|
|
57
|
-
if isinstance(line,str):
|
|
58
|
-
if line.startswith("TITLE:"):
|
|
59
|
-
return _parse_title(line,line_number)
|
|
60
|
-
elif line.startswith("FCM:"):
|
|
61
|
-
return _parse_fcm(line, line_number)
|
|
62
|
-
elif long_event_num_p.match(line) != None:
|
|
63
|
-
length_file_128 = sum(_edl_column_widths(6,128))
|
|
64
|
-
if len(line) < length_file_128:
|
|
65
|
-
return _parse_long_standard_form(line, 32, line_number)
|
|
66
|
-
else:
|
|
67
|
-
return _parse_long_standard_form(line, 128, line_number)
|
|
68
|
-
elif short_event_num_p.match(line) != None:
|
|
69
|
-
return _parse_standard_form(line, line_number)
|
|
70
|
-
elif line.startswith("AUD"):
|
|
71
|
-
return _parse_extended_audio_channels(line,line_number)
|
|
72
|
-
elif line.startswith("*"):
|
|
73
|
-
return _parse_remark( line[1:].strip(), line_number)
|
|
74
|
-
elif line.startswith(">>> SOURCE"):
|
|
75
|
-
return _parse_source_umid_statement(line, line_number)
|
|
76
|
-
elif line.startswith("EFFECTS NAME IS"):
|
|
77
|
-
return _parse_effects_name(line, line_number)
|
|
78
|
-
elif line.startswith("SPLIT:"):
|
|
79
|
-
return _parse_split(line, line_number)
|
|
80
|
-
elif line.startswith("M2"):
|
|
81
|
-
return _parse_motion_memory(line, line_number)
|
|
82
|
-
else:
|
|
83
|
-
return _parse_unrecognized(line, line_number)
|
|
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]
|
|
84
39
|
|
|
85
|
-
|
|
86
|
-
def
|
|
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:
|
|
87
78
|
title = line[6:].strip()
|
|
88
|
-
return StmtTitle(title=title,line_number=line_num)
|
|
79
|
+
return StmtTitle(title=title, line_number=line_num)
|
|
80
|
+
|
|
89
81
|
|
|
90
|
-
def _parse_fcm(line, line_num):
|
|
82
|
+
def _parse_fcm(line, line_num) -> StmtFCM:
|
|
91
83
|
val = line[4:].strip()
|
|
92
84
|
if val == "DROP FRAME":
|
|
93
|
-
return StmtFCM(drop=
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return _parse_columns_for_standard_form(line, 6, source_field_length, line_number)
|
|
99
|
-
|
|
100
|
-
def _parse_standard_form(line, line_number):
|
|
101
|
-
return _parse_columns_for_standard_form(line, 3, 8, line_number)
|
|
102
|
-
|
|
85
|
+
return StmtFCM(drop=True, line_number=line_num)
|
|
86
|
+
|
|
87
|
+
return StmtFCM(drop=False, line_number=line_num)
|
|
88
|
+
|
|
89
|
+
|
|
103
90
|
def _parse_extended_audio_channels(line, line_number):
|
|
104
91
|
content = line.strip()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
return StmtAudioExt(audio3=True, audio4=True, line_number=line_number)
|
|
92
|
+
audio3 = "3" in content
|
|
93
|
+
audio4 = "4" in content
|
|
94
|
+
|
|
95
|
+
if audio3 or audio4:
|
|
96
|
+
return StmtAudioExt(audio3, audio4, line_number)
|
|
111
97
|
else:
|
|
112
|
-
return StmtUnrecognized(
|
|
113
|
-
|
|
98
|
+
return StmtUnrecognized(line, line_number)
|
|
99
|
+
|
|
100
|
+
|
|
114
101
|
def _parse_remark(line, line_number) -> object:
|
|
115
102
|
if line.startswith("FROM CLIP NAME:"):
|
|
116
|
-
return StmtClipName(name=line[15:].strip()
|
|
103
|
+
return StmtClipName(name=line[15:].strip(), affect="from",
|
|
104
|
+
line_number=line_number)
|
|
117
105
|
elif line.startswith("TO CLIP NAME:"):
|
|
118
|
-
return StmtClipName(name=line[13:].strip(), affect="to",
|
|
106
|
+
return StmtClipName(name=line[13:].strip(), affect="to",
|
|
107
|
+
line_number=line_number)
|
|
119
108
|
elif line.startswith("SOURCE FILE:"):
|
|
120
|
-
return StmtSourceFile(filename=line[12:].strip()
|
|
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
|
+
|
|
121
171
|
else:
|
|
122
172
|
return StmtRemark(text=line, line_number=line_number)
|
|
123
173
|
|
|
174
|
+
|
|
124
175
|
def _parse_effects_name(line, line_number) -> StmtEffectsName:
|
|
125
176
|
name = line[16:].strip()
|
|
126
177
|
return StmtEffectsName(name=name, line_number=line_number)
|
|
127
178
|
|
|
128
|
-
def _parse_split(line, line_number):
|
|
129
|
-
split_type = line[10:21]
|
|
130
|
-
is_video = False
|
|
131
|
-
if split_type.startswith("VIDEO"):
|
|
132
|
-
is_video = True
|
|
133
179
|
|
|
134
|
-
|
|
135
|
-
|
|
180
|
+
def _parse_split(line: str, line_number):
|
|
181
|
+
split_type = line[10:21]
|
|
182
|
+
is_video = split_type.startswith("VIDEO")
|
|
136
183
|
|
|
184
|
+
split_delay = line[24:35]
|
|
185
|
+
return StmtSplitEdit(video=is_video, delay=split_delay,
|
|
186
|
+
line_number=line_number)
|
|
137
187
|
|
|
138
|
-
def _parse_motion_memory(line, line_number):
|
|
139
|
-
return StmtMotionMemory(source = "", fps="")
|
|
140
188
|
|
|
189
|
+
# def _parse_motion_memory(line, line_number):
|
|
190
|
+
# return StmtMotionMemory(source="", fps="")
|
|
191
|
+
#
|
|
141
192
|
|
|
142
193
|
def _parse_unrecognized(line, line_number):
|
|
143
194
|
return StmtUnrecognized(content=line, line_number=line_number)
|
|
144
195
|
|
|
145
|
-
|
|
196
|
+
|
|
197
|
+
def _parse_columns_for_standard_form(line: str, event_field_length: int,
|
|
198
|
+
source_field_length: int,
|
|
199
|
+
line_number: int):
|
|
146
200
|
col_widths = _edl_column_widths(event_field_length, source_field_length)
|
|
147
|
-
|
|
201
|
+
|
|
148
202
|
if sum(col_widths) > len(line):
|
|
149
203
|
return StmtUnrecognized(content=line, line_number=line_number)
|
|
150
|
-
|
|
151
|
-
column_strings = collimate(line,col_widths)
|
|
152
|
-
|
|
153
|
-
return StmtEvent(event=column_strings[0],
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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(),
|
|
157
211
|
trans_op=column_strings[8].strip(),
|
|
158
212
|
source_in=column_strings[10].strip(),
|
|
159
213
|
source_out=column_strings[12].strip(),
|
|
160
214
|
record_in=column_strings[14].strip(),
|
|
161
215
|
record_out=column_strings[16].strip(),
|
|
162
|
-
|
|
163
|
-
|
|
216
|
+
line_number=line_number,
|
|
217
|
+
source_field_size=source_field_length)
|
|
164
218
|
|
|
165
219
|
|
|
166
220
|
def _parse_source_umid_statement(line, line_number):
|
|
167
|
-
trimmed = line[3:].strip()
|
|
168
|
-
return StmtSourceUMID(name=None, umid=None, line_number=line_number)
|
|
169
|
-
|
|
221
|
+
# trimmed = line[3:].strip()
|
|
222
|
+
# return StmtSourceUMID(name=None, umid=None, line_number=line_number)
|
|
223
|
+
...
|
pycmx/statements.py
ADDED
|
@@ -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
|
pycmx/transition.py
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
|
|
4
4
|
from typing import Optional
|
|
5
5
|
|
|
6
|
+
|
|
6
7
|
class Transition:
|
|
7
8
|
"""
|
|
8
9
|
A CMX transition: a wipe, dissolve or cut.
|
|
9
10
|
"""
|
|
10
|
-
|
|
11
|
+
|
|
11
12
|
Cut = "C"
|
|
12
13
|
Dissolve = "D"
|
|
13
14
|
Wipe = "W"
|
|
@@ -41,7 +42,7 @@ class Transition:
|
|
|
41
42
|
@property
|
|
42
43
|
def cut(self) -> bool:
|
|
43
44
|
"`True` if this transition is a cut."
|
|
44
|
-
return self.transition == 'C'
|
|
45
|
+
return self.transition == 'C'
|
|
45
46
|
|
|
46
47
|
@property
|
|
47
48
|
def dissolve(self) -> bool:
|
|
@@ -56,7 +57,7 @@ class Transition:
|
|
|
56
57
|
@property
|
|
57
58
|
def effect_duration(self) -> int:
|
|
58
59
|
"""The duration of this transition, in frames of the record target.
|
|
59
|
-
|
|
60
|
+
|
|
60
61
|
In the event of a key event, this is the duration of the fade in.
|
|
61
62
|
"""
|
|
62
63
|
return int(self.operand)
|
|
@@ -86,5 +87,3 @@ class Transition:
|
|
|
86
87
|
the key foreground and replaced with the key background.
|
|
87
88
|
"""
|
|
88
89
|
return self.transition == Transition.KeyOut
|
|
89
|
-
|
|
90
|
-
|
pycmx/util.py
CHANGED
|
@@ -1,31 +1,30 @@
|
|
|
1
1
|
# pycmx
|
|
2
|
-
# (c) 2018 Jamie Hardt
|
|
2
|
+
# (c) 2018-2025 Jamie Hardt
|
|
3
3
|
|
|
4
4
|
# Utility functions
|
|
5
5
|
|
|
6
|
-
def collimate(a_string, column_widths):
|
|
6
|
+
def collimate(a_string, column_widths):
|
|
7
7
|
"""
|
|
8
|
-
Split a list-type thing, like a string, into slices that are column_widths
|
|
8
|
+
Split a list-type thing, like a string, into slices that are column_widths
|
|
9
9
|
length.
|
|
10
|
-
|
|
10
|
+
|
|
11
11
|
>>> collimate("a b1 c2345",[2,3,3,2])
|
|
12
12
|
['a ','b1 ','c23','45']
|
|
13
13
|
|
|
14
14
|
Args:
|
|
15
15
|
a_string: The string to split. This parameter can actually be anything
|
|
16
16
|
sliceable.
|
|
17
|
-
column_widths: A list of integers, each one is the length of a column.
|
|
17
|
+
column_widths: A list of integers, each one is the length of a column.
|
|
18
18
|
|
|
19
19
|
Returns:
|
|
20
|
-
A list of slices. The len() of the returned list will *always* equal
|
|
21
|
-
len(:column_widths:).
|
|
20
|
+
A list of slices. The len() of the returned list will *always* equal
|
|
21
|
+
len(:column_widths:).
|
|
22
22
|
"""
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
if len(column_widths) == 0:
|
|
25
25
|
return []
|
|
26
|
-
|
|
26
|
+
|
|
27
27
|
width = column_widths[0]
|
|
28
28
|
element = a_string[:width]
|
|
29
29
|
rest = a_string[width:]
|
|
30
|
-
return [element] + collimate(rest, column_widths[1:])
|
|
31
|
-
|
|
30
|
+
return [element] + collimate(rest, column_widths[1:])
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: pycmx
|
|
3
|
-
Version: 1.
|
|
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
|
|
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"
|
|
43
|
-
|
|
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
|
-
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pycmx/__init__.py,sha256=u_VU3f3WltGoZzg_iZBLQZUtFqGFVZgBX3trqM7v3D4,365
|
|
2
|
+
pycmx/cdl.py,sha256=zONbFbvyETNs4u4mi7V-vePhO5VmQ_UE7wthbX0kigo,1041
|
|
3
|
+
pycmx/channel_map.py,sha256=_PIzTudMebzUfMWKNTA3i1egvmp4EsXqLPamCzjIUjk,3174
|
|
4
|
+
pycmx/edit.py,sha256=KmGbAd-wDbxFLmZKYgl0WW-qpYZbkyXeC5ViFiMvk8k,6446
|
|
5
|
+
pycmx/edit_list.py,sha256=sqmFkPG53k4-gQ9RMuSo2UjaR3K7o3akSM3sClTBWwc,3286
|
|
6
|
+
pycmx/event.py,sha256=cJ0XOiJ1upX_Wbo9Vo48FwVwSQWIQ0WSjn05Sp1mkDg,4524
|
|
7
|
+
pycmx/parse_cmx_events.py,sha256=TdM1Gjwi6PD3Bm0aFNDevPlSzZP2aP-uRsCjFmjDAwM,444
|
|
8
|
+
pycmx/parse_cmx_statements.py,sha256=wbheiju57CxfzYsYslykXzLMH-sP_WTMOzzmYZhpd18,7996
|
|
9
|
+
pycmx/statements.py,sha256=J6QRBQzIZ4q6so5-D3-t_2IsahMW6i3xCTPh7WqldgA,1598
|
|
10
|
+
pycmx/transition.py,sha256=IlVz2X3Z7CttJccqzzjhPPUNY2Pe8ZmVMIBnCrVLIG0,2364
|
|
11
|
+
pycmx/util.py,sha256=K1FVj2qptBtX-5RgBivc0RZl3JGdxAQ8x8WNhlBhY2Q,787
|
|
12
|
+
pycmx-1.4.0.dist-info/METADATA,sha256=YF0tm4MxDKn96CTFANOuglsgy_crhmWlHEG0pF7-4v4,4360
|
|
13
|
+
pycmx-1.4.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
14
|
+
pycmx-1.4.0.dist-info/licenses/LICENSE,sha256=JS087ZFloGaV7LHygeUaXmT-fGLx0VF30W62wDTLRCc,1056
|
|
15
|
+
pycmx-1.4.0.dist-info/RECORD,,
|
pycmx-1.2.3.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
pycmx/__init__.py,sha256=yS4sYKFfw3ScPaCr0_g0qSmhhZRxOLkgwR5kQ9nxYnc,388
|
|
2
|
-
pycmx/channel_map.py,sha256=M7P3b0S2cBrmQjfSP5sixFxGv_uBjvie0f9PVdy9cMI,3248
|
|
3
|
-
pycmx/edit.py,sha256=sipPmT2W0hPoYyRyZzwohohjO1fqwEU_mxsQheZxWC0,3775
|
|
4
|
-
pycmx/edit_list.py,sha256=zZaOTEPgubHxetQF01BhMf6d097jXjvohYrMXtbP1a4,3172
|
|
5
|
-
pycmx/event.py,sha256=-wlOdOwOVVk_4Wus0D8PVxRiEPBv1XZrRiQwkoRIOY4,3781
|
|
6
|
-
pycmx/parse_cmx_events.py,sha256=_KeowR6jm4LlFWtejgZ0SL5jX-sMu8GKgx7ferUuRbE,472
|
|
7
|
-
pycmx/parse_cmx_statements.py,sha256=UqS-p1kAFR8cp1pgiiBZLlyIhF-8hOWykYIS6-JuEBg,6755
|
|
8
|
-
pycmx/transition.py,sha256=fAbWMqBZmZIcpZUobTlYuTd4WxercHMik_zqp7yK4mA,2378
|
|
9
|
-
pycmx/util.py,sha256=alnJaV9vqH-KGtExrWjBiYMTiTILDXnF8Ju0sXWa69A,801
|
|
10
|
-
pycmx-1.2.3.dist-info/LICENSE,sha256=JS087ZFloGaV7LHygeUaXmT-fGLx0VF30W62wDTLRCc,1056
|
|
11
|
-
pycmx-1.2.3.dist-info/METADATA,sha256=64OBJd-tmpJl9dsgHarsEbLAOMa4mqHZ6PFTaZsTYQI,3999
|
|
12
|
-
pycmx-1.2.3.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
|
|
13
|
-
pycmx-1.2.3.dist-info/RECORD,,
|
|
File without changes
|