mirror of
https://github.com/iluvcapra/pycmx.git
synced 2025-12-31 08:50:54 +00:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80339267a | ||
|
|
27d1073f8c | ||
|
|
0840ade312 | ||
|
|
a52e4329ce | ||
|
|
47772b21d0 | ||
|
|
7b4a76448e | ||
|
|
c6c5d15e09 | ||
|
|
c439ea1fbe | ||
|
|
68f118ab6b | ||
|
|
a20491297e | ||
|
|
9d57c2d374 | ||
|
|
1882cc5308 | ||
|
|
c57fe94335 | ||
|
|
007661ef38 | ||
|
|
f34c6dd4db | ||
|
|
eb89708bab | ||
|
|
9fde608fa0 | ||
|
|
4c4ca428f2 | ||
|
|
fe4e3b9d85 | ||
|
|
119467a884 | ||
|
|
b5a3285e64 | ||
|
|
af1c532a67 | ||
|
|
23667fb782 | ||
|
|
ce31cbb879 | ||
|
|
914e8d5525 | ||
|
|
21f8880099 | ||
|
|
348962c3f7 | ||
|
|
5e902d4926 | ||
|
|
44f751fd75 | ||
|
|
16e8754a7b | ||
|
|
fbf55ec2e6 | ||
|
|
1fab2c3d71 | ||
|
|
28c2344a53 |
@@ -1,6 +1,7 @@
|
|||||||
language: python
|
language: python
|
||||||
python:
|
python:
|
||||||
- "3.6"
|
- "3.6"
|
||||||
|
- "3.5"
|
||||||
script:
|
script:
|
||||||
- "python3 setup.py test"
|
- "python3 setup.py test"
|
||||||
install:
|
install:
|
||||||
|
|||||||
25
README.md
25
README.md
@@ -1,4 +1,5 @@
|
|||||||
[](https://travis-ci.com/iluvcapra/pycmx) [](https://pycmx.readthedocs.io/en/latest/?badge=latest)
|
[](https://travis-ci.com/iluvcapra/pycmx)
|
||||||
|
[](https://pycmx.readthedocs.io/en/latest/?badge=latest)   [](https://pypi.org/project/pycmx/) 
|
||||||
|
|
||||||
|
|
||||||
# pycmx
|
# pycmx
|
||||||
@@ -82,28 +83,6 @@ Audio channel 7 is present
|
|||||||
False
|
False
|
||||||
```
|
```
|
||||||
|
|
||||||
## How is this different from `python-edl`?
|
|
||||||
|
|
||||||
There are two important differences between `import edl` and `import pycmx`
|
|
||||||
and motivated my development of this module.
|
|
||||||
|
|
||||||
1. The `pycmx` parser doesn't take timecode or framerates into account,
|
|
||||||
and strictly treats timecodes like opaque values. As far as `pycmx` is
|
|
||||||
concerend, they're just strings. This was done because in my experience,
|
|
||||||
the frame rate of an EDL is often difficult to precisely determine and
|
|
||||||
often the frame rate of various sources is different from the frame rate
|
|
||||||
of the target track.
|
|
||||||
|
|
||||||
In any event, timecodes in an EDL are a kind of *address* and are not
|
|
||||||
exactly scalar, they're meant to point to a particular block of video or
|
|
||||||
audio data on a medium and presuming that they refer to a real time, or
|
|
||||||
duration, or are convertible, etc. isn't always safe.
|
|
||||||
|
|
||||||
2. The `pycmx` parser reads event numbers and keeps track of which EDL rows
|
|
||||||
are meant to happen "at the same time," with two decks. This makes it
|
|
||||||
easier to reconstruct transition A/B clips, and read clip names from
|
|
||||||
such events appropriately.
|
|
||||||
|
|
||||||
## Should I Use This?
|
## Should I Use This?
|
||||||
|
|
||||||
At this time, this is (at best) beta software. I feel like the interface is
|
At this time, this is (at best) beta software. I feel like the interface is
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""
|
"""
|
||||||
pycmx is a module for parsing CMX 3600-style EDLs. For more information and
|
pycmx is a module for parsing CMX 3600-style EDLs. For more information and
|
||||||
examples see README.md
|
examples see README.md
|
||||||
|
|
||||||
This module (c) 2018 Jamie Hardt. For more information on your rights to
|
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
|
copy and reuse this software, refer to the LICENSE file included with the
|
||||||
distribution.
|
distribution.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = '0.7'
|
__version__ = '1.0'
|
||||||
__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
|
||||||
|
|||||||
@@ -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),
|
||||||
@@ -92,7 +92,15 @@ class ChannelMap:
|
|||||||
if matchresult:
|
if matchresult:
|
||||||
self.set_audio_channel(int( matchresult.group(1)), True )
|
self.set_audio_channel(int( matchresult.group(1)), True )
|
||||||
|
|
||||||
def _append_sxt(self, audio_ext):
|
def _append_ext(self, audio_ext):
|
||||||
self.a3 = ext.audio3
|
self.a3 = ext.audio3
|
||||||
self.a4 = ext.audio4
|
self.a4 = ext.audio4
|
||||||
|
|
||||||
|
def __or__(self, other):
|
||||||
|
"""
|
||||||
|
Return the logical union of this channel map with another
|
||||||
|
"""
|
||||||
|
out_v = self.video | other.video
|
||||||
|
out_a = self._audio_channel_set | other._audio_channel_set
|
||||||
|
|
||||||
|
return ChannelMap(v=out_v,audio_channels = out_a)
|
||||||
|
|||||||
126
pycmx/edit.py
Normal file
126
pycmx/edit.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
111
pycmx/edit_list.py
Normal file
111
pycmx/edit_list.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from .parse_cmx_statements import (StmtUnrecognized, StmtFCM, StmtEvent, StmtSourceUMID)
|
||||||
|
from .event import Event
|
||||||
|
from .channel_map import ChannelMap
|
||||||
|
|
||||||
|
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 format(self):
|
||||||
|
"""
|
||||||
|
The detected format of the EDL. Possible values are: `3600`,`File32`,
|
||||||
|
`File128`, and `unknown`
|
||||||
|
"""
|
||||||
|
first_event = next( (s for s in self.event_statements if type(s) is StmtEvent), None)
|
||||||
|
|
||||||
|
if first_event:
|
||||||
|
if first_event.format == 8:
|
||||||
|
return '3600'
|
||||||
|
elif first_event.format == 32:
|
||||||
|
return 'File32'
|
||||||
|
elif first_event.format == 128:
|
||||||
|
return 'File128'
|
||||||
|
else:
|
||||||
|
return 'unknown'
|
||||||
|
else:
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self):
|
||||||
|
"""
|
||||||
|
Return the union of every channel channel.
|
||||||
|
"""
|
||||||
|
|
||||||
|
retval = ChannelMap()
|
||||||
|
for event in self.events:
|
||||||
|
for edit in event.edits:
|
||||||
|
retval = retval | edit.channels
|
||||||
|
|
||||||
|
return retval
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
elif type(stmt) is StmtSourceUMID:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
event_statements.append(stmt)
|
||||||
|
|
||||||
|
yield Event(statements=event_statements)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sources(self):
|
||||||
|
"""
|
||||||
|
A generator for all of the sources in the list
|
||||||
|
"""
|
||||||
|
|
||||||
|
for stmt in self.event_statements:
|
||||||
|
if type(stmt) is StmtSourceUMID:
|
||||||
|
yield stmt
|
||||||
|
|
||||||
|
|
||||||
97
pycmx/event.py
Normal file
97
pycmx/event.py
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
# 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"])
|
||||||
StmtEvent = namedtuple("Event",["event","source","channels","trans",\
|
StmtEvent = namedtuple("Event",["event","source","channels","trans",\
|
||||||
"trans_op","source_in","source_out","record_in","record_out","line_number"])
|
"trans_op","source_in","source_out","record_in","record_out","format","line_number"])
|
||||||
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
|
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
|
||||||
StmtClipName = namedtuple("ClipName",["name","affect","line_number"])
|
StmtClipName = namedtuple("ClipName",["name","affect","line_number"])
|
||||||
StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
|
StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
|
||||||
StmtRemark = namedtuple("Remark",["text","line_number"])
|
StmtRemark = namedtuple("Remark",["text","line_number"])
|
||||||
StmtEffectsName = namedtuple("EffectsName",["name","line_number"])
|
StmtEffectsName = namedtuple("EffectsName",["name","line_number"])
|
||||||
StmtTrailer = namedtuple("Trailer",["text","line_number"])
|
StmtSourceUMID = namedtuple("Source",["name","umid","line_number"])
|
||||||
StmtSplitEdit = namedtuple("SplitEdit",["video","magnitue", "line_number"])
|
StmtSplitEdit = namedtuple("SplitEdit",["video","magnitue", "line_number"])
|
||||||
StmtMotionMemory = namedtuple("MotionMemory",["source","fps"]) # FIXME needs more fields
|
StmtMotionMemory = namedtuple("MotionMemory",["source","fps"]) # FIXME needs more fields
|
||||||
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
|
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
|
||||||
@@ -69,8 +69,8 @@ def _parse_cmx3600_line(line, line_number):
|
|||||||
return _parse_extended_audio_channels(line,line_number)
|
return _parse_extended_audio_channels(line,line_number)
|
||||||
elif line.startswith("*"):
|
elif line.startswith("*"):
|
||||||
return _parse_remark( line[1:].strip(), line_number)
|
return _parse_remark( line[1:].strip(), line_number)
|
||||||
elif line.startswith(">>>"):
|
elif line.startswith(">>> SOURCE"):
|
||||||
return _parse_trailer_statement(line, line_number)
|
return _parse_source_umid_statement(line, line_number)
|
||||||
elif line.startswith("EFFECTS NAME IS"):
|
elif line.startswith("EFFECTS NAME IS"):
|
||||||
return _parse_effects_name(line, line_number)
|
return _parse_effects_name(line, line_number)
|
||||||
elif line.startswith("SPLIT:"):
|
elif line.startswith("SPLIT:"):
|
||||||
@@ -157,10 +157,11 @@ def _parse_columns_for_standard_form(line, event_field_length, source_field_leng
|
|||||||
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)
|
line_number=line_number,
|
||||||
|
format=source_field_length)
|
||||||
|
|
||||||
|
|
||||||
def _parse_trailer_statement(line, line_number):
|
def _parse_source_umid_statement(line, line_number):
|
||||||
trimmed = line[3:].strip()
|
trimmed = line[3:].strip()
|
||||||
return StmtTrailer(trimmed, line_number=line_number)
|
return StmtSourceUMID(name=None, umid=None, line_number=line_number)
|
||||||
|
|
||||||
|
|||||||
86
pycmx/transition.py
Normal file
86
pycmx/transition.py
Normal 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
|
||||||
2
pypi_upload.sh
Executable file
2
pypi_upload.sh
Executable file
@@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
python3 -m twine upload --repository-url https://upload.pypi.org/legacy/ dist/*
|
||||||
10
setup.py
10
setup.py
@@ -4,16 +4,20 @@ with open("README.md", "r") as fh:
|
|||||||
long_description = fh.read()
|
long_description = fh.read()
|
||||||
|
|
||||||
setup(name='pycmx',
|
setup(name='pycmx',
|
||||||
version='0.7',
|
version='1.0',
|
||||||
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 :: 4 - Beta',
|
classifiers=['Development Status :: 5 - Production/Stable',
|
||||||
'License :: OSI Approved :: MIT License',
|
'License :: OSI Approved :: MIT License',
|
||||||
'Topic :: Multimedia',
|
'Topic :: Multimedia',
|
||||||
'Topic :: Multimedia :: Video',
|
'Topic :: Multimedia :: Video',
|
||||||
'Topic :: Text Processing'],
|
'Topic :: Text Processing',
|
||||||
|
'Programming Language :: Python :: 3 :: Only',
|
||||||
|
'Programming Language :: Python :: 3.5',
|
||||||
|
'Programming Language :: Python :: 3.6'
|
||||||
|
],
|
||||||
packages=['pycmx'])
|
packages=['pycmx'])
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import test_parse
|
||||||
|
|||||||
@@ -18,21 +18,24 @@ class TestParse(TestCase):
|
|||||||
counts = [ 287, 466, 250 , 376, 120 , 3 , 466 ]
|
counts = [ 287, 466, 250 , 376, 120 , 3 , 466 ]
|
||||||
|
|
||||||
for fn, count in zip(type(self).files, counts):
|
for fn, count in zip(type(self).files, counts):
|
||||||
with open(f"tests/edls/{fn}" ,'r') as f:
|
with open("tests/edls/" + fn ,'r') as f:
|
||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
actual = len( list( edl.events ))
|
actual = len( list( edl.events ))
|
||||||
self.assertTrue( actual == count , f"expected {count} in file {fn} but found {actual}")
|
self.assertTrue( actual == count ,
|
||||||
|
"expected %i in file %s but found %i" % (count, fn, actual))
|
||||||
|
|
||||||
def test_list_sanity(self):
|
def test_list_sanity(self):
|
||||||
for fn in type(self).files:
|
for fn in type(self).files:
|
||||||
with open(f"tests/edls/{fn}" ,'r') as f:
|
with open("tests/edls/" + fn ,'r') as f:
|
||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
self.assertTrue( type(edl.title) is str )
|
self.assertTrue( type(edl.title) is str )
|
||||||
|
self.assertTrue( len(edl.title) > 0 )
|
||||||
|
|
||||||
|
|
||||||
def test_event_sanity(self):
|
def test_event_sanity(self):
|
||||||
for fn in type(self).files:
|
for fn in type(self).files:
|
||||||
with open(f"tests/edls/{fn}" ,'r') as f:
|
path = "tests/edls/" + fn
|
||||||
|
with open(path ,'r') as f:
|
||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
for index, event in enumerate(edl.events):
|
for index, event in enumerate(edl.events):
|
||||||
self.assertTrue( len(event.edits) > 0 )
|
self.assertTrue( len(event.edits) > 0 )
|
||||||
@@ -44,7 +47,7 @@ class TestParse(TestCase):
|
|||||||
with open("tests/edls/TEST.edl",'r') as f:
|
with open("tests/edls/TEST.edl",'r') as f:
|
||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
events = list( edl.events )
|
events = list( edl.events )
|
||||||
|
|
||||||
self.assertEqual( events[0].number , 1)
|
self.assertEqual( events[0].number , 1)
|
||||||
self.assertEqual( events[0].edits[0].source , "OY_HEAD_")
|
self.assertEqual( events[0].edits[0].source , "OY_HEAD_")
|
||||||
self.assertEqual( events[0].edits[0].clip_name , "HEAD LEADER MONO")
|
self.assertEqual( events[0].edits[0].clip_name , "HEAD LEADER MONO")
|
||||||
@@ -98,4 +101,12 @@ 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" )
|
||||||
|
|
||||||
|
|
||||||
|
# add test for edit_list.channels
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user