11 Commits
v0.7 ... v0.8

Author SHA1 Message Date
Jamie Hardt
23667fb782 Merge branch 'jamie' 2018-12-29 14:09:27 -08:00
Jamie Hardt
ce31cbb879 Read transition names 2018-12-29 14:06:22 -08:00
Jamie Hardt
914e8d5525 Blank lines 2018-12-29 13:14:52 -08:00
Jamie Hardt
21f8880099 Update edit.py
Black and Aux Source events
2018-12-29 12:53:18 -08:00
Jamie Hardt
348962c3f7 Code Style/Blank lines 2018-12-29 12:53:08 -08:00
Jamie Hardt
5e902d4926 Reorganized classes into separate files 2018-12-29 12:29:46 -08:00
Jamie Hardt
44f751fd75 Merge branch release into master 2018-12-28 23:09:02 -08:00
Jamie Hardt
16e8754a7b Merge branch master into release 2018-12-28 23:08:22 -08:00
Jamie Hardt
fbf55ec2e6 test tweaks
added a test and removed superfluous init file
2018-12-28 22:51:09 -08:00
Jamie Hardt
1fab2c3d71 channel_map.py
There was a really obvious typo
2018-12-28 22:15:27 -08:00
Jamie Hardt
28c2344a53 Version nudge
version 0.8 is next up
2018-12-26 22:42:12 -08:00
11 changed files with 390 additions and 341 deletions

View File

@@ -8,9 +8,10 @@ copy and reuse this software, refer to the LICENSE file included with the
distribution.
"""
__version__ = '0.7'
__version__ = '0.8'
__author__ = 'Jamie Hardt'
from .parse_cmx_events import parse_cmx3600, Transition, Event, Edit
from . import parse_cmx_events
from .parse_cmx_events import parse_cmx3600
from .transition import Transition
from .event import Event
from .edit import Edit

View File

@@ -4,12 +4,12 @@
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),
_chan_map = {
"V" : (True, False, False),
"A" : (False, True, False),
"A2" : (False, False, True),
"AA" : (False, True, True),
@@ -92,7 +92,7 @@ class ChannelMap:
if matchresult:
self.set_audio_channel(int( matchresult.group(1)), True )
def _append_sxt(self, audio_ext):
def _append_ext(self, audio_ext):
self.a3 = ext.audio3
self.a4 = ext.audio4

126
pycmx/edit.py Normal file
View File

@@ -0,0 +1,126 @@
# pycmx
# (c) 2018 Jamie Hardt
from .transition import Transition
from .channel_map import ChannelMap
from .parse_cmx_statements import StmtEffectsName
class Edit:
"""
An individual source-to-record operation, with a source roll, source and
recorder timecode in and out, a transition and channels.
"""
def __init__(self, edit_statement, audio_ext_statement, clip_name_statement, source_file_statement, trans_name_statement = None):
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
self.trans_name_statement = trans_name_statement
@property
def line_number(self):
"""
Get the line number for the "standard form" statement associated with
this edit. Line numbers a zero-indexed, such that the
"TITLE:" record is line zero.
"""
return self.edit_statement.line_number
@property
def channels(self):
"""
Get the :obj:`ChannelMap` object associated with this Edit.
"""
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):
"""
Get the :obj:`Transition` object associated with this edit.
"""
if self.trans_name_statement:
return Transition(self.edit_statement.trans, self.edit_statement.trans_op, self.trans_name_statement.name)
else:
return Transition(self.edit_statement.trans, self.edit_statement.trans_op, None)
@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 black(self):
"""
Black video or silence should be used as the source for this event.
"""
return self.source == "BL"
@property
def aux_source(self):
"""
An auxiliary source is the source of this event.
"""
return self.source == "AX"
@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 is None:
return None
else:
return self.clip_name_statement.name

61
pycmx/edit_list.py Normal file
View File

@@ -0,0 +1,61 @@
# pycmx
# (c) 2018 Jamie Hardt
from .parse_cmx_statements import (StmtUnrecognized, StmtFCM, StmtEvent)
from .event import Event
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
@property
def unrecognized_statements(self):
"""
A generator for all the unrecognized statements in the list.
"""
for s in self.event_statements:
if type(s) is StmtUnrecognized:
yield s
@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)

97
pycmx/event.py Normal file
View File

@@ -0,0 +1,97 @@
# pycmx
# (c) 2018 Jamie Hardt
from .parse_cmx_statements import (StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized, StmtEffectsName)
from .edit import Edit
class Event:
"""
Represents a collection of :class:`Edit`s, all with the same event number.
"""
def __init__(self, statements):
self.statements = statements
@property
def number(self):
"""
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()
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) )
# attach trans name to last event
try:
trans_statement = self._trans_name_statements()[0]
trans_names = [None] * (len(edits_audio) - 1)
trans_names.append(trans_statement)
the_zip.append(trans_names)
except IndexError:
the_zip.append([None] * len(edits_audio) )
return [ Edit(e1[0],e1[1],n1,s1,u1) for (e1,n1,s1,u1) in zip(*the_zip) ]
@property
def unrecognized_statements(self):
"""
A generator for all the unrecognized statements in the event.
"""
for s in self.statements:
if type(s) is StmtUnrecognized:
yield s
def _trans_name_statements(self):
return [s for s in self.statements if type(s) is StmtEffectsName]
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)

