Doc, file path

Documentation, cleaned up interface, and we now parse file handles, not file paths
This commit is contained in:
Jamie Hardt
2018-12-26 14:25:19 -08:00
parent 26b2f5274c
commit 82814522d1
6 changed files with 196 additions and 142 deletions

View File

@@ -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 .parse_cmx_events import parse_cmx3600, Transition, Event, Edit
from . import parse_cmx_events from . import parse_cmx_events

View File

@@ -9,7 +9,7 @@ class ChannelMap:
Represents a set of all the channels to which an event applies. 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), "A" : (False, True, False),
"A2" : (False, False, True), "A2" : (False, False, True),
"AA" : (False, True, True), "AA" : (False, True, True),
@@ -35,6 +35,7 @@ class ChannelMap:
@property @property
def a1(self): def a1(self):
"""True if A1 is included."""
return self.get_audio_channel(1) return self.get_audio_channel(1)
@a1.setter @a1.setter
@@ -43,6 +44,7 @@ class ChannelMap:
@property @property
def a2(self): def a2(self):
"""True if A2 is included."""
return self.get_audio_channel(2) return self.get_audio_channel(2)
@a2.setter @a2.setter
@@ -51,6 +53,7 @@ class ChannelMap:
@property @property
def a3(self): def a3(self):
"""True if A3 is included."""
return self.get_audio_channel(3) return self.get_audio_channel(3)
@a3.setter @a3.setter
@@ -59,6 +62,7 @@ class ChannelMap:
@property @property
def a4(self): def a4(self):
"""True if A4 is included."""
return self.get_audio_channel(4) return self.get_audio_channel(4)
@a4.setter @a4.setter
@@ -66,18 +70,20 @@ class ChannelMap:
self.set_audio_channel(4,val) self.set_audio_channel(4,val)
def get_audio_channel(self,chan_num): def get_audio_channel(self,chan_num):
"""True if chan_num is included."""
return (chan_num in self._audio_channel_set) return (chan_num in self._audio_channel_set)
def set_audio_channel(self,chan_num,enabled): def set_audio_channel(self,chan_num,enabled):
"""If enabled is true, chan_num will be included."""
if enabled: if enabled:
self._audio_channel_set.add(chan_num) self._audio_channel_set.add(chan_num)
elif self.get_audio_channel(chan_num): elif self.get_audio_channel(chan_num):
self._audio_channel_set.remove(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+)') alt_channel_re = compile('^A(\d+)')
if event_str in self.chan_map: if event_str in self._chan_map:
channels = self.chan_map[event_str] channels = self._chan_map[event_str]
self.v = channels[0] self.v = channels[0]
self.a1 = channels[1] self.a1 = channels[1]
self.a2 = channels[2] self.a2 = channels[2]
@@ -86,7 +92,7 @@ class ChannelMap:
if matchresult: if matchresult:
self.set_audio_channel(int( matchresult.group(1)), True ) 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.a3 = ext.audio3
self.a4 = ext.audio4 self.a4 = ext.audio4

View File

