87 Commits
v0.6 ... v1.1.1

Author SHA1 Message Date
Jamie Hardt
50d48708e9 .idea files 2020-01-04 22:38:25 -08:00
Jamie Hardt
f67c4ac2c5 Update setup.py 2020-01-04 22:38:15 -08:00
Jamie Hardt
1b8a3c3288 Create pythonpublish.yml 2020-01-04 22:36:54 -08:00
Jamie Hardt
b37b57d7c9 Removed pypi upload code 2020-01-04 22:36:24 -08:00
Jamie Hardt
a9937683e5 Update setup.py 2020-01-03 09:43:38 -08:00
Jamie Hardt
4fae65fa8d Update .travis.yml
Adding python 3.8
2020-01-03 09:42:40 -08:00
Jamie Hardt
566e6257f4 Added 'audio' method to ChannelMap
Added `audio` property to channelmap to test if it contains any audio channels.
2019-08-18 10:57:16 -07:00
Jamie Hardt
c56d2066ad Update setup.py
v1.0.1
2019-08-17 13:06:39 -07:00
Jamie Hardt
8b49a788ae Add version 3.7 support checks 2019-08-17 13:01:51 -07:00
Jamie Hardt
b31450f03d Update README.md
codecov badge
2019-01-05 12:57:16 -08:00
Jamie Hardt
5d14c3177a Update .travis.yml 2019-01-05 12:55:09 -08:00
Jamie Hardt
08dea6031d Update .travis.yml
switch to codecov
2019-01-05 12:49:52 -08:00
Jamie Hardt
d23fa33558 Update setup.py
Added version 2.7
2019-01-04 19:06:46 -08:00
Jamie Hardt
fcc4732d1a Update .travis.yml 2019-01-04 18:14:00 -08:00
Jamie Hardt
47c1ad96f0 Update .travis.yml 2019-01-04 18:01:49 -08:00
Jamie Hardt
804f649570 Configuring Coveralls 2019-01-04 18:01:23 -08:00
Jamie Hardt
58483198c3 Update .travis.yml 2019-01-04 09:12:21 -08:00
Jamie Hardt
aa01e9ad2d Update .travis.yml 2019-01-03 22:03:23 -08:00
Jamie Hardt
464052f510 Update .travis.yml
Adding 2.7 to see what happens
2019-01-03 20:09:39 -08:00
Jamie Hardt
3ba28a61dd Update README.md
Removed "should I use this?"
2019-01-03 19:47:38 -08:00
Jamie Hardt
b80339267a Version 1.0 2019-01-03 19:40:03 -08:00
Jamie Hardt
27d1073f8c Update test_parse.py 2019-01-03 19:35:20 -08:00
Jamie Hardt
0840ade312 Update test_parse.py
One more...
2019-01-03 19:33:26 -08:00
Jamie Hardt
a52e4329ce Update test_parse.py 2019-01-03 19:31:33 -08:00
Jamie Hardt
47772b21d0 Merge branch 'master' of https://github.com/iluvcapra/pycmx 2019-01-03 19:28:50 -08:00
Jamie Hardt
7b4a76448e Changed a format string in the tests so 3.5 should build 2019-01-03 19:28:48 -08:00
Jamie Hardt
c6c5d15e09 Update README.md 2019-01-03 11:13:31 -08:00
Jamie Hardt
c439ea1fbe Update README.md 2019-01-03 11:12:52 -08:00
Jamie Hardt
68f118ab6b Update README.md
Removed Travis badge
2019-01-01 12:25:36 -08:00
Jamie Hardt
a20491297e Update README.md 2018-12-31 12:11:17 -08:00
Jamie Hardt
9d57c2d374 Update README.md 2018-12-31 12:03:37 -08:00
Jamie Hardt
1882cc5308 Can't support 3.7 yet? 2018-12-31 11:59:59 -08:00
Jamie Hardt
c57fe94335 Removed versions I don't support 2018-12-31 11:58:04 -08:00
Jamie Hardt
007661ef38 Merge branch 'master' of https://github.com/iluvcapra/pycmx 2018-12-31 11:52:23 -08:00
Jamie Hardt
f34c6dd4db Update .travis.yml
Added more versions to travis
2018-12-31 11:52:08 -08:00
Jamie Hardt
eb89708bab Update README.md
badges
2018-12-31 11:49:07 -08:00
Jamie Hardt
9fde608fa0 Update setup.py
Added version classifiers
2018-12-31 11:47:34 -08:00
Jamie Hardt
4c4ca428f2 Update README.md
Badges
2018-12-31 11:43:59 -08:00
Jamie Hardt
fe4e3b9d85 Update README.md 2018-12-31 11:35:34 -08:00
Jamie Hardt
119467a884 Update README.md
Added pypi badge
2018-12-31 11:35:11 -08:00
Jamie Hardt
b5a3285e64 Nudge version
Also saved upload command to a script
2018-12-29 16:31:48 -08:00
Jamie Hardt
af1c532a67 Some SourceUMID Impl 2018-12-29 15:16:26 -08:00
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
Jamie Hardt
dbf495f138 Merge branch 'master' of https://github.com/iluvcapra/pycmx 2018-12-26 22:20:35 -08:00
Jamie Hardt
2483d94d7b Docs 2018-12-26 22:19:33 -08:00
Jamie Hardt
dbe8a16eff Update index.rst 2018-12-26 21:47:59 -08:00
Jamie Hardt
dbbfc27196 Update conf.py
Experiment
2018-12-26 21:47:09 -08:00
Jamie Hardt
f0c257f15f Update index.rst 2018-12-26 21:42:58 -08:00
Jamie Hardt
cbec18607a Doc buildinfo 2018-12-26 21:38:10 -08:00
Jamie Hardt
acb12b7d9d Update index.rst 2018-12-26 21:29:31 -08:00
Jamie Hardt
618f6422cc Sphinx quickstart 2018-12-26 21:27:25 -08:00
Jamie Hardt
64001e8c78 Merge branch 'master' of https://github.com/iluvcapra/pycmx 2018-12-26 21:09:53 -08:00
Jamie Hardt
69dc7ed1ce Documentation
Docs folder
2018-12-26 21:08:25 -08:00
Jamie Hardt
98b4ff9106 Update README.md
Added docs badge
2018-12-26 20:59:26 -08:00
Jamie Hardt
18c6ff658a Update edl2scenelist.py
Format handling
2018-12-26 18:31:17 -08:00
Jamie Hardt
0a4309ab77 Update edl2scenelist.py
Ooops typos
2018-12-26 18:10:10 -08:00
Jamie Hardt
50fea58724 Update edl2scenelist.py
Output "cols" column output mode
2018-12-26 17:34:15 -08:00
Jamie Hardt
4ceb4be7ab Update test_parse.py
Some more tests
2018-12-26 17:30:19 -08:00
Jamie Hardt
fbb2b8700d Merge branch 'master' of https://github.com/iluvcapra/pycmx 2018-12-26 17:18:21 -08:00
Jamie Hardt
3f6ea4feee Create edl2scenelist.py
Wrote an example script for extracting scene lists from a video edl.
2018-12-26 17:18:17 -08:00
Jamie Hardt
fcd84b1edf Update README.md 2018-12-26 16:08:25 -08:00
Jamie Hardt
f85304d83b Update README.md 2018-12-26 16:06:57 -08:00
Jamie Hardt
205c58e52c Update README.md
Broke up usage examples
2018-12-26 15:57:57 -08:00
Jamie Hardt
d3cdce6b99 Update README.md 2018-12-26 15:53:33 -08:00
Jamie Hardt
32da584363 Update parse_cmx_events.py
Added unrecognized_statements properties to EditList and Events classes
2018-12-26 15:48:56 -08:00
Jamie Hardt
c74177953f Update test_parse.py
Added some more test case EDLs to the test
2018-12-26 15:35:27 -08:00
Jamie Hardt
5a4f57bd7e Update test_parse.py
Renamed typo method
2018-12-26 15:32:47 -08:00
Jamie Hardt
3e4c6d5955 Update test_parse.py
Tiwddle
2018-12-26 15:32:13 -08:00
Jamie Hardt
11034dd9f1 Update test_parse.py
event.number is an int now, does not need to be cast from a string
2018-12-26 15:30:25 -08:00
Jamie Hardt
bc02eb10fc Edit line numbers
Line numbers of edits are accessible
2018-12-26 15:29:49 -08:00
Jamie Hardt
9afe9d194d Update __init__.py
Added author
2018-12-26 15:29:01 -08:00
Jamie Hardt
44e911d878 Update README.md
Documentation updated
2018-12-26 14:27:05 -08:00
Jamie Hardt
82814522d1 Doc, file path
Documentation, cleaned up interface, and we now parse file handles, not file paths
2018-12-26 14:25:19 -08:00
Jamie Hardt
26b2f5274c Merge branch 'master' into jamie 2018-12-24 17:39:48 -08:00
Jamie Hardt
30ee3e0be5 Update test_parse.py 2018-12-24 17:32:23 -08:00
Jamie Hardt
82fc5f21da Update README.md 2018-12-24 17:30:56 -08:00
Jamie Hardt
d4353d1e68 Nudge version
v0.7
2018-12-24 16:31:00 -08:00
33 changed files with 1166 additions and 419 deletions

