mirror of
https://github.com/iluvcapra/pycmx.git
synced 2025-12-31 08:50:54 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d14914ea | ||
|
|
f44d5c470c | ||
|
|
ca873af772 | ||
|
|
ab40ba1fa0 | ||
|
|
782b9f7425 | ||
|
|
483efdcc32 | ||
|
|
6867f9ac4a | ||
|
|
24272569e3 | ||
|
|
16afb8fc64 | ||
|
|
d1e3eb85d3 | ||
|
|
8d3bef2c09 | ||
|
|
e0b7025fff | ||
|
|
fbe9e9eeb9 | ||
|
|
168fd16473 | ||
|
|
e4b6036ab7 | ||
|
|
ce3d8088a1 | ||
|
|
9f41758b37 | ||
|
|
07407baf96 | ||
|
|
aa309a4458 | ||
|
|
2b8dd4c1c9 | ||
|
|
387158b07c | ||
|
|
741c9d95e8 | ||
|
|
53764900ba | ||
|
|
66791081be | ||
|
|
5e49c19ac2 | ||
|
|
4593729e3a | ||
|
|
703ba1140a | ||
|
|
0f06c4de5c | ||
|
|
920af8a86d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,3 +7,6 @@
|
|||||||
# Python egg metadata, regenerated from source files by setuptools.
|
# Python egg metadata, regenerated from source files by setuptools.
|
||||||
/*.egg-info
|
/*.egg-info
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
# Vim Swapfiles
|
||||||
|
*.swp
|
||||||
|
|||||||
83
README.md
83
README.md
@@ -8,53 +8,62 @@ The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and it
|
|||||||
|
|
||||||
* The major variations of the CMX3600, the standard, "File32" and "File128"
|
* The major variations of the CMX3600, the standard, "File32" and "File128"
|
||||||
formats are automatically detected and properly read.
|
formats are automatically detected and properly read.
|
||||||
|
* Preserves relationship between events and individual edits/clips.
|
||||||
* Remark or comment fields with common recognized forms are read and
|
* Remark or comment fields with common recognized forms are read and
|
||||||
available to the client, including clip name and source file data.
|
available to the client, including clip name and source file data.
|
||||||
|
* Symbolically decodes transitions and audio channels.
|
||||||
|
* Does not parse or validate timecodes, does not enforce framerates, does not
|
||||||
|
parameterize timecode or framerates in any way. This makes the parser more
|
||||||
|
tolerant of EDLs with mixed rates.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
>>> import pycmx
|
>>> import pycmx
|
||||||
>>> events = pycmx.parse_cmx3600("INS4_R1_010417.edl")
|
>>> edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
|
||||||
>>> print(events[5:8])
|
>>> edl.title
|
||||||
[CmxEvent(title='INS4_R1_010417', number='000006',
|
'DC7 R1_v8.2'
|
||||||
clip_name='V1A-6A', source_name='A192C008_160909_R1BY',
|
>>> events = list( edl.events )
|
||||||
channels=CmxChannelMap(v=True, audio_channels=set()),
|
# the event list is a generator
|
||||||
source_start='19:26:38:13', source_finish='19:27:12:03',
|
>>> len(events)
|
||||||
record_start='01:00:57:15', record_finish='01:01:31:05',
|
120
|
||||||
fcm_drop=False),
|
>>> events[43].number
|
||||||
CmxEvent(title='INS4_R1_010417', number='000007',
|
'044'
|
||||||
clip_name='1-4A', source_name='A188C004_160908_R1BY',
|
>>> events[43].edits[0].source_in
|
||||||
channels=CmxChannelMap(v=True, audio_channels=set()),
|
'00:00:00:00'
|
||||||
source_start='19:29:48:01', source_finish='19:30:01:00',
|
>>> events[43].edits[0].transition.cut
|
||||||
record_start='01:01:31:05', record_finish='01:01:44:04',
|
True
|
||||||
fcm_drop=False),
|
>>> events[43].edits[0].record_out
|
||||||
CmxEvent(title='INS4_R1_010417', number='000008',
|
'01:10:21:10'
|
||||||
clip_name='2G-3', source_name='A056C007_160819_R1BY',
|
|
||||||
channels=CmxChannelMap(v=True, audio_channels=set()),
|
# events contain multiple
|
||||||
source_start='19:56:27:14', source_finish='19:56:41:00',
|
# edits to preserve A/B dissolves
|
||||||
record_start='01:01:44:04', record_finish='01:01:57:14',
|
# and key backgrounds
|
||||||
fcm_drop=False)]
|
|
||||||
|
>>> events[41].edits[0].transition.dissolve
|
||||||
|
False
|
||||||
|
>>> events[41].edits[1].transition.dissolve
|
||||||
|
True
|
||||||
|
>>> events[41].edits[0].clip_name
|
||||||
|
'TC R1 V1.2 TEMP1 DX M.WAV'
|
||||||
|
>>> events[41].edits[1].clip_name
|
||||||
|
'TC R1 V6 TEMP2 M DX.WAV'
|
||||||
|
|
||||||
|
# parsed channel maps are also
|
||||||
|
# available to the client
|
||||||
|
>>> events[2].edits[0].channels.get_audio_channel(7)
|
||||||
|
True
|
||||||
|
>>> events[2].edits[0].channels.get_audio_channel(6)
|
||||||
|
False
|
||||||
|
>>> for c in events[2].edits[0].channels.channels:
|
||||||
|
... print(f"Audio channel {c} is present")
|
||||||
|
...
|
||||||
|
Audio channel 7 is present
|
||||||
|
>>> events[2].edits[0].channels.video
|
||||||
|
False
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Known Issues/Roadmap
|
|
||||||
|
|
||||||
To be addressed:
|
|
||||||
* Does not decode transitions.
|
|
||||||
* Does not decode "M2" speed changes.
|
|
||||||
* Does not decode repair notes, audio notes or other Avid-specific notes.
|
|
||||||
* Does not decode Avid marker list.
|
|
||||||
|
|
||||||
May not be addressed:
|
|
||||||
|
|
||||||
* Does not parse source list at end of EDL.
|
|
||||||
|
|
||||||
Probably beyond the scope of this module:
|
|
||||||
* Does not parse timecode entries.
|
|
||||||
* Does not parse color correction notes. For this functionality we refer you to [pycdl](https://pypi.org/project/pycdl/) or [cdl-convert](https://pypi.org/project/cdl-convert/).
|
|
||||||
|
|
||||||
## Should I Use This?
|
## Should I Use This?
|
||||||
|
|
||||||
At this time, this is (at best) alpha software and the interface will be
|
At this time, this is (at best) alpha software and the interface will be
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# pycmx init
|
# pycmx init
|
||||||
|
|
||||||
from .parse_cmx import parse_cmx3600
|
from .parse_cmx_events import parse_cmx3600, Transition, Event, Edit
|
||||||
|
from . import parse_cmx_events
|
||||||
|
|
||||||
__version__ = '0.5'
|
__version__ = '0.6'
|
||||||
|
|||||||
92
pycmx/channel_map.py
Normal file
92
pycmx/channel_map.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from re import (compile, match)
|
||||||
|
|
||||||
|
class ChannelMap:
|
||||||
|
|
||||||
|
"""
|
||||||
|
Represents a set of all the channels to which an event applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
chan_map = { "V" : (True, False, False),
|
||||||
|
"A" : (False, True, False),
|
||||||
|
"A2" : (False, False, True),
|
||||||
|
"AA" : (False, True, True),
|
||||||
|
"B" : (True, True, False),
|
||||||
|
"AA/V" : (True, True, True),
|
||||||
|
"A2/V" : (True, False, True)
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, v=False, audio_channels=set()):
|
||||||
|
self._audio_channel_set = audio_channels
|
||||||
|
self.v = v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video(self):
|
||||||
|
'True if video is included'
|
||||||
|
return self.v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self):
|
||||||
|
'A generator for each audio channel'
|
||||||
|
for c in self._audio_channel_set:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a1(self):
|
||||||
|
return self.get_audio_channel(1)
|
||||||
|
|
||||||
|
@a1.setter
|
||||||
|
def a1(self,val):
|
||||||
|
self.set_audio_channel(1,val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a2(self):
|
||||||
|
return self.get_audio_channel(2)
|
||||||
|
|
||||||
|
@a2.setter
|
||||||
|
def a2(self,val):
|
||||||
|
self.set_audio_channel(2,val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a3(self):
|
||||||
|
return self.get_audio_channel(3)
|
||||||
|
|
||||||
|
@a3.setter
|
||||||
|
def a3(self,val):
|
||||||
|
self.set_audio_channel(3,val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a4(self):
|
||||||
|
return self.get_audio_channel(4)
|
||||||
|
|
||||||
|
@a4.setter
|
||||||
|
def a4(self,val):
|
||||||
|
self.set_audio_channel(4,val)
|
||||||
|
|
||||||
|
def get_audio_channel(self,chan_num):
|
||||||
|
return (chan_num in self._audio_channel_set)
|
||||||
|
|
||||||
|
def set_audio_channel(self,chan_num,enabled):
|
||||||
|
if enabled:
|
||||||
|
self._audio_channel_set.add(chan_num)
|
||||||
|
elif self.get_audio_channel(chan_num):
|
||||||
|
self._audio_channel_set.remove(chan_num)
|
||||||
|
|
||||||
|
def append_event(self, event_str):
|
||||||
|
alt_channel_re = compile('^A(\d+)')
|
||||||
|
if event_str in self.chan_map:
|
||||||
|
channels = self.chan_map[event_str]
|
||||||
|
self.v = channels[0]
|
||||||
|
self.a1 = channels[1]
|
||||||
|
self.a2 = channels[2]
|
||||||
|
else:
|
||||||
|
matchresult = match(alt_channel_re, event_str)
|
||||||
|
if matchresult:
|
||||||
|
self.set_audio_channel(int( matchresult.group(1)), True )
|
||||||
|
|
||||||
|
def append_sxt(self, audio_ext):
|
||||||
|
self.a3 = ext.audio3
|
||||||
|
self.a4 = ext.audio4
|
||||||
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
class CmxEvent:
|
|
||||||
def __init__(self,title,number,clip_name,source_name,channels,
|
|
||||||
transition,source_start,source_finish,
|
|
||||||
record_start, record_finish, fcm_drop, remarks = [] ,
|
|
||||||
unrecognized = [], line_number = None):
|
|
||||||
self.title = title
|
|
||||||
self.number = number
|
|
||||||
self.clip_name = clip_name
|
|
||||||
self.source_name = source_name
|
|
||||||
self.channels = channels
|
|
||||||
self.transition = transition
|
|
||||||
self.source_start = source_start
|
|
||||||
self.source_finish = source_finish
|
|
||||||
self.record_start = record_start
|
|
||||||
self.record_finish = record_finish
|
|
||||||
self.fcm_drop = fcm_drop
|
|
||||||
self.remarks = remarks
|
|
||||||
self.unrecgonized = unrecognized
|
|
||||||
self.black = (source_name == 'BL')
|
|
||||||
self.aux_source = (source_name == 'AX')
|
|
||||||
self.line_number = line_number
|
|
||||||
|
|
||||||
|
|
||||||
def can_accept(self):
|
|
||||||
return {'AudioExt','Remark','SourceFile','ClipName','EffectsName'}
|
|
||||||
|
|
||||||
def accept_statement(self, statement):
|
|
||||||
statement_type = type(statement).__name__
|
|
||||||
if statement_type == 'AudioExt':
|
|
||||||
self.channels.appendExt(statement)
|
|
||||||
elif statement_type == 'Remark':
|
|
||||||
self.remarks.append(statement.text)
|
|
||||||
elif statement_type == 'SourceFile':
|
|
||||||
self.source_name = statement.filename
|
|
||||||
elif statement_type == 'ClipName':
|
|
||||||
self.clip_name = statement.name
|
|
||||||
elif statement_type == 'EffectsName':
|
|
||||||
self.transition.name = statement.name
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"""CmxEvent(title={self.title.__repr__()},number={self.number.__repr__()},\
|
|
||||||
clip_name={self.clip_name.__repr__()},source_name={self.source_name.__repr__()},\
|
|
||||||
channels={self.channels.__repr__()},transition={self.transition.__repr__()},\
|
|
||||||
source_start={self.source_start.__repr__()},source_finish={self.source_finish.__repr__()},\
|
|
||||||
record_start={self.source_start.__repr__()},record_finish={self.record_finish.__repr__()},\
|
|
||||||
fcm_drop={self.fcm_drop.__repr__()},remarks={self.remarks.__repr__()},line_number={self.line_number.__repr__()})"""
|
|
||||||
|
|
||||||
|
|
||||||
class CmxTransition:
|
|
||||||
def __init__(self, transition, operand):
|
|
||||||
self.transition = transition
|
|
||||||
self.operand = operand
|
|
||||||
self.name = ''
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cut(self):
|
|
||||||
return self.transition == 'C'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dissolve(self):
|
|
||||||
return self.transition == 'D'
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wipe(self):
|
|
||||||
return self.transition.startswith('W')
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def effect_duration(self):
|
|
||||||
return int(self.operand)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wipe_number(self):
|
|
||||||
if self.wipe:
|
|
||||||
return int(self.transition[1:])
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_background(self):
|
|
||||||
return self.transition == 'KB'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_foreground(self):
|
|
||||||
return self.transition == 'K'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_out(self):
|
|
||||||
return self.transition == 'KO'
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"""CmxTransition(transition={self.transition.__repr__()},operand={self.operand.__repr__()})"""
|
|
||||||
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# pycmx
|
|
||||||
# (c) 2018 Jamie Hardt
|
|
||||||
|
|
||||||
from .parse_cmx_statements import parse_cmx3600_statements
|
|
||||||
from .cmx_event import CmxEvent, CmxTransition
|
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from re import compile, match
|
|
||||||
|
|
||||||
class NamedTupleParser:
|
|
||||||
|
|
||||||
def __init__(self, tuple_list):
|
|
||||||
self.tokens = tuple_list
|
|
||||||
self.current_token = None
|
|
||||||
|
|
||||||
def peek(self):
|
|
||||||
return self.tokens[0]
|
|
||||||
|
|
||||||
def at_end(self):
|
|
||||||
return len(self.tokens) == 0
|
|
||||||
|
|
||||||
def next_token(self):
|
|
||||||
self.current_token = self.peek()
|
|
||||||
self.tokens = self.tokens[1:]
|
|
||||||
|
|
||||||
def accept(self, type_name):
|
|
||||||
if self.at_end():
|
|
||||||
return False
|
|
||||||
elif (type(self.peek()).__name__ == type_name ):
|
|
||||||
self.next_token()
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def expect(self, type_name):
|
|
||||||
assert( self.accept(type_name) )
|
|
||||||
|
|
||||||
|
|
||||||
class CmxChannelMap:
|
|
||||||
"""
|
|
||||||
Represents a set of all the channels to which an event applies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
chan_map = { "V" : (True, False, False),
|
|
||||||
"A" : (False, True, False),
|
|
||||||
"A2" : (False, False, True),
|
|
||||||
"AA" : (False, True, True),
|
|
||||||
"B" : (True, True, False),
|
|
||||||
"AA/V" : (True, True, True),
|
|
||||||
"A2/V" : (True, False, True)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, v=False, audio_channels=set()):
|
|
||||||
self._audio_channel_set = audio_channels
|
|
||||||
self.v = v
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a1(self):
|
|
||||||
return self.get_audio_channel(1)
|
|
||||||
|
|
||||||
@a1.setter
|
|
||||||
def a1(self,val):
|
|
||||||
self.set_audio_channel(1,val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a2(self):
|
|
||||||
return self.get_audio_channel(2)
|
|
||||||
|
|
||||||
@a2.setter
|
|
||||||
def a2(self,val):
|
|
||||||
self.set_audio_channel(2,val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a3(self):
|
|
||||||
return self.get_audio_channel(3)
|
|
||||||
|
|
||||||
@a3.setter
|
|
||||||
def a3(self,val):
|
|
||||||
self.set_audio_channel(3,val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a4(self):
|
|
||||||
return self.get_audio_channel(4)
|
|
||||||
|
|
||||||
@a4.setter
|
|
||||||
def a4(self,val):
|
|
||||||
self.set_audio_channel(4,val)
|
|
||||||
|
|
||||||
|
|
||||||
def get_audio_channel(self,chan_num):
|
|
||||||
return (chan_num in self._audio_channel_set)
|
|
||||||
|
|
||||||
def set_audio_channel(self,chan_num,enabled):
|
|
||||||
if enabled:
|
|
||||||
self._audio_channel_set.add(chan_num)
|
|
||||||
elif self.get_audio_channel(chan_num):
|
|
||||||
self._audio_channel_set.remove(chan_num)
|
|
||||||
|
|
||||||
|
|
||||||
def appendEvent(self, event_str):
|
|
||||||
alt_channel_re = compile('^A(\d+)')
|
|
||||||
if event_str in self.chan_map:
|
|
||||||
channels = self.chan_map[event_str]
|
|
||||||
self.v = channels[0]
|
|
||||||
self.a1 = channels[1]
|
|
||||||
self.a2 = channels[2]
|
|
||||||
else:
|
|
||||||
matchresult = match(alt_channel_re, event_str)
|
|
||||||
if matchresult:
|
|
||||||
self.set_audio_channel(int( matchresult.group(1)), True )
|
|
||||||
|
|
||||||
def appendExt(self, audio_ext):
|
|
||||||
self.a3 = ext.audio3
|
|
||||||
self.a4 = ext.audio4
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"CmxChannelMap(v={self.v.__repr__()}, audio_channels={self._audio_channel_set.__repr__()})"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cmx3600(file):
|
|
||||||
"""Accepts the path to a CMX EDL and returns a list of all events contained therein."""
|
|
||||||
statements = parse_cmx3600_statements(file)
|
|
||||||
parser = NamedTupleParser(statements)
|
|
||||||
parser.expect('Title')
|
|
||||||
title = parser.current_token.title
|
|
||||||
return event_list(title, parser)
|
|
||||||
|
|
||||||
|
|
||||||
def event_list(title, parser):
|
|
||||||
state = {"fcm_drop" : False}
|
|
||||||
|
|
||||||
events_result = []
|
|
||||||
this_event = None
|
|
||||||
|
|
||||||
while not parser.at_end():
|
|
||||||
if parser.accept('FCM'):
|
|
||||||
state['fcm_drop'] = parser.current_token.drop
|
|
||||||
elif parser.accept('Event'):
|
|
||||||
if this_event != None:
|
|
||||||
events_result.append(this_event)
|
|
||||||
|
|
||||||
raw_event = parser.current_token
|
|
||||||
channels = CmxChannelMap(v=False, audio_channels=set([]))
|
|
||||||
channels.appendEvent(raw_event.channels)
|
|
||||||
|
|
||||||
this_event = CmxEvent(title=title,number=int(raw_event.event), clip_name=None ,
|
|
||||||
source_name=raw_event.source,
|
|
||||||
channels=channels,
|
|
||||||
transition=CmxTransition(raw_event.trans, raw_event.trans_op),
|
|
||||||
source_start= raw_event.source_in,
|
|
||||||
source_finish= raw_event.source_out,
|
|
||||||
record_start= raw_event.record_in,
|
|
||||||
record_finish= raw_event.record_out,
|
|
||||||
fcm_drop= state['fcm_drop'],
|
|
||||||
line_number = raw_event.line_number)
|
|
||||||
elif parser.accept('AudioExt') or parser.accept('ClipName') or \
|
|
||||||
parser.accept('SourceFile') or parser.accept('EffectsName') or \
|
|
||||||
parser.accept('Remark'):
|
|
||||||
this_event.accept_statement(parser.current_token)
|
|
||||||
elif parser.accept('Trailer'):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
parser.next_token()
|
|
||||||
|
|
||||||
if this_event != None:
|
|
||||||
events_result.append(this_event)
|
|
||||||
|
|
||||||
return events_result
|
|
||||||
|
|
||||||
247
pycmx/parse_cmx_events.py
Normal file
247
pycmx/parse_cmx_events.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from .parse_cmx_statements import (parse_cmx3600_statements,
|
||||||
|
StmtEvent, StmtFCM, StmtTitle, StmtClipName, StmtSourceFile, StmtAudioExt)
|
||||||
|
|
||||||
|
from .channel_map import ChannelMap
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
def parse_cmx3600(path):
|
||||||
|
statements = parse_cmx3600_statements(path)
|
||||||
|
return EditList(statements)
|
||||||
|
|
||||||
|
|
||||||
|
class EditList:
|
||||||
|
def __init__(self, statements):
|
||||||
|
self.title_statement = statements[0]
|
||||||
|
self.event_statements = statements[1:]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def title(self):
|
||||||
|
'The title of the edit list'
|
||||||
|
return self.title_statement.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def events(self):
|
||||||
|
'A generator for all the events in the edit list'
|
||||||
|
is_drop = None
|
||||||
|
current_event_num = None
|
||||||
|
event_statements = []
|
||||||
|
for stmt in self.event_statements:
|
||||||
|
if type(stmt) is StmtFCM:
|
||||||
|
is_drop = stmt.drop
|
||||||
|
elif type(stmt) is StmtEvent:
|
||||||
|
if current_event_num is None:
|
||||||
|
current_event_num = stmt.event
|
||||||
|
event_statements.append(stmt)
|
||||||
|
else:
|
||||||
|
if current_event_num != stmt.event:
|
||||||
|
yield Event(statements=event_statements)
|
||||||
|
event_statements = [stmt]
|
||||||
|
current_event_num = stmt.event
|
||||||
|
else:
|
||||||
|
event_statements.append(stmt)
|
||||||
|
|
||||||
|
else:
|
||||||
|
event_statements.append(stmt)
|
||||||
|
|
||||||
|
yield Event(statements=event_statements)
|
||||||
|
|
||||||
|
|
||||||
|
class Edit:
|
||||||
|
def __init__(self, edit_statement, audio_ext_statement, clip_name_statement, source_file_statement):
|
||||||
|
self.edit_statement = edit_statement
|
||||||
|
self.audio_ext = audio_ext_statement
|
||||||
|
self.clip_name_statement = clip_name_statement
|
||||||
|
self.source_file_statement = source_file_statement
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self):
|
||||||
|
cm = ChannelMap()
|
||||||
|
cm.append_event(self.edit_statement.channels)
|
||||||
|
if self.audio_ext != None:
|
||||||
|
cm.append_ext(self.audio_ext)
|
||||||
|
return cm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transition(self):
|
||||||
|
return Transition(self.edit_statement.trans, self.edit_statement.trans_op)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_in(self):
|
||||||
|
return self.edit_statement.source_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_out(self):
|
||||||
|
return self.edit_statement.source_out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_in(self):
|
||||||
|
return self.edit_statement.record_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_out(self):
|
||||||
|
return self.edit_statement.record_out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self):
|
||||||
|
return self.edit_statement.source
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_file(self):
|
||||||
|
return self.source_file_statement.filename
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clip_name(self):
|
||||||
|
if self.clip_name_statement != None:
|
||||||
|
return self.clip_name_statement.name
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Event:
|
||||||
|
def __init__(self, statements):
|
||||||
|
self.statements = statements
|
||||||
|
|
||||||
|
@property
|
||||||
|
def number(self):
|
||||||
|
return self._edit_statements()[0].event
|
||||||
|
|
||||||
|
@property
|
||||||
|
def edits(self):
|
||||||
|
edits_audio = list( self._statements_with_audio_ext() )
|
||||||
|
clip_names = self._clip_name_statements()
|
||||||
|
source_files= self._source_file_statements()
|
||||||
|
|
||||||
|
the_zip = [edits_audio]
|
||||||
|
|
||||||
|
if len(edits_audio) == 2:
|
||||||
|
cn = [None, None]
|
||||||
|
for clip_name in clip_names:
|
||||||
|
if clip_name.affect == 'from':
|
||||||
|
cn[0] = clip_name
|
||||||
|
elif clip_name.affect == 'to':
|
||||||
|
cn[1] = clip_name
|
||||||
|
|
||||||
|
the_zip.append(cn)
|
||||||
|
|
||||||
|
else:
|
||||||
|
if len(edits_audio) == len(clip_names):
|
||||||
|
the_zip.append(clip_names)
|
||||||
|
else:
|
||||||
|
the_zip.append([None] * len(edits_audio) )
|
||||||
|
|
||||||
|
if len(edits_audio) == len(source_files):
|
||||||
|
the_zip.append(source_files)
|
||||||
|
elif len(source_files) == 1:
|
||||||
|
the_zip.append( source_files * len(edits_audio) )
|
||||||
|
else:
|
||||||
|
the_zip.append([None] * len(edits_audio) )
|
||||||
|
|
||||||
|
|
||||||
|
return [ Edit(e1[0],e1[1],n1,s1) for (e1,n1,s1) in zip(*the_zip) ]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _edit_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtEvent]
|
||||||
|
|
||||||
|
def _clip_name_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtClipName]
|
||||||
|
|
||||||
|
def _source_file_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtSourceFile]
|
||||||
|
|
||||||
|
def _statements_with_audio_ext(self):
|
||||||
|
for (s1, s2) in zip(self.statements, self.statements[1:]):
|
||||||
|
if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
|
||||||
|
yield (s1,s2)
|
||||||
|
elif type(s1) is StmtEvent:
|
||||||
|
yield (s1, None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Transition:
|
||||||
|
"""Represents a CMX transition, a wipe, dissolve or cut."""
|
||||||
|
|
||||||
|
Cut = "C"
|
||||||
|
Dissolve = "D"
|
||||||
|
Wipe = "W"
|
||||||
|
KeyBackground = "KB"
|
||||||
|
Key = "K"
|
||||||
|
KeyOut = "KO"
|
||||||
|
|
||||||
|
def __init__(self, transition, operand):
|
||||||
|
self.transition = transition
|
||||||
|
self.operand = operand
|
||||||
|
self.name = ''
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def kind(self):
|
||||||
|
if self.cut:
|
||||||
|
return Transition.Cut
|
||||||
|
elif self.dissolve:
|
||||||
|
return Transition.Dissolve
|
||||||
|
elif self.wipe:
|
||||||
|
return Transition.Wipe
|
||||||
|
elif self.key_background:
|
||||||
|
return Transition.KeyBackground
|
||||||
|
elif self.key_foreground:
|
||||||
|
return Transition.Key
|
||||||
|
elif self.key_out:
|
||||||
|
return Transition.KeyOut
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cut(self):
|
||||||
|
"`True` if this transition is a cut."
|
||||||
|
return self.transition == 'C'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dissolve(self):
|
||||||
|
"`True` if this traansition is a dissolve."
|
||||||
|
return self.transition == 'D'
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wipe(self):
|
||||||
|
"`True` if this transition is a wipe."
|
||||||
|
return self.transition.startswith('W')
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effect_duration(self):
|
||||||
|
""""`The duration of this transition, in frames of the record target.
|
||||||
|
|
||||||
|
In the event of a key event, this is the duration of the fade in.
|
||||||
|
"""
|
||||||
|
return int(self.operand)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wipe_number(self):
|
||||||
|
"Wipes are identified by a particular number."
|
||||||
|
if self.wipe:
|
||||||
|
return int(self.transition[1:])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_background(self):
|
||||||
|
"`True` if this is a key background event."
|
||||||
|
return self.transition == KeyBackground
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_foreground(self):
|
||||||
|
"`True` if this is a key foreground event."
|
||||||
|
return self.transition == Key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_out(self):
|
||||||
|
"`True` if this is a key out event."
|
||||||
|
return self.transition == KeyOut
|
||||||
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
|
# pycmx
|
||||||
# Parsed Statement Data Structures
|
# (c) 2018 Jamie Hardt
|
||||||
#
|
|
||||||
# These represent individual lines that have been typed and have undergone some light symbolic parsing.
|
|
||||||
|
|
||||||
from .util import collimate
|
from .util import collimate
|
||||||
import re
|
import re
|
||||||
@@ -10,15 +8,18 @@ from collections import namedtuple
|
|||||||
from itertools import count
|
from itertools import count
|
||||||
|
|
||||||
|
|
||||||
StmtTitle = namedtuple("Title",["title","line_number"])
|
StmtTitle = namedtuple("Title",["title","line_number"])
|
||||||
StmtFCM = namedtuple("FCM",["drop","line_number"])
|
StmtFCM = namedtuple("FCM",["drop","line_number"])
|
||||||
StmtEvent = namedtuple("Event",["event","source","channels","trans","trans_op","source_in","source_out","record_in","record_out","line_number"])
|
StmtEvent = namedtuple("Event",["event","source","channels","trans",\
|
||||||
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
|
"trans_op","source_in","source_out","record_in","record_out","line_number"])
|
||||||
StmtClipName = namedtuple("ClipName",["name","line_number"])
|
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
|
||||||
|
StmtClipName = namedtuple("ClipName",["name","affect","line_number"])
|
||||||
StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
|
StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
|
||||||
StmtRemark = namedtuple("Remark",["text","line_number"])
|
StmtRemark = namedtuple("Remark",["text","line_number"])
|
||||||
StmtEffectsName = namedtuple("EffectsName",["name","line_number"])
|
StmtEffectsName = namedtuple("EffectsName",["name","line_number"])
|
||||||
StmtTrailer = namedtuple("Trailer",["text","line_number"])
|
StmtTrailer = namedtuple("Trailer",["text","line_number"])
|
||||||
|
StmtSplitEdit = namedtuple("SplitEdit",["video","magnitue", "line_number"])
|
||||||
|
StmtMotionMemory = namedtuple("MotionMemory",["source","fps"]) # FIXME needs more fields
|
||||||
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
|
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +27,8 @@ def parse_cmx3600_statements(path):
|
|||||||
with open(path,'r') as file:
|
with open(path,'r') as file:
|
||||||
lines = file.readlines()
|
lines = file.readlines()
|
||||||
line_numbers = count()
|
line_numbers = count()
|
||||||
return [parse_cmx3600_line(line.strip(), line_number) for (line, line_number) in zip(lines,line_numbers)]
|
return [parse_cmx3600_line(line.strip(), line_number) \
|
||||||
|
for (line, line_number) in zip(lines,line_numbers)]
|
||||||
|
|
||||||
def edl_column_widths(event_field_length, source_field_length):
|
def edl_column_widths(event_field_length, source_field_length):
|
||||||
return [event_field_length,2, source_field_length,1,
|
return [event_field_length,2, source_field_length,1,
|
||||||
@@ -37,7 +39,13 @@ def edl_column_widths(event_field_length, source_field_length):
|
|||||||
11,1,
|
11,1,
|
||||||
11,1,
|
11,1,
|
||||||
11]
|
11]
|
||||||
|
|
||||||
|
def edl_m2_column_widths():
|
||||||
|
return [2, # "M2"
|
||||||
|
3,3, #
|
||||||
|
8,8,1,4,2,1,4,13,3,1,1]
|
||||||
|
|
||||||
|
|
||||||
def parse_cmx3600_line(line, line_number):
|
def parse_cmx3600_line(line, line_number):
|
||||||
long_event_num_p = re.compile("^[0-9]{6} ")
|
long_event_num_p = re.compile("^[0-9]{6} ")
|
||||||
short_event_num_p = re.compile("^[0-9]{3} ")
|
short_event_num_p = re.compile("^[0-9]{3} ")
|
||||||
@@ -63,6 +71,10 @@ def parse_cmx3600_line(line, line_number):
|
|||||||
return parse_trailer_statement(line, line_number)
|
return parse_trailer_statement(line, line_number)
|
||||||
elif line.startswith("EFFECTS NAME IS"):
|
elif line.startswith("EFFECTS NAME IS"):
|
||||||
return parse_effects_name(line, line_number)
|
return parse_effects_name(line, line_number)
|
||||||
|
elif line.startswith("SPLIT:"):
|
||||||
|
return parse_split(line, line_number)
|
||||||
|
elif line.startswith("M2"):
|
||||||
|
return parse_motion_memory(line, line_number)
|
||||||
else:
|
else:
|
||||||
return parse_unrecognized(line, line_number)
|
return parse_unrecognized(line, line_number)
|
||||||
|
|
||||||
@@ -97,7 +109,9 @@ def parse_extended_audio_channels(line, line_number):
|
|||||||
|
|
||||||
def parse_remark(line, line_number):
|
def parse_remark(line, line_number):
|
||||||
if line.startswith("FROM CLIP NAME:"):
|
if line.startswith("FROM CLIP NAME:"):
|
||||||
return StmtClipName(name=line[15:].strip() , line_number=line_number)
|
return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number)
|
||||||
|
elif line.startswith("TO CLIP NAME:"):
|
||||||
|
return StmtClipName(name=line[13:].strip(), affect="to", line_number=line_number)
|
||||||
elif line.startswith("SOURCE FILE:"):
|
elif line.startswith("SOURCE FILE:"):
|
||||||
return StmtSourceFile(filename=line[12:].strip() , line_number=line_number)
|
return StmtSourceFile(filename=line[12:].strip() , line_number=line_number)
|
||||||
else:
|
else:
|
||||||
@@ -107,6 +121,20 @@ def parse_effects_name(line, line_number):
|
|||||||
name = line[16:].strip()
|
name = line[16:].strip()
|
||||||
return StmtEffectsName(name=name, line_number=line_number)
|
return StmtEffectsName(name=name, line_number=line_number)
|
||||||
|
|
||||||
|
def parse_split(line, line_number):
|
||||||
|
split_type = line[10:21]
|
||||||
|
is_video = False
|
||||||
|
if split_type.startswith("VIDEO"):
|
||||||
|
is_video = True
|
||||||
|
|
||||||
|
split_mag = line[24:35]
|
||||||
|
return StmtSplitEdit(video=is_video, magnitude=split_mag, line_number=line_number)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_motion_memory(line, line_number):
|
||||||
|
return StmtMotionMemory(source = "", fps="")
|
||||||
|
|
||||||
|
|
||||||
def parse_unrecognized(line, line_number):
|
def parse_unrecognized(line, line_number):
|
||||||
return StmtUnrecognized(content=line, line_number=line_number)
|
return StmtUnrecognized(content=line, line_number=line_number)
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,50 @@ def collimate(a_string, column_widths):
|
|||||||
return [element] + collimate(rest, column_widths[1:])
|
return [element] + collimate(rest, column_widths[1:])
|
||||||
|
|
||||||
|
|
||||||
|
class NamedTupleParser:
|
||||||
|
"""
|
||||||
|
Accepts a list of namedtuple and the client can step through the list with
|
||||||
|
parser operations such as `accept()` and `expect()`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, tuple_list):
|
||||||
|
self.tokens = tuple_list
|
||||||
|
self.current_token = None
|
||||||
|
|
||||||
|
def peek(self):
|
||||||
|
"""
|
||||||
|
Returns the token to come after the `current_token` without
|
||||||
|
popping the current token.
|
||||||
|
"""
|
||||||
|
return self.tokens[0]
|
||||||
|
|
||||||
|
def at_end(self):
|
||||||
|
"`True` if the `current_token` is the last one."
|
||||||
|
return len(self.tokens) == 0
|
||||||
|
|
||||||
|
def next_token(self):
|
||||||
|
"Sets `current_token` to the next token popped from the list"
|
||||||
|
self.current_token = self.peek()
|
||||||
|
self.tokens = self.tokens[1:]
|
||||||
|
|
||||||
|
def accept(self, type_name):
|
||||||
|
"""
|
||||||
|
If the next token.__name__ is `type_name`, returns true and advances
|
||||||
|
to the next token with `next_token()`.
|
||||||
|
"""
|
||||||
|
if self.at_end():
|
||||||
|
return False
|
||||||
|
elif (type(self.peek()).__name__ == type_name ):
|
||||||
|
self.next_token()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def expect(self, type_name):
|
||||||
|
"""
|
||||||
|
If the next token.__name__ is `type_name`, the parser is advanced.
|
||||||
|
If it is not, an assertion failure occurs.
|
||||||
|
"""
|
||||||
|
assert( self.accept(type_name) )
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
2
setup.py
2
setup.py
@@ -4,7 +4,7 @@ with open("README.md", "r") as fh:
|
|||||||
long_description = fh.read()
|
long_description = fh.read()
|
||||||
|
|
||||||
setup(name='pycmx',
|
setup(name='pycmx',
|
||||||
version='0.5',
|
version='0.6',
|
||||||
author='Jamie Hardt',
|
author='Jamie Hardt',
|
||||||
author_email='jamiehardt@me.com',
|
author_email='jamiehardt@me.com',
|
||||||
description='CMX 3600 Edit Decision List Parser',
|
description='CMX 3600 Edit Decision List Parser',
|
||||||
|
|||||||
1561
tests/edls/INS4_R1_DX_092117.edl
Normal file
1561
tests/edls/INS4_R1_DX_092117.edl
Normal file
File diff suppressed because it is too large
Load Diff
1550
tests/edls/INS4_R2_DX_092117.edl
Normal file
1550
tests/edls/INS4_R2_DX_092117.edl
Normal file
File diff suppressed because it is too large
Load Diff
1588
tests/edls/INS4_R3_DX_092117.edl
Normal file
1588
tests/edls/INS4_R3_DX_092117.edl
Normal file
File diff suppressed because it is too large
Load Diff
1872
tests/edls/INS4_R4_DX_092117.edl
Normal file
1872
tests/edls/INS4_R4_DX_092117.edl
Normal file
File diff suppressed because it is too large
Load Diff
1810
tests/edls/INS4_R5_DX_092117.edl
Normal file
1810
tests/edls/INS4_R5_DX_092117.edl
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,24 +11,63 @@ class TestParse(TestCase):
|
|||||||
"TEST.edl"
|
"TEST.edl"
|
||||||
]
|
]
|
||||||
|
|
||||||
counts = [ 287, 250 , 376, 148 ]
|
counts = [ 287, 250 , 376, 120 ]
|
||||||
|
|
||||||
|
|
||||||
for fn, count in zip(files, counts):
|
for fn, count in zip(files, counts):
|
||||||
events = pycmx.parse_cmx3600(f"tests/edls/{fn}" )
|
edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" )
|
||||||
self.assertTrue( len(events) == count , f"expected {len(events)} but found {count}")
|
actual = len(list(edl.events ))
|
||||||
|
self.assertTrue( actual == count , f"expected {count} in file {fn} but found {actual}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_events(self):
|
||||||
|
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
|
||||||
|
events = list( edl.events )
|
||||||
|
|
||||||
|
self.assertEqual( int(events[0].number) , 1)
|
||||||
|
self.assertEqual( events[0].edits[0].source , "OY_HEAD_")
|
||||||
|
self.assertEqual( events[0].edits[0].clip_name , "HEAD LEADER MONO")
|
||||||
|
self.assertEqual( events[0].edits[0].source_file , "OY_HEAD_LEADER.MOV")
|
||||||
|
self.assertEqual( events[0].edits[0].source_in , "00:00:00:00")
|
||||||
|
self.assertEqual( events[0].edits[0].source_out , "00:00:00:00")
|
||||||
|
self.assertEqual( events[0].edits[0].record_in , "01:00:00:00")
|
||||||
|
self.assertEqual( events[0].edits[0].record_out , "01:00:08:00")
|
||||||
|
self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut)
|
||||||
|
|
||||||
|
def test_channel_mop(self):
|
||||||
|
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
|
||||||
|
events = list( edl.events )
|
||||||
|
self.assertFalse( events[0].edits[0].channels.video)
|
||||||
|
self.assertFalse( events[0].edits[0].channels.a1)
|
||||||
|
self.assertTrue( events[0].edits[0].channels.a2)
|
||||||
|
self.assertTrue( events[2].edits[0].channels.get_audio_channel(7) )
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_edit_events(self):
|
||||||
|
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
|
||||||
|
events = list( edl.events )
|
||||||
|
|
||||||
|
|
||||||
|
self.assertEqual( int(events[42].number) , 43)
|
||||||
|
self.assertEqual( len(events[42].edits), 2)
|
||||||
|
|
||||||
|
|
||||||
|
self.assertEqual( events[42].edits[0].source , "TC_R1_V1")
|
||||||
|
self.assertEqual( events[42].edits[0].clip_name , "TC R1 V1.2 TEMP1 FX ST.WAV")
|
||||||
|
self.assertEqual( events[42].edits[0].source_in , "00:00:00:00")
|
||||||
|
self.assertEqual( events[42].edits[0].source_out , "00:00:00:00")
|
||||||
|
self.assertEqual( events[42].edits[0].record_in , "01:08:56:09")
|
||||||
|
self.assertEqual( events[42].edits[0].record_out , "01:08:56:09")
|
||||||
|
self.assertTrue( events[42].edits[0].transition.kind == pycmx.Transition.Cut)
|
||||||
|
|
||||||
|
|
||||||
|
self.assertEqual( events[42].edits[1].source , "TC_R1_V6")
|
||||||
|
self.assertEqual( events[42].edits[1].clip_name , "TC R1 V6 TEMP2 ST FX.WAV")
|
||||||
|
self.assertEqual( events[42].edits[1].source_in , "00:00:00:00")
|
||||||
|
self.assertEqual( events[42].edits[1].source_out , "00:00:00:00")
|
||||||
|
self.assertEqual( events[42].edits[1].record_in , "01:08:56:09")
|
||||||
|
self.assertEqual( events[42].edits[1].record_out , "01:08:56:11")
|
||||||
|
self.assertTrue( events[42].edits[1].transition.kind == pycmx.Transition.Dissolve)
|
||||||
|
|
||||||
|
|
||||||
def test_audio_channels(self):
|
|
||||||
events = pycmx.parse_cmx3600(f"tests/edls/TEST.edl" )
|
|
||||||
self.assertTrue(events[0].channels.a2)
|
|
||||||
self.assertFalse(events[0].channels.a1)
|
|
||||||
self.assertTrue(events[2].channels.get_audio_channel(7))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(1))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(2))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(3))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(4))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(10))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(11))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(12))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(13))
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user