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 . import parse_cmx_events

View File

@@ -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

View File

@@ -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):
"""
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.
"""

View File

@@ -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:
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) \
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)

View File

@@ -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) )

View File

@@ -14,15 +14,16 @@ class TestParse(TestCase):
counts = [ 287, 466, 250 , 376, 120 ]
for fn, count in zip(files, counts):
edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" )
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")
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)
@@ -36,7 +37,8 @@ class TestParse(TestCase):
self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut)
def test_channel_mop(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
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)
@@ -45,14 +47,13 @@ class TestParse(TestCase):
def test_multi_edit_events(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
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( 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")
@@ -61,7 +62,6 @@ class TestParse(TestCase):
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")