53 Commits
v0.4 ... v0.6

Author SHA1 Message Date
Jamie Hardt
15d14914ea Update README.md
Documentation on channels
2018-12-24 16:02:37 -08:00
Jamie Hardt
f44d5c470c ChannelMaps
Test implementation
2018-12-24 15:58:14 -08:00
Jamie Hardt
ca873af772 Adding channel map
First test works
2018-12-24 15:55:37 -08:00
Jamie Hardt
ab40ba1fa0 Update README.md 2018-12-24 14:37:37 -08:00
Jamie Hardt
782b9f7425 Update README.md 2018-12-24 14:36:54 -08:00
Jamie Hardt
483efdcc32 Update README.md 2018-12-24 14:33:03 -08:00
Jamie Hardt
6867f9ac4a Update README.md 2018-12-24 14:32:31 -08:00
Jamie Hardt
24272569e3 Update README.md 2018-12-24 14:30:35 -08:00
Jamie Hardt
16afb8fc64 Update parse_cmx_events.py
Made title a property
2018-12-24 14:30:31 -08:00
Jamie Hardt
d1e3eb85d3 Expose TC and transitions on class 2018-12-24 14:16:56 -08:00
Jamie Hardt
8d3bef2c09 Event reimplementation
Implementation of events and edits
2018-12-24 13:25:21 -08:00
Jamie Hardt
e0b7025fff Update parse_cmx_events.py
More implementation
2018-12-24 11:17:47 -08:00
Jamie Hardt
fbe9e9eeb9 Update parse_cmx_events.py
Tweaked loop initialization to make more clear
2018-12-24 02:03:43 -08:00
Jamie Hardt
168fd16473 Implementing events
Statements separated into events
2018-12-24 02:02:30 -08:00
Jamie Hardt
e4b6036ab7 Deleted parse_cmx etc
Going to reimplement these
2018-12-24 00:49:00 -08:00
Jamie Hardt
ce3d8088a1 Update parse_cmx_statements.py
Tweaked code style of tuple declarations
2018-12-24 00:30:06 -08:00
Jamie Hardt
9f41758b37 More Audio Test EDLs 2018-12-22 22:38:08 -08:00
Jamie Hardt
07407baf96 Added split events, some implementation of M2s 2018-12-12 16:10:16 -06:00
Jamie Hardt
aa309a4458 Ignore swapfiles 2018-12-12 16:09:48 -06:00
Jamie Hardt
2b8dd4c1c9 Typos 2018-12-10 23:46:44 -06:00
Jamie Hardt
387158b07c typo 2018-12-10 23:29:49 -06:00
Jamie Hardt
741c9d95e8 Moved TupleParser to util 2018-12-10 23:27:44 -06:00
Jamie Hardt
53764900ba Update parse_cmx.py
Cleared out blank lines
2018-12-10 23:19:04 -06:00
Jamie Hardt
66791081be Added some docstrings 2018-12-08 05:13:42 -08:00
Jamie Hardt
5e49c19ac2 Added kind method to CmxTransition 2018-12-07 10:34:02 -08:00
Jamie Hardt
4593729e3a Merge branch 'master' of github.com:iluvcapra/pycmx 2018-12-07 10:00:30 -08:00
Jamie Hardt
703ba1140a Added some docs 2018-12-07 10:00:03 -08:00
Jamie Hardt
0f06c4de5c Updated version to 0.6 2018-12-05 17:07:55 -08:00
Jamie Hardt
920af8a86d Updated README 2018-12-05 17:07:00 -08:00
Jamie Hardt
57ea48e5e8 Improved __repr__() methods 2018-12-05 16:49:54 -08:00
Jamie Hardt
7e13978d9a Added line numbers to statement parser 2018-12-05 16:36:46 -08:00
Jamie Hardt
f358704139 Travis 2018-12-05 16:19:14 -08:00
Jamie Hardt
6201633956 Travis 2018-12-05 12:17:30 -08:00
Jamie Hardt
2924ea548b Travis tweak 2018-12-05 12:14:55 -08:00
Jamie Hardt
88bf68c78e .travis.yml 2018-12-05 12:03:34 -08:00
Jamie Hardt
6d1ca12e42 Some work in the travis file and adding line numbers to parsing statements 2018-12-05 11:59:00 -08:00
Jamie Hardt
829d98f4b4 Tweaking travis.yml 2018-12-05 11:46:48 -08:00
Jamie Hardt
8969e31969 Tweaking travis.yml 2018-12-05 11:43:46 -08:00
Jamie Hardt
484d2ae98f Tweaking travis.yml 2018-12-05 11:41:15 -08:00
Jamie Hardt
07652eaaa8 Tweaking travis.yml 2018-12-05 11:38:37 -08:00
Jamie Hardt
a9124e1f97 Adding .travis.yml 2018-12-05 11:35:33 -08:00
Jamie Hardt
7e709241f8 Revert "Adding a travis config script"
This reverts commit 597dceb9c5.
2018-12-05 11:35:33 -08:00
Jamie Hardt
731b8fcc00 Added Travis Status image 2018-12-05 11:31:38 -08:00
Jamie Hardt
597dceb9c5 Adding a travis config script 2018-12-05 11:23:10 -08:00
Jamie Hardt
c2c83d826a Added version symbol to __init__ 2018-12-02 21:04:39 -08:00
Jamie Hardt
08dd1f956d Implemented better logic for reading event-modifying lines 2018-12-01 15:33:50 -08:00
Jamie Hardt
989d52aaee CmxEvent and CmxTransition implementation 2018-12-01 15:15:09 -08:00
Jamie Hardt
b60610aa8b Some infrastructure for CmxTransitions 2018-12-01 14:35:26 -08:00
Jamie Hardt
6611e38b9f modified copyright notices 2018-12-01 14:16:54 -08:00
Jamie Hardt
abcce06865 Added some more test files from other projects 2018-12-01 13:52:26 -08:00
Jamie Hardt
9d586342be Roadmap note 2018-12-01 13:10:10 -08:00
Jamie Hardt
e5f632d8a4 Updated setup, mnudge to version 0.5 2018-12-01 13:08:16 -08:00
Jamie Hardt
30cd99431b updated code example 2018-12-01 13:05:58 -08:00
20 changed files with 9144 additions and 301 deletions

