Merge branch 'jamie'

This commit is contained in:
Jamie Hardt
2018-12-29 14:09:27 -08:00
9 changed files with 386 additions and 338 deletions

View File

@@ -11,6 +11,7 @@ distribution.
__version__ = '0.8' __version__ = '0.8'
__author__ = 'Jamie Hardt' __author__ = 'Jamie Hardt'
from .parse_cmx_events import parse_cmx3600, Transition, Event, Edit from .parse_cmx_events import parse_cmx3600
from . import parse_cmx_events from .transition import Transition
from .event import Event
from .edit import Edit

View File

@@ -4,12 +4,12 @@
from re import (compile, match) from re import (compile, match)
class ChannelMap: 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),

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 # pycmx
# (c) 2018 Jamie Hardt # (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 collections import namedtuple
from .parse_cmx_statements import (parse_cmx3600_statements, StmtEvent,StmtFCM )
from .edit_list import EditList
def parse_cmx3600(f): def parse_cmx3600(f):
""" """
Parse a CMX 3600 EDL. Parse a CMX 3600 EDL.
@@ -16,333 +14,8 @@ def parse_cmx3600(f):
f : a file-like object, anything that's readlines-able. f : a file-like object, anything that's readlines-able.
Returns: Returns:
An :obj:`EditList`. An :class:`EditList`.
""" """
statements = parse_cmx3600_statements(f) statements = parse_cmx3600_statements(f)
return EditList(statements) 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 # pycmx
# (c) 2018 Jamie Hardt # (c) 2018 Jamie Hardt
from .util import collimate
import re import re
import sys import sys
from collections import namedtuple from collections import namedtuple
from itertools import count from itertools import count
from .util import collimate
StmtTitle = namedtuple("Title",["title","line_number"]) StmtTitle = namedtuple("Title",["title","line_number"])
StmtFCM = namedtuple("FCM",["drop","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

@@ -99,4 +99,8 @@ class TestParse(TestCase):
self.assertEqual( events[14].edits[0].line_number, 45) self.assertEqual( events[14].edits[0].line_number, 45)
self.assertEqual( events[180].edits[0].line_number, 544) 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" )