View File

@@ -1,13 +1,11 @@
# pycmx
# (c) 2018 Jamie Hardt
from .parse_cmx_statements import (parse_cmx3600_statements,
StmtEvent, StmtFCM, StmtTitle, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized)
from .channel_map import ChannelMap
from collections import namedtuple
from .parse_cmx_statements import (parse_cmx3600_statements, StmtEvent,StmtFCM )
from .edit_list import EditList
def parse_cmx3600(f):
"""
Parse a CMX 3600 EDL.
@@ -16,333 +14,8 @@ def parse_cmx3600(f):
f : a file-like object, anything that's readlines-able.
Returns:
An :obj:`EditList`.
An :class:`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
@property
def unrecognized_statements(self):
"""
A generator for all the unrecognized statements in the list.
"""
for s in self.event_statements:
if type(s) is StmtUnrecognized:
yield s
@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, other_statements = []):
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
self.other_statements = other_statements
@property
def line_number(self):
"""
Get the line number for the "standard form" statement associated with
this edit. Line numbers a zero-indexed, such that the
"TITLE:" record is line zero.
"""
return self.edit_statement.line_number
@property
def channels(self):
"""
Get the :obj:`ChannelMap` object associated with this Edit.
"""
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):
"""
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:
return None
class Event:
"""
Represents a collection of :obj:`Edit`s, all with the same event number.
"""
def __init__(self, statements):
self.statements = statements
@property
def number(self):
"""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()
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) ]
@property
def unrecognized_statements(self):
"""
A generator for all the unrecognized statements in the event.
"""
for s in self.statements:
if type(s) is StmtUnrecognized:
yield s
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):
"""
Return the kind of transition: Cut, Wipe, etc
"""
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,12 +1,12 @@
# pycmx
# (c) 2018 Jamie Hardt
from .util import collimate
import re
import sys
from collections import namedtuple
from itertools import count
from .util import collimate
StmtTitle = namedtuple("Title",["title","line_number"])
StmtFCM = namedtuple("FCM",["drop","line_number"])

86
pycmx/transition.py Normal file
View File

@@ -0,0 +1,86 @@
# pycmx
# (c) 2018 Jamie Hardt
class Transition:
"""
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, name=None):
self.transition = transition
self.operand = operand
self.name = name
@property
def kind(self):
"""
Return the kind of transition: Cut, Wipe, etc
"""
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 edit is a key background."
return self.transition == KeyBackground
@property
def key_foreground(self):
"`True` if this edit is a key foreground."
return self.transition == Key
@property
def key_out(self):
"""
`True` if this edit is a key out. This material will removed from
the key foreground and replaced with the key background.
"""
return self.transition == KeyOut

View File

@@ -4,7 +4,7 @@ with open("README.md", "r") as fh:
long_description = fh.read()
setup(name='pycmx',
version='0.7',
version='0.8',
author='Jamie Hardt',
author_email='jamiehardt@me.com',
description='CMX 3600 Edit Decision List Parser',

View File

View File

@@ -28,6 +28,7 @@ class TestParse(TestCase):
with open(f"tests/edls/{fn}" ,'r') as f:
edl = pycmx.parse_cmx3600(f)
self.assertTrue( type(edl.title) is str )
self.assertTrue( len(edl.title) > 0 )
def test_event_sanity(self):
@@ -98,4 +99,8 @@ class TestParse(TestCase):
self.assertEqual( events[14].edits[0].line_number, 45)
self.assertEqual( events[180].edits[0].line_number, 544)
def test_transition_name(self):
with open("tests/edls/test_25.edl","r") as f:
edl = pycmx.parse_cmx3600(f)
events = list(edl.events)
self.assertEqual( events[4].edits[1].transition.name , "CROSS DISSOLVE" )