3
.gitignore vendored
View File

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

7
.travis.yml Normal file
View File

@@ -0,0 +1,7 @@
language: python
python:
- "3.6"
script:
- "python3 setup.py test"
install:
- "pip3 install setuptools"

View File

@@ -1,3 +1,5 @@
[![Build Status](https://travis-ci.com/iluvcapra/pycmx.svg?branch=master)](https://travis-ci.com/iluvcapra/pycmx)
# pycmx # pycmx
The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and its most most common variations. The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and its most most common variations.
@@ -6,50 +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
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,a1=False,a2=False,a3=False,a4=False), # 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,a1=False,a2=False,a3=False,a4=False), '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,a1=False,a2=False,a3=False,a4=False), # 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.
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

View File

@@ -1,3 +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.6'

92
pycmx/channel_map.py Normal file
View 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

View File

@@ -1,17 +0,0 @@
class CmxEvent:
def __init__(self,title,number,clip_name,source_name,channels,source_start,source_finish,
record_start, record_finish, fcm_drop, remarks = [] , unrecognized = []):
self.title = title
self.number = number
self.clip_name = clip_name
self.source_name = source_name
self.channels = channels
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

View File

@@ -1,179 +0,0 @@
# pycmx
# (c) 2018 Jamie Hardt
from .parse_cmx_statements import parse_cmx3600_statements
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}, audio_channels={self._audio_channel_set})"
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)
CmxEvent = namedtuple('CmxEvent',['title','number','clip_name',
'source_name','channels','source_start','source_finish','record_start',
'record_finish','fcm_drop'])
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:
event_t = CmxEvent(**this_event)
events_result.append(event_t)
raw_event = parser.current_token
channels = CmxChannelMap(v=False, audio_channels=set([]))
channels.appendEvent(raw_event.channels)
this_event = {'title': title, 'number': raw_event.event, 'clip_name': None ,
'source_name': raw_event.source,
'channels': channels,
'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']}
elif parser.accept('AudioExt'):
this_event['channels'].appendExt(parser.current_token)
elif parser.accept('ClipName'):
this_event['clip_name'] = parser.current_token.name
elif parser.accept('SourceFile'):
this_event['source_name'] = parser.current_token.filename
elif parser.accept('Trailer'):
break
else:
parser.next_token()
if this_event != None:
event_t = CmxEvent(**this_event)
events_result.append(event_t)
return events_result

247
pycmx/parse_cmx_events.py Normal file
View 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

View File

