diff --git a/README.md b/README.md index a80d6dd..3270518 100644 --- a/README.md +++ b/README.md @@ -11,54 +11,16 @@ The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and it * Remark or comment fields with common recognized forms are read and available to the client, including clip name and source file data. * Symbolically decodes transitions +* 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 ``` ->>> import pycmx ->>> result = pycmx.parse_cmx3600("STP R1 v082517.edl") ->>> print(result[0:3]) -[CmxEvent(title='STP_Reel 1_082517',number=1, - clip_name='FKI_LEADER_HEAD_1920X1080.MOV', - source_name='FKI_LEADER_HEAD_1920X1080.MOV', - channels=CmxChannelMap(v=True, audio_channels=set()), - transition=CmxTransition(transition='C',operand=''), - source_start='01:00:00:00',source_finish='01:00:08:00', - record_start='01:00:00:00',record_finish='01:00:08:00', - fcm_drop=False,remarks=[],line_number=2), -CmxEvent(title='STP_Reel 1_082517',number=2, - clip_name='BH_PRODUCTIONS_1.85_PRORES.MOV', - source_name='BH_PRODUCTIONS_1.85_PRORES.MOV', - channels=CmxChannelMap(v=True, audio_channels=set()), - transition=CmxTransition(transition='C',operand=''), - source_start='01:00:00:00',source_finish='01:00:14:23', - record_start='01:00:00:00',record_finish='01:00:23:00', - fcm_drop=False,remarks=[],line_number=5), -CmxEvent(title='STP_Reel 1_082517',number=3, - clip_name='V4L-1*', - source_name='B116C001_150514_R0UR', - channels=CmxChannelMap(v=True, audio_channels=set()), - transition=CmxTransition(transition='C',operand=''), - source_start='16:37:29:06',source_finish='16:37:40:22', - record_start='16:37:29:06',record_finish='01:00:50:09', - fcm_drop=False,remarks=[],line_number=8)] + ``` -## Known Issues/Roadmap - -To be addressed: -* 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? At this time, this is (at best) alpha software and the interface will be diff --git a/pycmx/parse_cmx_events.py b/pycmx/parse_cmx_events.py index 3eaf95a..5137f7f 100644 --- a/pycmx/parse_cmx_events.py +++ b/pycmx/parse_cmx_events.py @@ -2,7 +2,7 @@ # (c) 2018 Jamie Hardt from .parse_cmx_statements import (parse_cmx3600_statements, - StmtEvent, StmtFCM, StmtTitle) + StmtEvent, StmtFCM, StmtTitle, StmtClipName, StmtSourceFile, StmtAudioExt) from collections import namedtuple @@ -20,49 +20,114 @@ class EditList: 'The title of the edit list' return self.title_statement.title + @property def events(self): - 'Each event in the edit list' - def events_p(statements_rest, curr_event_num, - statements_event, events, is_drop): - - stmt = statements_rest[0] - rem = statements_rest[1:] - - - if type(stmt) is StmtEvent: - if stmt.event == curr_event_num: - return ( rem,curr_event_num,statements_event + [stmt],events,is_drop) + '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: - new_event = Event(statements_event) - return ( rem,stmt.event, [stmt], events + [new_event],is_drop ) - - elif type(stmt) is StmtFCM: - return ( rem, curr_event_num, statements_event, events,stmt.drop) + 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: - return ( rem, curr_event_num, statements_event + [stmt],events, is_drop) + event_statements.append(stmt) - result = (self.event_statements, None, [], [], False) - while True: - if len(result[0]) == 0: - return result[3] - else: - result = events_p(*result) + 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 -Edit = namedtuple("Edit","channels transition source_ref source_start source_finish record_start record_finish") + @property + def source(self): + return self.edit_statement.source + + @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 - def number(): - return 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] - def edits(): - for statement in self.statements: - + 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) + + + + diff --git a/pycmx/parse_cmx_statements.py b/pycmx/parse_cmx_statements.py index 67b2dba..51e6269 100644 --- a/pycmx/parse_cmx_statements.py +++ b/pycmx/parse_cmx_statements.py @@ -13,7 +13,7 @@ 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"]) StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"]) -StmtClipName = namedtuple("ClipName",["name","line_number"]) +StmtClipName = namedtuple("ClipName",["name","affect","line_number"]) StmtSourceFile = namedtuple("SourceFile",["filename","line_number"]) StmtRemark = namedtuple("Remark",["text","line_number"]) StmtEffectsName = namedtuple("EffectsName",["name","line_number"]) @@ -109,7 +109,9 @@ def parse_extended_audio_channels(line, line_number): def parse_remark(line, line_number): 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:"): return StmtSourceFile(filename=line[12:].strip() , line_number=line_number) else: diff --git a/tests/test_parse.py b/tests/test_parse.py index 62a5151..d1e507c 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -16,8 +16,24 @@ class TestParse(TestCase): for fn, count in zip(files, counts): edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" ) - actual = len(edl.events()) - self.assertTrue( actual == count , f"expected {count} but found {actual}") + 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( int(events[42].number) , 43) + 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[1].source , "TC_R1_V6") + self.assertEqual( events[42].edits[1].clip_name , "TC R1 V6 TEMP2 ST FX.WAV") +