diff --git a/pycmx/__init__.py b/pycmx/__init__.py index 7c64143..74c4795 100644 --- a/pycmx/__init__.py +++ b/pycmx/__init__.py @@ -1,4 +1,14 @@ -# pycmx init +# -*- coding: utf-8 -*- +""" +pycmx is a module for parsing CMX 3600-style EDLs. For more information and +examples see README.md + +This module (c) 2018 Jamie Hardt. For more information on your rights to +copy and reuse this software, refer to the LICENSE file included with the +distribution. +""" + + from .parse_cmx_events import parse_cmx3600, Transition, Event, Edit from . import parse_cmx_events diff --git a/pycmx/channel_map.py b/pycmx/channel_map.py index c7e9121..39e6295 100644 --- a/pycmx/channel_map.py +++ b/pycmx/channel_map.py @@ -9,7 +9,7 @@ class ChannelMap: Represents a set of all the channels to which an event applies. """ - chan_map = { "V" : (True, False, False), + _chan_map = { "V" : (True, False, False), "A" : (False, True, False), "A2" : (False, False, True), "AA" : (False, True, True), @@ -35,6 +35,7 @@ class ChannelMap: @property def a1(self): + """True if A1 is included.""" return self.get_audio_channel(1) @a1.setter @@ -43,6 +44,7 @@ class ChannelMap: @property def a2(self): + """True if A2 is included.""" return self.get_audio_channel(2) @a2.setter @@ -51,6 +53,7 @@ class ChannelMap: @property def a3(self): + """True if A3 is included.""" return self.get_audio_channel(3) @a3.setter @@ -59,6 +62,7 @@ class ChannelMap: @property def a4(self): + """True if A4 is included.""" return self.get_audio_channel(4) @a4.setter @@ -66,18 +70,20 @@ class ChannelMap: self.set_audio_channel(4,val) def get_audio_channel(self,chan_num): + """True if chan_num is included.""" return (chan_num in self._audio_channel_set) def set_audio_channel(self,chan_num,enabled): + """If enabled is true, chan_num will be included.""" 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): + 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] + 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] @@ -86,7 +92,7 @@ class ChannelMap: if matchresult: self.set_audio_channel(int( matchresult.group(1)), True ) - def append_sxt(self, audio_ext): + def _append_sxt(self, audio_ext): self.a3 = ext.audio3 self.a4 = ext.audio4 diff --git a/pycmx/parse_cmx_events.py b/pycmx/parse_cmx_events.py index c5afcce..0d50ab4 100644 --- a/pycmx/parse_cmx_events.py +++ b/pycmx/parse_cmx_events.py @@ -8,18 +8,35 @@ from .channel_map import ChannelMap from collections import namedtuple -def parse_cmx3600(path): - statements = parse_cmx3600_statements(path) +def parse_cmx3600(f): + """ + Parse a CMX 3600 EDL. + + Args: + f : a file-like object, anything that's readlines-able. + + Returns: + An :obj:`EditList`. + """ + statements = parse_cmx3600_statements(f) return EditList(statements) class EditList: + """ + Represents an entire edit decision list as returned by `parse_cmx3600()`. + + """ def __init__(self, statements): self.title_statement = statements[0] self.event_statements = statements[1:] @property def title(self): + """ + The title of this edit list, as attensted by the 'TITLE:' statement on + the first line. + """ 'The title of the edit list' return self.title_statement.title @@ -59,44 +76,81 @@ class Edit: @property def channels(self): + """ + Get the :obj:`ChannelMap` object associated with this Edit. + """ cm = ChannelMap() - cm.append_event(self.edit_statement.channels) + cm._append_event(self.edit_statement.channels) if self.audio_ext != None: - cm.append_ext(self.audio_ext) + cm._append_ext(self.audio_ext) return cm @property def transition(self): + """ + Get the :obj:`Transition` object associated with this edit. + """ return Transition(self.edit_statement.trans, self.edit_statement.trans_op) @property def source_in(self): + """ + Get the source in timecode. + """ return self.edit_statement.source_in @property def source_out(self): + """ + Get the source out timecode. + """ + return self.edit_statement.source_out @property def record_in(self): + """ + Get the record in timecode. + """ + return self.edit_statement.record_in @property def record_out(self): + """ + Get the record out timecode. + """ + return self.edit_statement.record_out @property def source(self): + """ + Get the source column. This is the 8, 32 or 128-character string on the + event record line, this usually references the tape name of the source. + """ return self.edit_statement.source @property def source_file(self): - return self.source_file_statement.filename + """ + Get the source file, as attested by a "* SOURCE FILE" remark on the + EDL. This will return None if the information is not present. + """ + if self.source_file_statement is None: + return None + else: + return self.source_file_statement.filename @property def clip_name(self): + """ + Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP + NAME" remark on the EDL. This will return None if the information is + not present. + """ if self.clip_name_statement != None: return self.clip_name_statement.name else: @@ -110,10 +164,16 @@ class Event: @property def number(self): - return self._edit_statements()[0].event + """Return the event number.""" + return int(self._edit_statements()[0].event) @property def edits(self): + """ + Returns the edits. Most events will have a single edit, a single event + will have multiple edits when a dissolve, wipe or key transition needs + to be performed. + """ edits_audio = list( self._statements_with_audio_ext() ) clip_names = self._clip_name_statements() source_files= self._source_file_statements() @@ -170,12 +230,18 @@ 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 @@ -184,6 +250,9 @@ class Transition: @property def kind(self): + """ + Return the kind of transition: Cut, Wipe, etc + """ if self.cut: return Transition.Cut elif self.dissolve: @@ -216,7 +285,7 @@ class Transition: @property def effect_duration(self): - """"`The duration of this transition, in frames of the record target. + """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. """ diff --git a/pycmx/parse_cmx_statements.py b/pycmx/parse_cmx_statements.py index 51e6269..b7de657 100644 --- a/pycmx/parse_cmx_statements.py +++ b/pycmx/parse_cmx_statements.py @@ -23,14 +23,16 @@ StmtMotionMemory = namedtuple("MotionMemory",["source","fps"]) # FIXME needs mor StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"]) -def parse_cmx3600_statements(path): - with open(path,'r') as file: - lines = file.readlines() - line_numbers = count() - return [parse_cmx3600_line(line.strip(), line_number) \ - for (line, line_number) in zip(lines,line_numbers)] +def parse_cmx3600_statements(file): + """ + Return a list of every statement in the file argument. + """ + lines = file.readlines() + line_numbers = count() + 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, 4,2, # chans 4,1, # trans @@ -40,63 +42,63 @@ def edl_column_widths(event_field_length, source_field_length): 11,1, 11] -def edl_m2_column_widths(): +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} ") short_event_num_p = re.compile("^[0-9]{3} ") if isinstance(line,str): if line.startswith("TITLE:"): - return parse_title(line,line_number) + return _parse_title(line,line_number) elif line.startswith("FCM:"): - return parse_fcm(line, line_number) + return _parse_fcm(line, line_number) elif long_event_num_p.match(line) != None: - length_file_128 = sum(edl_column_widths(6,128)) + length_file_128 = sum(_edl_column_widths(6,128)) if len(line) < length_file_128: - return parse_long_standard_form(line, 32, line_number) + return _parse_long_standard_form(line, 32, line_number) else: - return parse_long_standard_form(line, 128, line_number) + return _parse_long_standard_form(line, 128, line_number) elif short_event_num_p.match(line) != None: - return parse_standard_form(line, line_number) + return _parse_standard_form(line, line_number) elif line.startswith("AUD"): - return parse_extended_audio_channels(line,line_number) + return _parse_extended_audio_channels(line,line_number) elif line.startswith("*"): - return parse_remark( line[1:].strip(), line_number) + return _parse_remark( line[1:].strip(), line_number) elif line.startswith(">>>"): - return parse_trailer_statement(line, line_number) + return _parse_trailer_statement(line, line_number) 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) + return _parse_split(line, line_number) elif line.startswith("M2"): - return parse_motion_memory(line, line_number) + return _parse_motion_memory(line, line_number) else: - return parse_unrecognized(line, line_number) + return _parse_unrecognized(line, line_number) -def parse_title(line, line_num): +def _parse_title(line, line_num): title = line[6:].strip() return StmtTitle(title=title,line_number=line_num) -def parse_fcm(line, line_num): +def _parse_fcm(line, line_num): val = line[4:].strip() if val == "DROP FRAME": return StmtFCM(drop= True, line_number=line_num) else: return StmtFCM(drop= False, line_number=line_num) -def parse_long_standard_form(line,source_field_length, line_number): - return parse_columns_for_standard_form(line, 6, source_field_length, line_number) +def _parse_long_standard_form(line,source_field_length, line_number): + return _parse_columns_for_standard_form(line, 6, source_field_length, line_number) -def parse_standard_form(line, line_number): - return parse_columns_for_standard_form(line, 3, 8, line_number) +def _parse_standard_form(line, line_number): + return _parse_columns_for_standard_form(line, 3, 8, line_number) -def parse_extended_audio_channels(line, line_number): +def _parse_extended_audio_channels(line, line_number): content = line.strip() if content == "AUD 3": return StmtAudioExt(audio3=True, audio4=False, line_number=line_number) @@ -107,7 +109,7 @@ def parse_extended_audio_channels(line, line_number): else: return StmtUnrecognized(content=line, line_number=line_number) -def parse_remark(line, line_number): +def _parse_remark(line, line_number): if line.startswith("FROM CLIP NAME:"): return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number) elif line.startswith("TO CLIP NAME:"): @@ -117,11 +119,11 @@ def parse_remark(line, line_number): else: return StmtRemark(text=line, line_number=line_number) -def parse_effects_name(line, line_number): +def _parse_effects_name(line, line_number): name = line[16:].strip() return StmtEffectsName(name=name, line_number=line_number) -def parse_split(line, line_number): +def _parse_split(line, line_number): split_type = line[10:21] is_video = False if split_type.startswith("VIDEO"): @@ -131,15 +133,15 @@ def parse_split(line, line_number): return StmtSplitEdit(video=is_video, magnitude=split_mag, line_number=line_number) -def parse_motion_memory(line, 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) -def parse_columns_for_standard_form(line, event_field_length, source_field_length, line_number): - col_widths = edl_column_widths(event_field_length, source_field_length) +def _parse_columns_for_standard_form(line, event_field_length, source_field_length, line_number): + col_widths = _edl_column_widths(event_field_length, source_field_length) if sum(col_widths) > len(line): return StmtUnrecognized(content=line, line_number=line_number) @@ -158,7 +160,7 @@ def parse_columns_for_standard_form(line, event_field_length, source_field_lengt line_number=line_number) -def parse_trailer_statement(line, line_number): +def _parse_trailer_statement(line, line_number): trimmed = line[3:].strip() return StmtTrailer(trimmed, line_number=line_number) diff --git a/pycmx/util.py b/pycmx/util.py index f262ae6..c2c95d4 100644 --- a/pycmx/util.py +++ b/pycmx/util.py @@ -4,7 +4,22 @@ # Utility functions def collimate(a_string, column_widths): - 'Splits a string into substrings that are column_widths length.' + """ + Split a list-type thing, like a string, into slices that are column_widths + length. + + >>> collimate("a b1 c2345",[2,3,3,2]) + ['a ','b1 ','c23','45'] + + Args: + a_string: The string to split. This parameter can actually be anything + sliceable. + column_widths: A list of integers, each one is the length of a column. + + Returns: + A list of slices. The len() of the returned list will *always* equal + len(:column_widths:). + """ if len(column_widths) == 0: return [] @@ -14,51 +29,3 @@ def collimate(a_string, column_widths): rest = a_string[width:] 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) ) - - diff --git a/tests/test_parse.py b/tests/test_parse.py index 6021d34..71f9427 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -14,61 +14,61 @@ class TestParse(TestCase): counts = [ 287, 466, 250 , 376, 120 ] - for fn, count in zip(files, counts): - edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" ) - actual = len(list(edl.events )) - self.assertTrue( actual == count , f"expected {count} in file {fn} but found {actual}") + with open(f"tests/edls/{fn}" ,'r') as f: + edl = pycmx.parse_cmx3600(f) + 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) + with open("tests/edls/TEST.edl",'r') as f: + edl = pycmx.parse_cmx3600(f) + 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) ) + with open("tests/edls/TEST.edl",'r') as f: + edl = pycmx.parse_cmx3600(f) + 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 ) + with open("tests/edls/TEST.edl",'r') as f: + edl = pycmx.parse_cmx3600(f) + events = list( edl.events ) + self.assertEqual( int(events[42].number) , 43) + self.assertEqual( len(events[42].edits), 2) - 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[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) + 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)