@@ -1,29 +1,34 @@
# 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
import sys import sys
from collections import namedtuple from collections import namedtuple
from itertools import count
StmtTitle = namedtuple("Title",["title"]) StmtTitle = namedtuple("Title",["title","line_number"])
StmtFCM = namedtuple("FCM",["drop"]) StmtFCM = namedtuple("FCM",["drop","line_number"])
StmtEvent = namedtuple("Event",["event","source","channels","trans","trans_op","source_in","source_out","record_in","record_out"]) StmtEvent = namedtuple("Event",["event","source","channels","trans",\
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4"]) "trans_op","source_in","source_out","record_in","record_out","line_number"])
StmtClipName = namedtuple("ClipName",["name"]) StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
StmtSourceFile = namedtuple("SourceFile",["filename"]) StmtClipName = namedtuple("ClipName",["name","affect","line_number"])
StmtRemark = namedtuple("Remark",["text"]) StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
StmtTrailer = namedtuple("Trailer",["text"]) StmtRemark = namedtuple("Remark",["text","line_number"])
StmtUnrecognized = namedtuple("Unrecognized",["content"]) StmtEffectsName = namedtuple("EffectsName",["name","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"])
def parse_cmx3600_statements(path): def parse_cmx3600_statements(path):
with open(path,'r') as file: with open(path,'r') as file:
lines = file.readlines() lines = file.readlines()
return [parse_cmx3600_line(line.strip()) for line in lines] 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,
@@ -34,78 +39,110 @@ def edl_column_widths(event_field_length, source_field_length):
11,1, 11,1,
11,1, 11,1,
11] 11]
def parse_cmx3600_line(line): 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):
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) return parse_title(line,line_number)
elif line.startswith("FCM:"): elif line.startswith("FCM:"):
return parse_fcm(line) 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) return parse_long_standard_form(line, 32, line_number)
else: else:
return parse_long_standard_form(line, 128) 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) return parse_standard_form(line, line_number)
elif line.startswith("AUD"): elif line.startswith("AUD"):
return parse_extended_audio_channels(line) return parse_extended_audio_channels(line,line_number)
elif line.startswith("*"): elif line.startswith("*"):
return parse_remark( line[1:].strip()) return parse_remark( line[1:].strip(), line_number)
elif line.startswith(">>>"): elif line.startswith(">>>"):
return parse_trailer_statement(line) return parse_trailer_statement(line, line_number)
elif line.startswith("EFFECTS NAME IS"):
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) return parse_unrecognized(line, line_number)
def parse_title(line): def parse_title(line, line_num):
title = line[6:].strip() title = line[6:].strip()
return StmtTitle(title=title) return StmtTitle(title=title,line_number=line_num)
def parse_fcm(line): 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) return StmtFCM(drop= True, line_number=line_num)
else: else:
return StmtFCM(drop= False) return StmtFCM(drop= False, line_number=line_num)
def parse_long_standard_form(line,source_field_length): def parse_long_standard_form(line,source_field_length, line_number):
return parse_columns_for_standard_form(line, 6, source_field_length) return parse_columns_for_standard_form(line, 6, source_field_length, line_number)
def parse_standard_form(line): def parse_standard_form(line, line_number):
return parse_columns_for_standard_form(line, 3, 8) return parse_columns_for_standard_form(line, 3, 8, line_number)
def parse_extended_audio_channels(line): 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) return StmtAudioExt(audio3=True, audio4=False, line_number=line_number)
elif content == "AUD 4": elif content == "AUD 4":
return StmtAudioExt(audio3=False, audio4=True) return StmtAudioExt(audio3=False, audio4=True, line_number=line_number)
elif content == "AUD 3 4": elif content == "AUD 3 4":
return StmtAudioExt(audio3=True, audio4=True) return StmtAudioExt(audio3=True, audio4=True, line_number=line_number)
else: else:
return StmtUnrecognized(content=line) return StmtUnrecognized(content=line, line_number=line_number)
def parse_remark(line): def parse_remark(line, line_number):
if line.startswith("FROM CLIP NAME:"): if line.startswith("FROM CLIP NAME:"):
return StmtClipName(name=line[15:].strip() ) 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() ) return StmtSourceFile(filename=line[12:].strip() , line_number=line_number)
else: else:
return StmtRemark(text=line) return StmtRemark(text=line, line_number=line_number)
def parse_unrecognized(line): def parse_effects_name(line, line_number):
return StmtUnrecognized(content=line) name = line[16:].strip()
return StmtEffectsName(name=name, line_number=line_number)
def parse_columns_for_standard_form(line, event_field_length, source_field_length): 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):
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) 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) return StmtUnrecognized(content=line, line_number=line_number)
column_strings = collimate(line,col_widths) column_strings = collimate(line,col_widths)
@@ -117,10 +154,11 @@ def parse_columns_for_standard_form(line, event_field_length, source_field_lengt
source_in=column_strings[10].strip(), source_in=column_strings[10].strip(),
source_out=column_strings[12].strip(), source_out=column_strings[12].strip(),
record_in=column_strings[14].strip(), record_in=column_strings[14].strip(),
record_out=column_strings[16].strip()) record_out=column_strings[16].strip(),
line_number=line_number)
def parse_trailer_statement(line): def parse_trailer_statement(line, line_number):
trimmed = line[3:].strip() trimmed = line[3:].strip()
return StmtTrailer(trimmed) return StmtTrailer(trimmed, line_number=line_number)