26
.github/workflows/pythonpublish.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Upload Python Package
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python
uses: actions/setup-python@v1
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py sdist bdist_wheel
twine upload dist/*

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# Default ignored files
/workspace.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.7" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/pycmx.iml" filepath="$PROJECT_DIR$/.idea/pycmx.iml" />
</modules>
</component>
</project>

11
.idea/pycmx.iml generated Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="jdk" jdkName="Python 3.7" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TestRunnerService">
<option name="PROJECT_TEST_RUNNER" value="Unittests" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

View File

@@ -1,7 +1,18 @@
language: python language: python
python: python:
- "3.8"
- "3.7"
- "3.6" - "3.6"
- "3.5"
- "2.7"
script: script:
- "python3 setup.py test" - "python setup.py test"
- "py.test tests/ -v --cov pycmx --cov-report term-missing"
install: install:
- "pip3 install setuptools" - "pip install setuptools"
before_install:
- "pip install coverage"
- "pip install codecov"
- "pip install pytest-cov==2.5.0"
after_success:
- "codecov"

View File

@@ -1,4 +1,7 @@
[![Build Status](https://travis-ci.com/iluvcapra/pycmx.svg?branch=master)](https://travis-ci.com/iluvcapra/pycmx) [![Build Status](https://travis-ci.com/iluvcapra/pycmx.svg?branch=master)](https://travis-ci.com/iluvcapra/pycmx)
[![codecov](https://codecov.io/gh/iluvcapra/pycmx/branch/master/graph/badge.svg)](https://codecov.io/gh/iluvcapra/pycmx)
[![Documentation Status](https://readthedocs.org/projects/pycmx/badge/?version=latest)](https://pycmx.readthedocs.io/en/latest/?badge=latest) ![](https://img.shields.io/github/license/iluvcapra/pycmx.svg) ![](https://img.shields.io/pypi/pyversions/pycmx.svg) [![](https://img.shields.io/pypi/v/pycmx.svg)](https://pypi.org/project/pycmx/) ![](https://img.shields.io/pypi/wheel/pycmx.svg)
# pycmx # pycmx
@@ -6,7 +9,7 @@ The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and it
## Features ## Features
* The major variations of the CMX3600, the standard, "File32" and "File128" * The major variations of the CMX 3600: 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. * 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
@@ -15,31 +18,49 @@ The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and it
* Does not parse or validate timecodes, does not enforce framerates, does not * Does not parse or validate timecodes, does not enforce framerates, does not
parameterize timecode or framerates in any way. This makes the parser more parameterize timecode or framerates in any way. This makes the parser more
tolerant of EDLs with mixed rates. tolerant of EDLs with mixed rates.
* Unrecognized lines are accessible on the `EditList` and `Event` classes
along with the line numbers, to help the client diagnose problems with a
list and give the client the ability to extend the package with their own
parsing code.
## Usage ## Usage
### Opening and Parsing EDL Files
``` ```
>>> import pycmx >>> import pycmx
>>> edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") >>> with open("tests/edls/TEST.edl") as f
... edl = pycmx.parse_cmx3600(f)
...
>>> edl.title >>> edl.title
'DC7 R1_v8.2' 'DC7 R1_v8.2'
```
### Reading Events and Edits
`EditList.events` is a generator...
```
>>> events = list( edl.events ) >>> events = list( edl.events )
# the event list is a generator
>>> len(events) >>> len(events)
120 120
>>> events[43].number >>> events[43].number
'044' '044'
```
...and events contain 1...n edits.
```
>>> events[43].edits[0].source_in >>> events[43].edits[0].source_in
'00:00:00:00' '00:00:00:00'
>>> events[43].edits[0].transition.cut >>> events[43].edits[0].transition.cut
True True
>>> events[43].edits[0].record_out >>> events[43].edits[0].record_out
'01:10:21:10' '01:10:21:10'
```
# events contain multiple ### Acessing Transitions and Enabled Channels
# edits to preserve A/B dissolves
# and key backgrounds
```
>>> events[41].edits[0].transition.dissolve >>> events[41].edits[0].transition.dissolve
False False
>>> events[41].edits[1].transition.dissolve >>> events[41].edits[1].transition.dissolve
@@ -61,14 +82,4 @@ False
Audio channel 7 is present Audio channel 7 is present
>>> events[2].edits[0].channels.video >>> events[2].edits[0].channels.video
False False
``` ```
## Should I Use This?
At this time, this is (at best) alpha software and the interface will be
changing often. It may be fun to experiment with but it is not suitable
at this time for production code.
Contributions are welcome and will make this module production-ready all the
faster! Please reach out or file a ticket!

102
bin/edl2scenelist.py Normal file
View File

@@ -0,0 +1,102 @@
import pycmx
import re
import argparse
import sys
import logging
FORMAT = '%(asctime)-15s %(message)s'
logging.basicConfig(format=FORMAT)
log = logging.getLogger(__name__)
def all_video_edits(edl):
for event in edl.events:
for edit in event.edits:
if edit.channels.video:
yield edit
def get_scene_name(edit, pattern):
scene_extractor = re.compile(pattern, re.I)
if edit.clip_name is None:
return None
else:
match_data = re.match(scene_extractor, edit.clip_name)
if match_data:
return match_data[1]
else:
return edit.clip_name
def output_cmx(outfile, out_list):
outfile.write("TITLE: SCENE LIST\r\n")
outfile.write("FCM: NON-DROP FRAME\r\n")
for o in out_list:
line = "%03i AX V C 00:00:00:00 00:00:00:00 %s %s\r\n" % (0, o['start'],o['end'])
outfile.write(line)
outfile.write("* FROM CLIP NAME: %s\r\n" % (o['scene']) )
def output_cols(outfile, out_list):
for o in out_list:
outfile.write("%-12s\t%-12s\t%s\n" % (o['start'], o['end'], o['scene'] ))
def scene_list(infile, outfile, out_format, pattern):
edl = pycmx.parse_cmx3600(infile)
current_scene_name = None
grouped_edits = [ ]
for edit in all_video_edits(edl):
this_scene_name = get_scene_name(edit, pattern)
if this_scene_name is not None:
if current_scene_name != this_scene_name:
grouped_edits.append([ ])
current_scene_name = this_scene_name
grouped_edits[-1].append(edit)
out_list = [ ]
for group in grouped_edits:
out_list.append({
'start': group[0].record_in,
'end': group[-1].record_out,
'scene': get_scene_name(group[0], pattern ) }
)
if out_format == 'cmx':
output_cmx(outfile, out_list)
if out_format == 'cols':
output_cols(outfile, out_list)
else:
log.warning(f"Format {out_format} unrecognized. Will use cmx.\n")
output_cmx(outfile, out_list)
def scene_list_cli():
parser = argparse.ArgumentParser(description=
'Read video events from an input CMX EDL and output events merged into scenes.')
parser.add_argument('-o','--outfile', default=sys.stdout, type=argparse.FileType('w'),
help='Output file. Default is stdout.')
parser.add_argument('-f','--format', default='cmx', type=str,
help='Output format. Options are cols and cmx, cmx is the default.')
parser.add_argument('-p','--pattern', default='V?([A-Z]*[0-9]+)',
help='RE pattern for extracting scene name from clip name. The default is "V?([A-Z]*[0-9]+)". ' + \
'This pattern will be matched case-insensitively.')
parser.add_argument('input_edl', default=sys.stdin, type=argparse.FileType('r'), nargs='?',
help='Input file. Default is stdin.')
args = parser.parse_args()
infile = args.input_edl
scene_list(infile=infile, outfile=args.outfile , out_format=args.format, pattern=args.pattern)
if __name__ == '__main__':
scene_list_cli()

4
docs/.buildinfo Normal file
View File

@@ -0,0 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: 9f647f525db0c82aadec132928a40ec5
tags: 645f666f9bcd5a90fca523b33c5a78b7

Binary file not shown.

Binary file not shown.

1
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
build/*

19
docs/Makefile Normal file
View File

@@ -0,0 +1,19 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line.
SPHINXOPTS =
SPHINXBUILD = sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

35
docs/make.bat Normal file
View File

@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
:end
popd

186
docs/source/conf.py Normal file
View File

@@ -0,0 +1,186 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
# This file does only contain a selection of the most common options. For a
# full list see the documentation:
# http://www.sphinx-doc.org/en/master/config
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('../../pycmx'))
# -- Project information -----------------------------------------------------
project = u'pycmx'
copyright = u'2018, Jamie Hardt'
author = u'Jamie Hardt'
# The short X.Y version
version = u''
# The full version, including alpha/beta/rc tags
release = u''
# -- General configuration ---------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#
# needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
'sphinx.ext.githubpages',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
# The master toctree document.
master_doc = 'index'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = None
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
html_theme_options = {'collapse_navigation': False}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself. Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'pycmxdoc'
# -- Options for LaTeX output ------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'pycmx.tex', u'pycmx Documentation',
u'Jamie Hardt', 'manual'),
]
# -- Options for manual page output ------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'pycmx', u'pycmx Documentation',
[author], 1)
]
# -- Options for Texinfo output ----------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'pycmx', u'pycmx Documentation',
author, 'pycmx', 'One line description of project.',
'Miscellaneous'),
]
# -- Options for Epub output -------------------------------------------------
# Bibliographic Dublin Core info.
epub_title = project
# The unique identifier of the text. This can be a ISBN number
# or the project homepage.
#
# epub_identifier = ''
# A unique identification for the text.
#
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html']
# -- Extension configuration -------------------------------------------------
# -- Options for todo extension ----------------------------------------------
# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True

20
docs/source/index.rst Normal file
View File

@@ -0,0 +1,20 @@
.. pycmx documentation master file, created by
sphinx-quickstart on Wed Dec 26 21:51:43 2018.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to pycmx's documentation!
=================================
.. toctree::
:maxdepth: 5
:caption: API Reference:
pycmx
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

7
docs/source/modules.rst Normal file
View File

@@ -0,0 +1,7 @@
pycmx
=====
.. toctree::
:maxdepth: 4
pycmx

46
docs/source/pycmx.rst Normal file
View File

@@ -0,0 +1,46 @@
pycmx package
=============
Submodules
----------
pycmx.channel\_map module
-------------------------
.. automodule:: pycmx.channel_map
:members:
:undoc-members:
:show-inheritance:
pycmx.parse\_cmx\_events module
-------------------------------
.. automodule:: pycmx.parse_cmx_events
:members:
:undoc-members:
:show-inheritance:
pycmx.parse\_cmx\_statements module
-----------------------------------
.. automodule:: pycmx.parse_cmx_statements
:members:
:undoc-members:
:show-inheritance:
pycmx.util module
-----------------
.. automodule:: pycmx.util
:members:
:undoc-members:
:show-inheritance:
Module contents
---------------
.. automodule:: pycmx
:members:
:undoc-members:
:show-inheritance:

View File

@@ -1,6 +1,17 @@
# pycmx init # -*- coding: utf-8 -*-
"""
pycmx is a module for parsing CMX 3600-style EDLs. For more information and
examples see README.md
from .parse_cmx_events import parse_cmx3600, Transition, Event, Edit This module (c) 2018 Jamie Hardt. For more information on your rights to
from . import parse_cmx_events copy and reuse this software, refer to the LICENSE file included with the
distribution.
"""
__version__ = '0.6' __version__ = '1.1.1'
__author__ = 'Jamie Hardt'
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) 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),
@@ -27,6 +27,11 @@ class ChannelMap:
'True if video is included' 'True if video is included'
return self.v return self.v
@property
def audio(self):
'True if an audio channel is included'
return len(self._audio_channel_set) > 0
@property @property
def channels(self): def channels(self):
'A generator for each audio channel' 'A generator for each audio channel'
@@ -35,6 +40,7 @@ class ChannelMap:
@property @property
def a1(self): def a1(self):
"""True if A1 is included."""
return self.get_audio_channel(1) return self.get_audio_channel(1)
@a1.setter @a1.setter
@@ -43,6 +49,7 @@ class ChannelMap:
@property @property
def a2(self): def a2(self):
"""True if A2 is included."""
return self.get_audio_channel(2) return self.get_audio_channel(2)
@a2.setter @a2.setter
@@ -51,6 +58,7 @@ class ChannelMap:
@property @property
def a3(self): def a3(self):
"""True if A3 is included."""
return self.get_audio_channel(3) return self.get_audio_channel(3)
@a3.setter @a3.setter
@@ -59,6 +67,7 @@ class ChannelMap:
@property @property
def a4(self): def a4(self):
"""True if A4 is included."""
return self.get_audio_channel(4) return self.get_audio_channel(4)
@a4.setter @a4.setter
@@ -66,18 +75,20 @@ class ChannelMap:
self.set_audio_channel(4,val) self.set_audio_channel(4,val)
def get_audio_channel(self,chan_num): def get_audio_channel(self,chan_num):
"""True if chan_num is included."""
return (chan_num in self._audio_channel_set) return (chan_num in self._audio_channel_set)
def set_audio_channel(self,chan_num,enabled): def set_audio_channel(self,chan_num,enabled):
"""If enabled is true, chan_num will be included."""
if enabled: if enabled:
self._audio_channel_set.add(chan_num) self._audio_channel_set.add(chan_num)
elif self.get_audio_channel(chan_num): elif self.get_audio_channel(chan_num):
self._audio_channel_set.remove(chan_num) self._audio_channel_set.remove(chan_num)
def append_event(self, event_str): def _append_event(self, event_str):
alt_channel_re = compile('^A(\d+)') alt_channel_re = compile('^A(\d+)')
if event_str in self.chan_map: if event_str in self._chan_map:
channels = self.chan_map[event_str] channels = self._chan_map[event_str]
self.v = channels[0] self.v = channels[0]
self.a1 = channels[1] self.a1 = channels[1]
self.a2 = channels[2] self.a2 = channels[2]
@@ -86,7 +97,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
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

111
pycmx/edit_list.py Normal file
View 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
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,247 +1,21 @@
# pycmx # pycmx
# (c) 2018 Jamie Hardt # (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 from collections import namedtuple
def parse_cmx3600(path): from .parse_cmx_statements import (parse_cmx3600_statements, StmtEvent,StmtFCM )
statements = parse_cmx3600_statements(path) from .edit_list import EditList
def parse_cmx3600(f):
"""
Parse a CMX 3600 EDL.
Args:
f : a file-like object, anything that's readlines-able.
Returns:
An :class:`EditList`.
"""
statements = parse_cmx3600_statements(f)
return EditList(statements) 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,36 +1,38 @@
# 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"])
def parse_cmx3600_statements(path): def parse_cmx3600_statements(file):
with open(path,'r') as file: """
lines = file.readlines() Return a list of every statement in the file argument.
line_numbers = count() """
return [parse_cmx3600_line(line.strip(), line_number) \ lines = file.readlines()
for (line, line_number) in zip(lines,line_numbers)] 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,
4,2, # chans 4,2, # chans
4,1, # trans 4,1, # trans
@@ -40,63 +42,63 @@ def edl_column_widths(event_field_length, source_field_length):
11,1, 11,1,
11] 11]
def edl_m2_column_widths(): def _edl_m2_column_widths():
return [2, # "M2" return [2, # "M2"
3,3, # 3,3, #
8,8,1,4,2,1,4,13,3,1,1] 8,8,1,4,2,1,4,13,3,1,1]
def parse_cmx3600_line(line, line_number): def _parse_cmx3600_line(line, line_number):
long_event_num_p = re.compile("^[0-9]{6} ") 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,line_number) return _parse_title(line,line_number)
elif line.startswith("FCM:"): elif line.startswith("FCM:"):
return parse_fcm(line, line_number) 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, line_number) return _parse_long_standard_form(line, 32, line_number)
else: else:
return parse_long_standard_form(line, 128, line_number) return _parse_long_standard_form(line, 128, line_number)
elif short_event_num_p.match(line) != None: elif short_event_num_p.match(line) != None:
return parse_standard_form(line, line_number) return _parse_standard_form(line, line_number)
elif line.startswith("AUD"): elif line.startswith("AUD"):
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:"):
return parse_split(line, line_number) return _parse_split(line, line_number)
elif line.startswith("M2"): elif line.startswith("M2"):
return parse_motion_memory(line, line_number) return _parse_motion_memory(line, line_number)
else: else:
return parse_unrecognized(line, line_number) return _parse_unrecognized(line, line_number)
def parse_title(line, line_num): def _parse_title(line, line_num):
title = line[6:].strip() title = line[6:].strip()
return StmtTitle(title=title,line_number=line_num) return StmtTitle(title=title,line_number=line_num)
def parse_fcm(line, line_num): 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, line_number=line_num) return StmtFCM(drop= True, line_number=line_num)
else: else:
return StmtFCM(drop= False, line_number=line_num) return StmtFCM(drop= False, line_number=line_num)
def parse_long_standard_form(line,source_field_length, line_number): def _parse_long_standard_form(line,source_field_length, line_number):
return parse_columns_for_standard_form(line, 6, source_field_length, line_number) return _parse_columns_for_standard_form(line, 6, source_field_length, line_number)
def parse_standard_form(line, line_number): def _parse_standard_form(line, line_number):
return parse_columns_for_standard_form(line, 3, 8, line_number) return _parse_columns_for_standard_form(line, 3, 8, line_number)
def parse_extended_audio_channels(line, line_number): def _parse_extended_audio_channels(line, line_number):
content = line.strip() content = line.strip()
if content == "AUD 3": if content == "AUD 3":
return StmtAudioExt(audio3=True, audio4=False, line_number=line_number) return StmtAudioExt(audio3=True, audio4=False, line_number=line_number)
@@ -107,7 +109,7 @@ def parse_extended_audio_channels(line, line_number):
else: else:
return StmtUnrecognized(content=line, line_number=line_number) return StmtUnrecognized(content=line, line_number=line_number)
def parse_remark(line, line_number): def _parse_remark(line, line_number):
if line.startswith("FROM CLIP NAME:"): if line.startswith("FROM CLIP NAME:"):
return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number) return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number)
elif line.startswith("TO CLIP NAME:"): elif line.startswith("TO CLIP NAME:"):
@@ -117,11 +119,11 @@ def parse_remark(line, line_number):
else: else:
return StmtRemark(text=line, line_number=line_number) return StmtRemark(text=line, line_number=line_number)
def parse_effects_name(line, line_number): def _parse_effects_name(line, line_number):
name = line[16:].strip() name = line[16:].strip()
return StmtEffectsName(name=name, line_number=line_number) return StmtEffectsName(name=name, line_number=line_number)
def parse_split(line, line_number): def _parse_split(line, line_number):
split_type = line[10:21] split_type = line[10:21]
is_video = False is_video = False
if split_type.startswith("VIDEO"): if split_type.startswith("VIDEO"):
@@ -131,15 +133,15 @@ def parse_split(line, line_number):
return StmtSplitEdit(video=is_video, magnitude=split_mag, line_number=line_number) return StmtSplitEdit(video=is_video, magnitude=split_mag, line_number=line_number)
def parse_motion_memory(line, line_number): def _parse_motion_memory(line, line_number):
return StmtMotionMemory(source = "", fps="") return StmtMotionMemory(source = "", fps="")
def parse_unrecognized(line, line_number): def _parse_unrecognized(line, line_number):
return StmtUnrecognized(content=line, line_number=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): 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, line_number=line_number) return StmtUnrecognized(content=line, line_number=line_number)
@@ -155,10 +157,11 @@ def parse_columns_for_standard_form(line, event_field_length, source_field_lengt
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
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,22 @@
# Utility functions # Utility functions
def collimate(a_string, column_widths): def collimate(a_string, column_widths):
'Splits a string into substrings that are column_widths length.' """
Split a list-type thing, like a string, into slices that are column_widths
length.
>>> collimate("a b1 c2345",[2,3,3,2])
['a ','b1 ','c23','45']
Args:
a_string: The string to split. This parameter can actually be anything
sliceable.
column_widths: A list of integers, each one is the length of a column.
Returns:
A list of slices. The len() of the returned list will *always* equal
len(:column_widths:).
"""
if len(column_widths) == 0: if len(column_widths) == 0:
return [] return []
@@ -14,51 +29,3 @@ def collimate(a_string, column_widths):
rest = a_string[width:] rest = a_string[width:]
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,16 +4,22 @@ with open("README.md", "r") as fh:
long_description = fh.read() long_description = fh.read()
setup(name='pycmx', setup(name='pycmx',
version='0.6', version='1.1.1',
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 :: 2.7',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8'
],
packages=['pycmx']) packages=['pycmx'])

View File

@@ -0,0 +1 @@
from . import test_parse

View File

@@ -4,70 +4,110 @@ import pycmx
class TestParse(TestCase): class TestParse(TestCase):
def test_edls(self): files = ["INS4_R1_010417.edl" ,
files = ["INS4_R1_010417.edl" , "INS4_R1_DX_092117.edl",
"STP R1 v082517.edl", "STP R1 v082517.edl",
"ToD_R4_LOCK3.1_030618_Video.edl", "ToD_R4_LOCK3.1_030618_Video.edl",
"TEST.edl" "TEST.edl",
] "test_edl_cdl.edl",
"INS4_R1_DX_092117.edl"
]
counts = [ 287, 250 , 376, 120 ] def test_event_counts(self):
counts = [ 287, 466, 250 , 376, 120 , 3 , 466 ]
for fn, count in zip(type(self).files, counts):
with open("tests/edls/" + fn ,'r') as f:
edl = pycmx.parse_cmx3600(f)
actual = len( list( edl.events ))
self.assertTrue( actual == count ,
"expected %i in file %s but found %i" % (count, fn, actual))
def test_list_sanity(self):
for fn in type(self).files:
with open("tests/edls/" + fn ,'r') as f:
edl = pycmx.parse_cmx3600(f)
self.assertTrue( type(edl.title) is str )
self.assertTrue( len(edl.title) > 0 )
for fn, count in zip(files, counts): def test_event_sanity(self):
edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" ) for fn in type(self).files:
actual = len(list(edl.events )) path = "tests/edls/" + fn
self.assertTrue( actual == count , f"expected {count} in file {fn} but found {actual}") with open(path ,'r') as f:
edl = pycmx.parse_cmx3600(f)
for index, event in enumerate(edl.events):
self.assertTrue( len(event.edits) > 0 )
self.assertTrue( event.number == index + 1 )
def test_events(self): def test_events(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") with open("tests/edls/TEST.edl",'r') as f:
events = list( edl.events ) edl = pycmx.parse_cmx3600(f)
events = list( edl.events )
self.assertEqual( int(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")
self.assertEqual( events[0].edits[0].source_file , "OY_HEAD_LEADER.MOV") 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_in , "00:00:00:00")
self.assertEqual( events[0].edits[0].source_out , "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_in , "01:00:00:00")
self.assertEqual( events[0].edits[0].record_out , "01:00:08:00") self.assertEqual( events[0].edits[0].record_out , "01:00:08:00")
self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut) self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut)
def test_channel_mop(self): def test_channel_map(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") with open("tests/edls/TEST.edl",'r') as f:
events = list( edl.events ) edl = pycmx.parse_cmx3600(f)
self.assertFalse( events[0].edits[0].channels.video) events = list( edl.events )
self.assertFalse( events[0].edits[0].channels.a1) self.assertFalse( events[0].edits[0].channels.video)
self.assertTrue( events[0].edits[0].channels.a2) self.assertFalse( events[0].edits[0].channels.a1)
self.assertTrue( events[2].edits[0].channels.get_audio_channel(7) ) self.assertTrue( events[0].edits[0].channels.a2)
self.assertTrue( events[2].edits[0].channels.get_audio_channel(7) )
self.assertTrue( events[2].edits[0].channels.audio)
def test_multi_edit_events(self): def test_multi_edit_events(self):
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl") with open("tests/edls/TEST.edl",'r') as f:
events = list( edl.events ) edl = pycmx.parse_cmx3600(f)
events = list( edl.events )
self.assertEqual( int(events[42].number) , 43) self.assertEqual( events[42].number , 43)
self.assertEqual( len(events[42].edits), 2) self.assertEqual( len(events[42].edits), 2)
self.assertEqual( events[42].edits[0].source , "TC_R1_V1")
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].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_in , "00:00:00:00") self.assertEqual( events[42].edits[0].source_out , "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_in , "01:08:56:09") self.assertEqual( events[42].edits[0].record_out , "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.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 , "TC_R1_V6") self.assertEqual( events[42].edits[1].source_in , "00:00:00:00")
self.assertEqual( events[42].edits[1].clip_name , "TC R1 V6 TEMP2 ST FX.WAV") self.assertEqual( events[42].edits[1].source_out , "00:00:00:00")
self.assertEqual( events[42].edits[1].source_in , "00:00:00:00") self.assertEqual( events[42].edits[1].record_in , "01:08:56:09")
self.assertEqual( events[42].edits[1].source_out , "00:00:00:00") self.assertEqual( events[42].edits[1].record_out , "01:08:56:11")
self.assertEqual( events[42].edits[1].record_in , "01:08:56:09") self.assertTrue( events[42].edits[1].transition.kind == pycmx.Transition.Dissolve)
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_line_numbers(self):
with open("tests/edls/ToD_R4_LOCK3.1_030618_Video.edl") as f:
edl = pycmx.parse_cmx3600(f)
events = list( edl.events )
self.assertEqual( events[0].edits[0].line_number, 2)
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" )
# add test for edit_list.channels