@@ -8,18 +8,35 @@ from .channel_map import ChannelMap
from collections import namedtuple from collections import namedtuple
def parse_cmx3600(path): def parse_cmx3600(f):
statements = parse_cmx3600_statements(path) """
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) return EditList(statements)
class EditList: class EditList:
"""
Represents an entire edit decision list as returned by `parse_cmx3600()`.
"""
def __init__(self, statements): def __init__(self, statements):
self.title_statement = statements[0] self.title_statement = statements[0]
self.event_statements = statements[1:] self.event_statements = statements[1:]
@property @property
def title(self): 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' 'The title of the edit list'
return self.title_statement.title return self.title_statement.title
@@ -59,44 +76,81 @@ class Edit:
@property @property
def channels(self): def channels(self):
"""
Get the :obj:`ChannelMap` object associated with this Edit.
"""
cm = ChannelMap() cm = ChannelMap()
cm.append_event(self.edit_statement.channels) cm._append_event(self.edit_statement.channels)
if self.audio_ext != None: if self.audio_ext != None:
cm.append_ext(self.audio_ext) cm._append_ext(self.audio_ext)
return cm return cm
@property @property
def transition(self): def transition(self):
"""
Get the :obj:`Transition` object associated with this edit.
"""
return Transition(self.edit_statement.trans, self.edit_statement.trans_op) return Transition(self.edit_statement.trans, self.edit_statement.trans_op)
@property @property
def source_in(self): def source_in(self):
"""
Get the source in timecode.
"""
return self.edit_statement.source_in return self.edit_statement.source_in
@property @property
def source_out(self): def source_out(self):
"""
Get the source out timecode.
"""
return self.edit_statement.source_out return self.edit_statement.source_out
@property @property
def record_in(self): def record_in(self):
"""
Get the record in timecode.
"""
return self.edit_statement.record_in return self.edit_statement.record_in
@property @property
def record_out(self): def record_out(self):
"""
Get the record out timecode.
"""
return self.edit_statement.record_out return self.edit_statement.record_out
@property @property
def source(self): 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 return self.edit_statement.source
@property @property
def source_file(self): 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 @property
def clip_name(self): 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: if self.clip_name_statement != None:
return self.clip_name_statement.name return self.clip_name_statement.name
else: else:
@@ -110,10 +164,16 @@ class Event:
@property @property
def number(self): def number(self):
return self._edit_statements()[0].event """Return the event number."""
return int(self._edit_statements()[0].event)
@property @property
def edits(self): 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() ) edits_audio = list( self._statements_with_audio_ext() )
clip_names = self._clip_name_statements() clip_names = self._clip_name_statements()
source_files= self._source_file_statements() source_files= self._source_file_statements()
@@ -170,12 +230,18 @@ class Transition:
"""Represents a CMX transition, a wipe, dissolve or cut.""" """Represents a CMX transition, a wipe, dissolve or cut."""
Cut = "C" Cut = "C"
Dissolve = "D" Dissolve = "D"
Wipe = "W" Wipe = "W"
KeyBackground = "KB" KeyBackground = "KB"
Key = "K" Key = "K"
KeyOut = "KO" KeyOut = "KO"
def __init__(self, transition, operand): def __init__(self, transition, operand):
self.transition = transition self.transition = transition
self.operand = operand self.operand = operand
@@ -184,6 +250,9 @@ class Transition:
@property @property
def kind(self): def kind(self):
"""
Return the kind of transition: Cut, Wipe, etc
"""
if self.cut: if self.cut:
return Transition.Cut return Transition.Cut
elif self.dissolve: elif self.dissolve:
@@ -216,7 +285,7 @@ class Transition:
@property @property
def effect_duration(self): 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. In the event of a key event, this is the duration of the fade in.
""" """

View File

@@ -23,14 +23,16 @@ StmtMotionMemory = namedtuple("MotionMemory",["source","fps"]) # FIXME needs mor
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"]) StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
def parse_cmx3600_statements(path): def parse_cmx3600_statements(file):
with open(path,'r') as file: """
lines = file.readlines() Return a list of every statement in the file argument.
line_numbers = count() """
return [parse_cmx3600_line(line.strip(), line_number) \ lines = file.readlines()
for (line, line_number) in zip(lines,line_numbers)] 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, return [event_field_length,2, source_field_length,1,
4,2, # chans 4,2, # chans
4,1, # trans 4,1, # trans
@@ -40,63 +42,63 @@ def edl_column_widths(event_field_length, source_field_length):
11,1, 11,1,
11] 11]
def edl_m2_column_widths(): def _edl_m2_column_widths():
return [2, # "M2" return [2, # "M2"
3,3, # 3,3, #
8,8,1,4,2,1,4,13,3,1,1] 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} ")
if isinstance(line,str): if isinstance(line,str):
if line.startswith("TITLE:"): if line.startswith("TITLE:"):
return parse_title(line,line_number) return _parse_title(line,line_number)
elif line.startswith("FCM:"): elif line.startswith("FCM:"):
return parse_fcm(line, line_number) return _parse_fcm(line, line_number)
elif long_event_num_p.match(line) != None: 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: 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: 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: 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"): elif line.startswith("AUD"):
return parse_extended_audio_channels(line,line_number) return _parse_extended_audio_channels(line,line_number)
elif line.startswith("*"): elif line.startswith("*"):
return parse_remark( line[1:].strip(), line_number) return _parse_remark( line[1:].strip(), line_number)
elif line.startswith(">>>"): elif line.startswith(">>>"):
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:"): elif line.startswith("SPLIT:"):
return parse_split(line, line_number) return _parse_split(line, line_number)
elif line.startswith("M2"): elif line.startswith("M2"):
return parse_motion_memory(line, line_number) return _parse_motion_memory(line, line_number)
else: 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() title = line[6:].strip()
return StmtTitle(title=title,line_number=line_num) return StmtTitle(title=title,line_number=line_num)
def parse_fcm(line, line_num): def _parse_fcm(line, line_num):
val = line[4:].strip() val = line[4:].strip()
if val == "DROP FRAME": if val == "DROP FRAME":
return StmtFCM(drop= True, line_number=line_num) return StmtFCM(drop= True, line_number=line_num)
else: else:
return StmtFCM(drop= False, line_number=line_num) return StmtFCM(drop= False, line_number=line_num)
def parse_long_standard_form(line,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) return _parse_columns_for_standard_form(line, 6, source_field_length, line_number)
def parse_standard_form(line, line_number): def _parse_standard_form(line, line_number):
return parse_columns_for_standard_form(line, 3, 8, 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() content = line.strip()
if content == "AUD 3": if content == "AUD 3":
return StmtAudioExt(audio3=True, audio4=False, line_number=line_number) return StmtAudioExt(audio3=True, audio4=False, line_number=line_number)
@@ -107,7 +109,7 @@ def parse_extended_audio_channels(line, line_number):
else: else:
return StmtUnrecognized(content=line, line_number=line_number) 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:"): if line.startswith("FROM CLIP NAME:"):
return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number) return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number)
elif line.startswith("TO CLIP NAME:"): elif line.startswith("TO CLIP NAME:"):
@@ -117,11 +119,11 @@ def parse_remark(line, line_number):
else: else:
return StmtRemark(text=line, line_number=line_number) 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() 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): def _parse_split(line, line_number):
split_type = line[10:21] split_type = line[10:21]
is_video = False is_video = False
if split_type.startswith("VIDEO"): 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) 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="") 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)
def parse_columns_for_standard_form(line, event_field_length, source_field_length, 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) col_widths = _edl_column_widths(event_field_length, source_field_length)
if sum(col_widths) > len(line): if sum(col_widths) > len(line):
return StmtUnrecognized(content=line, line_number=line_number) 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) line_number=line_number)
def parse_trailer_statement(line, line_number): def _parse_trailer_statement(line, line_number):
trimmed = line[3:].strip() trimmed = line[3:].strip()
return StmtTrailer(trimmed, line_number=line_number) return StmtTrailer(trimmed, line_number=line_number)

View File

@@ -4,7 +4,22 @@
# Utility functions # Utility functions
def collimate(a_string, column_widths): 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: if len(column_widths) == 0:
return [] return []
@@ -14,51 +29,3 @@ def collimate(a_string, column_widths):
rest = a_string[width:] rest = a_string[width:]
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) )

View File

@@ -14,61 +14,61 @@ class TestParse(TestCase):
counts = [ 287, 466, 250 , 376, 120 ] counts = [ 287, 466, 250 , 376, 120 ]
for fn, count in zip(files, counts): for fn, count in zip(files, counts):
edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" ) with open(f"tests/edls/{fn}" ,'r') as f:
actual = len(list(edl.events )) edl = pycmx.parse_cmx3600(f)
self.assertTrue( actual == count , f"expected {count} in file {fn} 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): def test_events(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") with open("tests/edls/TEST.edl",'r') as f:
events = list( edl.events ) edl = pycmx.parse_cmx3600(f)
events = list( edl.events )
self.assertEqual( int(events[0].number) , 1) self.assertEqual( int(events[0].number) , 1)
self.assertEqual( events[0].edits[0].source , "OY_HEAD_") 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].clip_name , "HEAD LEADER MONO")
self.assertEqual( events[0].edits[0].source_file , "OY_HEAD_LEADER.MOV") 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_in , "00:00:00:00")
self.assertEqual( events[0].edits[0].source_out , "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_in , "01:00:00:00")
self.assertEqual( events[0].edits[0].record_out , "01:00:08:00") self.assertEqual( events[0].edits[0].record_out , "01:00:08:00")
self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut) self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut)
def test_channel_mop(self): def test_channel_mop(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") with open("tests/edls/TEST.edl",'r') as f:
events = list( edl.events ) edl = pycmx.parse_cmx3600(f)
self.assertFalse( events[0].edits[0].channels.video) events = list( edl.events )
self.assertFalse( events[0].edits[0].channels.a1) self.assertFalse( events[0].edits[0].channels.video)
self.assertTrue( events[0].edits[0].channels.a2) self.assertFalse( events[0].edits[0].channels.a1)
self.assertTrue( events[2].edits[0].channels.get_audio_channel(7) ) 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): def test_multi_edit_events(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") with open("tests/edls/TEST.edl",'r') as f:
events = list( edl.events ) 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( events[42].edits[0].source , "TC_R1_V1")
self.assertEqual( len(events[42].edits), 2) 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[0].source , "TC_R1_V1") self.assertEqual( events[42].edits[1].clip_name , "TC R1 V6 TEMP2 ST FX.WAV")
self.assertEqual( events[42].edits[0].clip_name , "TC R1 V1.2 TEMP1 FX ST.WAV") self.assertEqual( events[42].edits[1].source_in , "00:00:00:00")
self.assertEqual( events[42].edits[0].source_in , "00:00:00:00") self.assertEqual( events[42].edits[1].source_out , "00:00:00:00")
self.assertEqual( events[42].edits[0].source_out , "00:00:00:00") self.assertEqual( events[42].edits[1].record_in , "01:08:56:09")
self.assertEqual( events[42].edits[0].record_in , "01:08:56:09") self.assertEqual( events[42].edits[1].record_out , "01:08:56:11")
self.assertEqual( events[42].edits[0].record_out , "01:08:56:09") self.assertTrue( events[42].edits[1].transition.kind == pycmx.Transition.Dissolve)
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)