View File

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

View File

@@ -4,14 +4,14 @@ with open("README.md", "r") as fh:
long_description = fh.read() long_description = fh.read()
setup(name='pycmx', setup(name='pycmx',
version='0.4', 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',
long_description_content_type="text/markdown", long_description_content_type="text/markdown",
long_description=long_description, long_description=long_description,
url='https://github.com/iluvcapra/pycmx', url='https://github.com/iluvcapra/pycmx',
classifiers=['Development Status :: 3 - Alpha', classifiers=['Development Status :: 4 - Beta',
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Topic :: Multimedia', 'Topic :: Multimedia',
'Topic :: Multimedia :: Video', 'Topic :: Multimedia :: Video',

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

60
tests/edls/test_24psf.edl Executable file
View File

@@ -0,0 +1,60 @@
TITLE: Test EDL 24
001 AX V C 01:00:00:00 01:00:59:24 00:00:00:00 00:00:59:24
* FROM CLIP NAME: clip 1
002 AX AA C 00:00:00:00 00:01:30:00 00:00:00:00 00:01:30:00
* FROM CLIP NAME: clip #2
003 AX V C 00:00:00:00 00:00:30:01 00:00:59:24 00:01:30:00
* FROM CLIP NAME: clip -3
AUD 3 4
004 AX V C 00:00:00:00 00:00:24:17 00:01:30:00 00:01:54:17
* FROM CLIP NAME: clip $4
005 AX V C 00:00:00:00 00:00:24:17 00:01:30:00 00:01:54:17
* FROM CLIP NAME: clip &5
006 AX AA C 00:00:29:01 00:00:29:01 00:01:59:01 00:01:59:01
006 BL AA W001 025 00:00:00:00 00:00:10:20 00:01:59:01 00:02:10:21
EFFECTS NAME IS Constant Power
* FROM CLIP NAME: Test rename
* TO CLIP NAME: BL
007 AX V C 01:00:00:00 01:00:05:00 00:02:01:10 00:02:06:10
* FROM CLIP NAME: Black Video
008 AX V C 01:00:10:14 01:00:15:00 00:02:06:10 00:02:10:21
* FROM CLIP NAME: Jellyfish.jpg
009 AX V C 00:00:00:00 00:00:30:01 00:02:10:21 00:02:40:22
* FROM CLIP NAME: Test rename
M2 AX -25.0 00:00:00:00
010 AX AA C 00:00:00:00 00:00:30:01 00:02:10:21 00:02:40:22
* FROM CLIP NAME: Test rename
M2 AX -25.0 00:00:00:00
REM The MIT License (MIT)
REM
REM Copyright (c) 2013 <simon@simon-hargreaves.com>
REM
REM Permission is hereby granted, free of charge, to any person obtaining a copy
REM of this software and associated documentation files (the "Software"), to deal
REM in the Software without restriction, including without limitation the rights
REM to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
REM copies of the Software, and to permit persons to whom the Software is
REM furnished to do so, subject to the following conditions:
REM
REM The above copyright notice and this permission notice shall be included in
REM all copies or substantial portions of the Software.
REM
REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
REM IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
REM FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
REM AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
REM LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
REM OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
REM THE SOFTWARE.
REM

66
tests/edls/test_25.edl Executable file
View File

@@ -0,0 +1,66 @@
TITLE: Sequence 01
001 AX V C 01:00:00:00 01:00:59:24 00:00:00:00 00:00:59:24
* FROM CLIP NAME: Jellyfish.jpg
002 AX AA C 00:00:00:00 00:01:30:00 00:00:00:00 00:01:30:00
* FROM CLIP NAME: N5_final screensaver_mp4.mov
003 AX V C 00:00:00:00 00:00:30:01 00:00:59:24 00:01:30:00
* FROM CLIP NAME: Test rename
AUD 3 4
004 AX V C 00:00:00:00 00:00:24:17 00:01:30:00 00:01:54:17
* FROM CLIP NAME: Test rename
005 AX V C 00:00:24:17 00:00:24:17 00:01:54:17 00:01:54:17
005 AX V D 070 00:59:58:21 01:00:05:14 00:01:54:17 00:02:01:10
EFFECTS NAME IS CROSS DISSOLVE
* FROM CLIP NAME: Test rename
* TO CLIP NAME: Jellyfish.jpg
006 AX AA C 00:00:00:00 00:00:29:01 00:01:30:00 00:01:59:01
* FROM CLIP NAME: Test rename
007 AX AA C 00:00:29:01 00:00:29:01 00:01:59:01 00:01:59:01
007 BL AA W001 025 00:00:00:00 00:00:10:20 00:01:59:01 00:02:10:21
EFFECTS NAME IS Constant Power
* FROM CLIP NAME: Test rename
* TO CLIP NAME: BL
008 AX V C 01:00:00:00 01:00:05:00 00:02:01:10 00:02:06:10
* FROM CLIP NAME: Black Video
009 AX V C 01:00:10:14 01:00:15:00 00:02:06:10 00:02:10:21
* FROM CLIP NAME: Jellyfish.jpg
010 AX V C 00:00:00:00 00:00:30:01 00:02:10:21 00:02:40:22
* FROM CLIP NAME: Test rename
M2 AX -25.0 00:00:00:00
011 AX AA C 00:00:00:00 00:00:30:01 00:02:10:21 00:02:40:22
* FROM CLIP NAME: Test rename
M2 AX -25.0 00:00:00:00
REM The MIT License (MIT)
REM
REM Copyright (c) 2013 <simon@simon-hargreaves.com>
REM
REM Permission is hereby granted, free of charge, to any person obtaining a copy
REM of this software and associated documentation files (the "Software"), to deal
REM in the Software without restriction, including without limitation the rights
REM to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
REM copies of the Software, and to permit persons to whom the Software is
REM furnished to do so, subject to the following conditions:
REM
REM The above copyright notice and this permission notice shall be included in
REM all copies or substantial portions of the Software.
REM
REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
REM IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
REM FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
REM AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
REM LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
REM OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
REM THE SOFTWARE.
REM

View File

@@ -0,0 +1,42 @@
TITLE: test_edl_cdl
FCM: NON-DROP FRAME
000001 BAAAAAAA_00001_ABCD V C 01:00:00:01 01:00:02:01 01:00:00:00 01:00:02:00
*ASC_SOP (0.8111 0.8112 0.8113)(0.2111 0.2112 0.2113)(1.8111 1.8112 1.8113)
*ASC_SAT 0.91
*Descript:
*FROM CLIP NAME: dra_001_0001_v0001
*LOC: 1:00:01:00 YELLOW DRA_001_0001
000002 BAAAAAAA_00001_ABCD V C 01:00:00:02 01:00:02:02 01:00:02:00 01:00:04:00
*ASC_SOP (0.8121 0.8122 0.8123)(0.2121 0.2122 0.2123)(1.8121 1.8122 1.8123)
*ASC_SAT 0.82
*Descript:
*FROM CLIP NAME: dra_001_0002_v0001
*LOC: 1:00:03:00 YELLOW DRA_001_0002
000003 BAAAAAAA_00001_ABCD V C 01:00:00:03 01:00:02:03 01:00:04:00 01:00:06:00
*ASC_SOP (0.8131 0.8132 0.8133)(0.2131 0.2132 0.2133)(1.8131 1.8132 1.8133)
*ASC_SAT 0.73
*Descript:
*FROM CLIP NAME: dra_001_0003_v0001
*LOC: 1:00:05:00 YELLOW DRA_001_0003
REM The MIT License (MIT)
REM
REM Copyright (c) 2014 Simon Hargreaves
REM
REM Permission is hereby granted, free of charge, to any person obtaining a copy
REM of this software and associated documentation files (the "Software"), to deal
REM in the Software without restriction, including without limitation the rights
REM to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
REM copies of the Software, and to permit persons to whom the Software is
REM furnished to do so, subject to the following conditions:
REM
REM The above copyright notice and this permission notice shall be included in all
REM copies or substantial portions of the Software.
REM
REM THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
REM IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
REM FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
REM AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
REM LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
REM OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
REM SOFTWARE.

View File

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