mirror of
https://github.com/iluvcapra/pycmx.git
synced 2025-12-31 17:00:53 +00:00
Compare commits
212 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6da04b2c07 | ||
|
|
89b6cde808 | ||
|
|
5e4dde5aa2 | ||
|
|
b6379ec1fe | ||
|
|
04731d634f | ||
|
|
2dcfdabf01 | ||
|
|
7ed423deaf | ||
|
|
2be779fe53 | ||
|
|
2adff6dd01 | ||
|
|
7871acdfd0 | ||
|
|
8bb6dad1da | ||
|
|
f88b82e8fb | ||
|
|
a86ef7ed2e | ||
|
|
1e1331eadb | ||
|
|
7a0481dbf9 | ||
|
|
20ad04e862 | ||
|
|
9e54aa5fcf | ||
|
|
71aff9baf7 | ||
|
|
ab55cab160 | ||
|
|
a717589683 | ||
|
|
f48c164e1b | ||
|
|
be1dc99e94 | ||
|
|
fbdfcddfff | ||
|
|
156828b648 | ||
|
|
1894a143b1 | ||
|
|
c0d278e079 | ||
|
|
7d3a58bff8 | ||
|
|
f1381f5f46 | ||
|
|
dc00b52b61 | ||
|
|
571ffdefd7 | ||
|
|
a79cb02139 | ||
|
|
ce1a0d32bb | ||
|
|
daa5d58a66 | ||
|
|
38ce1445a1 | ||
|
|
552d007360 | ||
|
|
d79fdcc6a8 | ||
|
|
3187a50a6b | ||
|
|
8e8d4f5753 | ||
|
|
cc371cd486 | ||
|
|
795a666a74 | ||
|
|
cd35f4f80d | ||
|
|
6525840151 | ||
|
|
b78ae05d8c | ||
|
|
51ed92f5df | ||
|
|
a6f042c76f | ||
|
|
179808fbf2 | ||
|
|
2b38d8aaf9 | ||
|
|
e19bb3c5c5 | ||
|
|
d0e2e5bbe5 | ||
|
|
e90897e503 | ||
|
|
4daa008e91 | ||
|
|
cf1d71800e | ||
|
|
fcb25ae183 | ||
|
|
97ada84bfe | ||
|
|
d157512c32 | ||
|
|
1e2d31716e | ||
|
|
6be7c54de5 | ||
|
|
a3109cdb7a | ||
|
|
3a9d81417e | ||
|
|
9838e6b357 | ||
|
|
41fdeeaf56 | ||
|
|
b3b6e57f6c | ||
|
|
62e7f10cf4 | ||
|
|
f855d3581d | ||
|
|
0b5555d333 | ||
|
|
7123a6f1bb | ||
|
|
173493a610 | ||
|
|
d20d9d1bdd | ||
|
|
d328e12283 | ||
|
|
4d83f81fc8 | ||
|
|
69b6c7236d | ||
|
|
71ffe8cd0d | ||
|
|
3fff6c8d2a | ||
|
|
e350565430 | ||
|
|
9ec30ede02 | ||
|
|
e5130b8011 | ||
|
|
00eaccabac | ||
|
|
521d86e444 | ||
|
|
0fdba2408b | ||
|
|
199bba2466 | ||
|
|
d8f0b5694e | ||
|
|
742b0f96c3 | ||
|
|
d44948bdc1 | ||
|
|
93cff15446 | ||
|
|
fc914409ce | ||
|
|
770e7f45a4 | ||
|
|
d3cf5fa5f2 | ||
|
|
67d2bd7093 | ||
|
|
26a7eae437 | ||
|
|
407aa1c1fd | ||
|
|
13d0a80a10 | ||
|
|
dcd2a22a43 | ||
|
|
c586740269 | ||
|
|
e28dbbbe5e | ||
|
|
41df450452 | ||
|
|
c37464036d | ||
|
|
9d89834eb3 | ||
|
|
8b53d2249c | ||
|
|
0cdbc4e9be | ||
|
|
e229e807b1 | ||
|
|
a7ee1f6737 | ||
|
|
9097de8efa | ||
|
|
bc6d7f34c0 | ||
|
|
b642f859f3 | ||
|
|
29a9a5fba7 | ||
|
|
20ff7d7ee8 | ||
|
|
183f121cfc | ||
|
|
e2dffcb745 | ||
|
|
8c2ba3cc09 | ||
|
|
c7569045c1 | ||
|
|
50bcac23bb | ||
|
|
67b1631ba9 | ||
|
|
85cbafba8f | ||
|
|
595cf35e57 | ||
|
|
7fa22d4b85 | ||
|
|
42f2de54b5 | ||
|
|
f7d1432014 | ||
|
|
db4eadb73e | ||
|
|
3305bc7920 | ||
|
|
6ba77b3568 | ||
|
|
c68f8bca80 | ||
|
|
284267c9c0 | ||
|
|
bd196f2dbf | ||
|
|
b14a9a6319 | ||
|
|
0cbd01f418 | ||
|
|
50d48708e9 | ||
|
|
f67c4ac2c5 | ||
|
|
1b8a3c3288 | ||
|
|
b37b57d7c9 | ||
|
|
a9937683e5 | ||
|
|
4fae65fa8d | ||
|
|
566e6257f4 | ||
|
|
c56d2066ad | ||
|
|
8b49a788ae | ||
|
|
b31450f03d | ||
|
|
5d14c3177a | ||
|
|
08dea6031d | ||
|
|
d23fa33558 | ||
|
|
fcc4732d1a | ||
|
|
47c1ad96f0 | ||
|
|
804f649570 | ||
|
|
58483198c3 | ||
|
|
aa01e9ad2d | ||
|
|
464052f510 | ||
|
|
3ba28a61dd | ||
|
|
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 | ||
|
|
dbf495f138 | ||
|
|
2483d94d7b | ||
|
|
dbe8a16eff | ||
|
|
dbbfc27196 | ||
|
|
f0c257f15f | ||
|
|
cbec18607a | ||
|
|
acb12b7d9d | ||
|
|
618f6422cc | ||
|
|
64001e8c78 | ||
|
|
69dc7ed1ce | ||
|
|
98b4ff9106 | ||
|
|
18c6ff658a | ||
|
|
0a4309ab77 | ||
|
|
50fea58724 | ||
|
|
4ceb4be7ab | ||
|
|
fbb2b8700d | ||
|
|
3f6ea4feee | ||
|
|
fcd84b1edf | ||
|
|
f85304d83b | ||
|
|
205c58e52c | ||
|
|
d3cdce6b99 | ||
|
|
32da584363 | ||
|
|
c74177953f | ||
|
|
5a4f57bd7e | ||
|
|
3e4c6d5955 | ||
|
|
11034dd9f1 | ||
|
|
bc02eb10fc | ||
|
|
9afe9d194d | ||
|
|
44e911d878 | ||
|
|
82814522d1 | ||
|
|
26b2f5274c | ||
|
|
30ee3e0be5 | ||
|
|
82fc5f21da | ||
|
|
d4353d1e68 |
5
.flake8
Normal file
5
.flake8
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[flake8]
|
||||||
|
per-file-ignores =
|
||||||
|
pycmx/__init__.py: F401
|
||||||
|
tests/__init__.py: F401
|
||||||
|
|
||||||
25
.github/workflows/bsky_test.yml
vendored
Normal file
25
.github/workflows/bsky_test.yml
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: Test Post to Bluesky
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# permissions:
|
||||||
|
# contents: read
|
||||||
|
# id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: release
|
||||||
|
steps:
|
||||||
|
- name: Send Bluesky Post
|
||||||
|
uses: myConsciousness/bluesky-post@v5
|
||||||
|
with:
|
||||||
|
text: |
|
||||||
|
This is a test post!
|
||||||
|
link-preview-url: ${{ github.server_url }}/${{ github.repository }}
|
||||||
|
identifier: ${{ secrets.BLUESKY_APP_USER }}
|
||||||
|
password: ${{ secrets.BLUESKY_APP_PASSWORD }}
|
||||||
|
service: bsky.social
|
||||||
|
retry-count: 1
|
||||||
38
.github/workflows/python-package.yml
vendored
Normal file
38
.github/workflows/python-package.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
|
||||||
|
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
|
||||||
|
|
||||||
|
name: Lint and Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "master" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
|
uses: actions/setup-python@v4.3.0
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install flake8 pytest
|
||||||
|
python -m pip install -e .
|
||||||
|
- name: Lint with flake8
|
||||||
|
run: |
|
||||||
|
# stop the build if there are Python syntax errors or undefined names
|
||||||
|
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
||||||
|
flake8 . --count --max-line-length=79 --statistics
|
||||||
|
- name: Test with pytest
|
||||||
|
run: |
|
||||||
|
pytest
|
||||||
42
.github/workflows/pythonpublish.yml
vendored
Normal file
42
.github/workflows/pythonpublish.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
name: Upload Python Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: release
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install build
|
||||||
|
- name: Build package
|
||||||
|
run: python -m build .
|
||||||
|
- name: Publish to Pypi
|
||||||
|
uses: pypa/gh-action-pypi-publish@v1.12.2
|
||||||
|
with:
|
||||||
|
password: ${{ secrets.PYPI_APIKEY }}
|
||||||
|
- name: Send Bluesky Post
|
||||||
|
uses: myConsciousness/bluesky-post@v5
|
||||||
|
with:
|
||||||
|
text: |
|
||||||
|
I've released a new version of pycmx, my python module for
|
||||||
|
reading CMX EDLs.
|
||||||
|
link-preview-url: ${{ github.server_url }}/${{ github.repository }}
|
||||||
|
identifier: ${{ secrets.BLUESKY_APP_USER }}
|
||||||
|
password: ${{ secrets.BLUESKY_APP_PASSWORD }}
|
||||||
|
service: bsky.social
|
||||||
|
retry-count: 1
|
||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -10,3 +10,13 @@
|
|||||||
|
|
||||||
# Vim Swapfiles
|
# Vim Swapfiles
|
||||||
*.swp
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
venv/
|
||||||
|
|
||||||
|
.coverage
|
||||||
|
lcov.info
|
||||||
|
|
||||||
|
|
||||||
|
# venv
|
||||||
|
venv/
|
||||||
|
|||||||
32
.readthedocs.yaml
Normal file
32
.readthedocs.yaml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# .readthedocs.yaml
|
||||||
|
# Read the Docs configuration file
|
||||||
|
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||||
|
|
||||||
|
# Required
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
# Set the version of Python and other tools you might need
|
||||||
|
build:
|
||||||
|
os: ubuntu-20.04
|
||||||
|
tools:
|
||||||
|
python: "3.10"
|
||||||
|
# You can also specify other tool versions:
|
||||||
|
# nodejs: "16"
|
||||||
|
# rust: "1.55"
|
||||||
|
# golang: "1.17"
|
||||||
|
|
||||||
|
# Build documentation in the docs/ directory with Sphinx
|
||||||
|
sphinx:
|
||||||
|
configuration: docs/source/conf.py
|
||||||
|
|
||||||
|
#If using Sphinx, optionally build your docs in additional formats such as PDF
|
||||||
|
formats:
|
||||||
|
- pdf
|
||||||
|
|
||||||
|
#Optionally declare the Python requirements required to build your docs
|
||||||
|
python:
|
||||||
|
install:
|
||||||
|
- method: pip
|
||||||
|
path: .
|
||||||
|
extra_requirements:
|
||||||
|
- doc
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
language: python
|
|
||||||
python:
|
|
||||||
- "3.6"
|
|
||||||
script:
|
|
||||||
- "python3 setup.py test"
|
|
||||||
install:
|
|
||||||
- "pip3 install setuptools"
|
|
||||||
13
CONTRIBUTING.md
Normal file
13
CONTRIBUTING.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Contributions to this project are welcome!
|
||||||
|
|
||||||
|
The best way to contribute code to this project is to find this project on [Github][github] and submit a pull request.
|
||||||
|
|
||||||
|
## Call for EDLs
|
||||||
|
|
||||||
|
If you have EDLs you are having trouble working with becuase of unusual formatting, please send me a copy! Contact us
|
||||||
|
through [Github].
|
||||||
|
|
||||||
|
|
||||||
|
[github]: https://github.com/iluvcapra/pycmx
|
||||||
2
LICENSE
2
LICENSE
@@ -1,4 +1,4 @@
|
|||||||
Copyright (c) 2018 Jamie Hardt.
|
Copyright (c) 2022 Jamie Hardt.
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
51
README.md
51
README.md
@@ -1,13 +1,18 @@
|
|||||||
[](https://travis-ci.com/iluvcapra/pycmx)
|
[](https://pycmx.readthedocs.io/en/latest/?badge=latest)   [](https://pypi.org/project/pycmx/) 
|
||||||
|

|
||||||
|
[](https://github.com/iluvcapra/pycmx/actions/workflows/python-package.yml)
|
||||||
|
|
||||||
|
|
||||||
# pycmx
|
# pycmx
|
||||||
|
|
||||||
The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and its most most common variations.
|
The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and
|
||||||
|
its most most common variations.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
* The major variations of the CMX3600, the standard, "File32" and "File128"
|
* The major variations of the CMX 3600: the standard, "File32", "File128" and
|
||||||
formats are automatically detected and properly read.
|
long Adobe Premiere event numbers 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
|
||||||
available to the client, including clip name and source file data.
|
available to the client, including clip name and source file data.
|
||||||
@@ -15,31 +20,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 +84,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!
|
|
||||||
|
|||||||
112
bin/edl2scenelist.py
Normal file
112
bin/edl2scenelist.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
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 i, o in enumerate(out_list):
|
||||||
|
line = '%03i AX V C ' % (i)
|
||||||
|
line += '00:00:00:00 00:00:00:00 %s %s\r\n' % (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()
|
||||||
BIN
doc/CMX3600.pdf
BIN
doc/CMX3600.pdf
Binary file not shown.
4
docs/.buildinfo
Normal file
4
docs/.buildinfo
Normal 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
|
||||||
BIN
docs/.doctrees/environment.pickle
Normal file
BIN
docs/.doctrees/environment.pickle
Normal file
Binary file not shown.
BIN
docs/.doctrees/index.doctree
Normal file
BIN
docs/.doctrees/index.doctree
Normal file
Binary file not shown.
1
docs/.gitignore
vendored
Normal file
1
docs/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
build/*
|
||||||
19
docs/Makefile
Normal file
19
docs/Makefile
Normal 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)
|
||||||
24
docs/source/classes.rst
Normal file
24
docs/source/classes.rst
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
pycmx Classes
|
||||||
|
=============
|
||||||
|
|
||||||
|
.. autoclass:: pycmx.edit_list.EditList
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
.. autoclass:: pycmx.event.Event
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
.. autoclass:: pycmx.edit.Edit
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
.. autoclass:: pycmx.transition.Transition
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
.. autoclass:: pycmx.channel_map.ChannelMap
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
187
docs/source/conf.py
Normal file
187
docs/source/conf.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
# -*- 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 importlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.abspath('../..'))
|
||||||
|
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
|
project = u'pycmx'
|
||||||
|
copyright = u'(c) 2025, Jamie Hardt'
|
||||||
|
author = u'Jamie Hardt'
|
||||||
|
|
||||||
|
release = importlib.metadata.version("pycmx")
|
||||||
|
version = release
|
||||||
|
# The short X.Y version
|
||||||
|
# The full version, including alpha/beta/rc tags
|
||||||
|
|
||||||
|
|
||||||
|
# -- 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 = 'em'
|
||||||
|
|
||||||
|
# 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], "3p")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# -- 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
|
||||||
7
docs/source/function.rst
Normal file
7
docs/source/function.rst
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Parse Function
|
||||||
|
==============
|
||||||
|
|
||||||
|
|
||||||
|
.. autofunction:: pycmx.parse_cmx_events.parse_cmx3600
|
||||||
|
|
||||||
|
|
||||||
20
docs/source/index.rst
Normal file
20
docs/source/index.rst
Normal 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
|
||||||
|
|
||||||
|
function
|
||||||
|
classes
|
||||||
|
|
||||||
|
Indices and tables
|
||||||
|
==================
|
||||||
|
|
||||||
|
* :ref:`genindex`
|
||||||
|
* :ref:`search`
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
# pycmx init
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
pycmx is a parser for CMX 3600-style EDLs.
|
||||||
|
|
||||||
from .parse_cmx_events import parse_cmx3600, Transition, Event, Edit
|
This module (c) 2025 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'
|
from .parse_cmx_events import parse_cmx3600
|
||||||
|
from .transition import Transition
|
||||||
|
from .event import Event
|
||||||
|
from .edit import Edit
|
||||||
|
|||||||
@@ -2,20 +2,22 @@
|
|||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
from re import (compile, match)
|
from re import (compile, match)
|
||||||
|
from typing import Dict, Tuple, Generator
|
||||||
|
|
||||||
|
|
||||||
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: Dict[str, Tuple] = {
|
||||||
"A" : (False, True, False),
|
"V": (True, False, False),
|
||||||
"A2" : (False, False, True),
|
"A": (False, True, False),
|
||||||
"AA" : (False, True, True),
|
"A2": (False, False, True),
|
||||||
"B" : (True, True, False),
|
"AA": (False, True, True),
|
||||||
"AA/V" : (True, True, True),
|
"B": (True, True, False),
|
||||||
"A2/V" : (True, False, True)
|
"AA/V": (True, True, True),
|
||||||
|
"A2/V": (True, False, True)
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, v=False, audio_channels=set()):
|
def __init__(self, v=False, audio_channels=set()):
|
||||||
@@ -23,70 +25,89 @@ class ChannelMap:
|
|||||||
self.v = v
|
self.v = v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def video(self):
|
def video(self) -> bool:
|
||||||
'True if video is included'
|
'True if video is included'
|
||||||
return self.v
|
return self.v
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def channels(self):
|
def audio(self) -> bool:
|
||||||
|
'True if an audio channel is included'
|
||||||
|
return len(self._audio_channel_set) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> Generator[int, None, None]:
|
||||||
'A generator for each audio channel'
|
'A generator for each audio channel'
|
||||||
for c in self._audio_channel_set:
|
for c in self._audio_channel_set:
|
||||||
yield c
|
yield c
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def a1(self):
|
def a1(self) -> bool:
|
||||||
|
"""True if A1 is included"""
|
||||||
return self.get_audio_channel(1)
|
return self.get_audio_channel(1)
|
||||||
|
|
||||||
@a1.setter
|
@a1.setter
|
||||||
def a1(self,val):
|
def a1(self, val: bool):
|
||||||
self.set_audio_channel(1,val)
|
self.set_audio_channel(1, val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def a2(self):
|
def a2(self) -> bool:
|
||||||
|
"""True if A2 is included"""
|
||||||
return self.get_audio_channel(2)
|
return self.get_audio_channel(2)
|
||||||
|
|
||||||
@a2.setter
|
@a2.setter
|
||||||
def a2(self,val):
|
def a2(self, val: bool):
|
||||||
self.set_audio_channel(2,val)
|
self.set_audio_channel(2, val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def a3(self):
|
def a3(self) -> bool:
|
||||||
|
"""True if A3 is included"""
|
||||||
return self.get_audio_channel(3)
|
return self.get_audio_channel(3)
|
||||||
|
|
||||||
@a3.setter
|
@a3.setter
|
||||||
def a3(self,val):
|
def a3(self, val: bool):
|
||||||
self.set_audio_channel(3,val)
|
self.set_audio_channel(3, val)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def a4(self):
|
def a4(self) -> bool:
|
||||||
|
"""True if A4 is included"""
|
||||||
return self.get_audio_channel(4)
|
return self.get_audio_channel(4)
|
||||||
|
|
||||||
@a4.setter
|
@a4.setter
|
||||||
def a4(self,val):
|
def a4(self, val: bool):
|
||||||
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) -> bool:
|
||||||
|
"""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: bool):
|
||||||
|
"""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(r'^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]
|
||||||
else:
|
else:
|
||||||
matchresult = match(alt_channel_re, event_str)
|
matchresult = match(alt_channel_re, event_str)
|
||||||
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 = audio_ext.audio3
|
||||||
self.a4 = ext.audio4
|
self.a4 = audio_ext.audio4
|
||||||
|
|
||||||
|
def __or__(self, other):
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
|||||||
133
pycmx/edit.py
Normal file
133
pycmx/edit.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from .transition import Transition
|
||||||
|
from .channel_map import ChannelMap
|
||||||
|
# from .parse_cmx_statements import StmtEffectsName
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
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) -> int:
|
||||||
|
"""
|
||||||
|
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) -> ChannelMap:
|
||||||
|
"""
|
||||||
|
Get the :obj:`ChannelMap` object associated with this Edit.
|
||||||
|
"""
|
||||||
|
cm = ChannelMap()
|
||||||
|
cm._append_event(self.edit_statement.channels)
|
||||||
|
if self.audio_ext is not None:
|
||||||
|
cm._append_ext(self.audio_ext)
|
||||||
|
return cm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transition(self) -> Transition:
|
||||||
|
"""
|
||||||
|
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) -> str:
|
||||||
|
"""
|
||||||
|
Get the source in timecode.
|
||||||
|
"""
|
||||||
|
return self.edit_statement.source_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_out(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the source out timecode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.edit_statement.source_out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_in(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the record in timecode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.edit_statement.record_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_out(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the record out timecode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self.edit_statement.record_out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
|
"""
|
||||||
|
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) -> bool:
|
||||||
|
"""
|
||||||
|
Black video or silence should be used as the source for this event.
|
||||||
|
"""
|
||||||
|
return self.source == "BL"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aux_source(self) -> bool:
|
||||||
|
"""
|
||||||
|
An auxiliary source is the source of this event.
|
||||||
|
"""
|
||||||
|
return self.source == "AX"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_file(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
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) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
108
pycmx/edit_list.py
Normal file
108
pycmx/edit_list.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from .parse_cmx_statements import (
|
||||||
|
StmtUnrecognized, StmtEvent, StmtSourceUMID)
|
||||||
|
from .event import Event
|
||||||
|
from .channel_map import ChannelMap
|
||||||
|
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
|
||||||
|
class EditList:
|
||||||
|
"""
|
||||||
|
Represents an entire edit decision list as returned by
|
||||||
|
:func:`~pycmx.parse_cmx3600()`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, statements):
|
||||||
|
self.title_statement = statements[0]
|
||||||
|
self.event_statements = statements[1:]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def format(self) -> str:
|
||||||
|
"""
|
||||||
|
The detected format of the EDL. Possible values are: "3600", "File32",
|
||||||
|
"File128", and "unknown".
|
||||||
|
|
||||||
|
Adobe EDLs with more than 999 events will be reported as "3600".
|
||||||
|
"""
|
||||||
|
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) -> ChannelMap:
|
||||||
|
"""
|
||||||
|
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) -> str:
|
||||||
|
"""
|
||||||
|
The title of this edit list.
|
||||||
|
"""
|
||||||
|
return self.title_statement.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unrecognized_statements(self) -> Generator[StmtUnrecognized,
|
||||||
|
None, None]:
|
||||||
|
"""
|
||||||
|
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) -> Generator[Event, None, None]:
|
||||||
|
'A generator for all the events in the edit list'
|
||||||
|
current_event_num = None
|
||||||
|
event_statements = []
|
||||||
|
for stmt in self.event_statements:
|
||||||
|
if 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) -> Generator[StmtSourceUMID, None, None]:
|
||||||
|
"""
|
||||||
|
A generator for all of the sources in the list
|
||||||
|
"""
|
||||||
|
|
||||||
|
for stmt in self.event_statements:
|
||||||
|
if type(stmt) is StmtSourceUMID:
|
||||||
|
yield stmt
|
||||||
108
pycmx/event.py
Normal file
108
pycmx/event.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2023 Jamie Hardt
|
||||||
|
|
||||||
|
from .parse_cmx_statements import (
|
||||||
|
StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized,
|
||||||
|
StmtEffectsName)
|
||||||
|
from .edit import Edit
|
||||||
|
|
||||||
|
from typing import List, Generator, Optional, Tuple, Any
|
||||||
|
|
||||||
|
|
||||||
|
class Event:
|
||||||
|
"""
|
||||||
|
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
|
||||||
|
event number. """
|
||||||
|
|
||||||
|
def __init__(self, statements):
|
||||||
|
self.statements = statements
|
||||||
|
|
||||||
|
@property
|
||||||
|
def number(self) -> int:
|
||||||
|
"""
|
||||||
|
Return the event number.
|
||||||
|
"""
|
||||||
|
return int(self._edit_statements()[0].event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def edits(self) -> List[Edit]:
|
||||||
|
"""
|
||||||
|
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: List[List[Any]] = [edits_audio]
|
||||||
|
|
||||||
|
if len(edits_audio) == 2:
|
||||||
|
start_name: Optional[StmtClipName] = None
|
||||||
|
end_name: Optional[StmtClipName] = None
|
||||||
|
|
||||||
|
for clip_name in clip_names:
|
||||||
|
if clip_name.affect == 'from':
|
||||||
|
start_name = clip_name
|
||||||
|
elif clip_name.affect == 'to':
|
||||||
|
end_name = clip_name
|
||||||
|
|
||||||
|
the_zip.append([start_name, end_name])
|
||||||
|
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: List[Optional[Any]] = [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(edit_statement=e1[0],
|
||||||
|
audio_ext_statement=e1[1],
|
||||||
|
clip_name_statement=n1,
|
||||||
|
source_file_statement=s1,
|
||||||
|
trans_name_statement=u1)
|
||||||
|
for (e1, n1, s1, u1) in zip(*the_zip)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None,
|
||||||
|
None]:
|
||||||
|
"""
|
||||||
|
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) -> List[StmtEffectsName]:
|
||||||
|
return [s for s in self.statements if type(s) is StmtEffectsName]
|
||||||
|
|
||||||
|
def _edit_statements(self) -> List[StmtEvent]:
|
||||||
|
return [s for s in self.statements if type(s) is StmtEvent]
|
||||||
|
|
||||||
|
def _clip_name_statements(self) -> List[StmtClipName]:
|
||||||
|
return [s for s in self.statements if type(s) is StmtClipName]
|
||||||
|
|
||||||
|
def _source_file_statements(self) -> List[StmtSourceFile]:
|
||||||
|
return [s for s in self.statements if type(s) is StmtSourceFile]
|
||||||
|
|
||||||
|
def _statements_with_audio_ext(self) -> Generator[
|
||||||
|
Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]:
|
||||||
|
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,247 +1,20 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
from .parse_cmx_statements import (parse_cmx3600_statements,
|
# from collections import namedtuple
|
||||||
StmtEvent, StmtFCM, StmtTitle, StmtClipName, StmtSourceFile, StmtAudioExt)
|
|
||||||
|
|
||||||
from .channel_map import ChannelMap
|
from .parse_cmx_statements import (parse_cmx3600_statements)
|
||||||
|
from .edit_list import EditList
|
||||||
|
|
||||||
from collections import namedtuple
|
from typing import TextIO
|
||||||
|
|
||||||
def parse_cmx3600(path):
|
|
||||||
statements = parse_cmx3600_statements(path)
|
|
||||||
return EditList(statements)
|
|
||||||
|
|
||||||
|
|
||||||
class EditList:
|
def parse_cmx3600(f: TextIO) -> 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)
|
Parse a CMX 3600 EDL.
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
:param TextIO f: a file-like object, an opened CMX 3600 .EDL file.
|
||||||
|
:returns: An :class:`pycmx.edit_list.EditList`.
|
||||||
|
"""
|
||||||
|
statements = parse_cmx3600_statements(f)
|
||||||
|
return EditList(statements)
|
||||||
|
|||||||
@@ -1,102 +1,123 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
from .util import collimate
|
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
from itertools import count
|
from typing import TextIO, List
|
||||||
|
|
||||||
|
|
||||||
StmtTitle = namedtuple("Title",["title","line_number"])
|
from .util import collimate
|
||||||
StmtFCM = namedtuple("FCM",["drop","line_number"])
|
|
||||||
StmtEvent = namedtuple("Event",["event","source","channels","trans",\
|
StmtTitle = namedtuple("Title", ["title", "line_number"])
|
||||||
"trans_op","source_in","source_out","record_in","record_out","line_number"])
|
StmtFCM = namedtuple("FCM", ["drop", "line_number"])
|
||||||
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
|
StmtEvent = namedtuple("Event", ["event", "source", "channels", "trans",
|
||||||
StmtClipName = namedtuple("ClipName",["name","affect","line_number"])
|
"trans_op", "source_in", "source_out",
|
||||||
StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
|
"record_in", "record_out", "format",
|
||||||
StmtRemark = namedtuple("Remark",["text","line_number"])
|
"line_number"])
|
||||||
StmtEffectsName = namedtuple("EffectsName",["name","line_number"])
|
StmtAudioExt = namedtuple("AudioExt", ["audio3", "audio4", "line_number"])
|
||||||
StmtTrailer = namedtuple("Trailer",["text","line_number"])
|
StmtClipName = namedtuple("ClipName", ["name", "affect", "line_number"])
|
||||||
StmtSplitEdit = namedtuple("SplitEdit",["video","magnitue", "line_number"])
|
StmtSourceFile = namedtuple("SourceFile", ["filename", "line_number"])
|
||||||
StmtMotionMemory = namedtuple("MotionMemory",["source","fps"]) # FIXME needs more fields
|
StmtRemark = namedtuple("Remark", ["text", "line_number"])
|
||||||
StmtUnrecognized = namedtuple("Unrecognized",["content","line_number"])
|
StmtEffectsName = namedtuple("EffectsName", ["name", "line_number"])
|
||||||
|
StmtSourceUMID = namedtuple("Source", ["name", "umid", "line_number"])
|
||||||
|
StmtSplitEdit = namedtuple("SplitEdit", ["video", "magnitude", "line_number"])
|
||||||
|
StmtMotionMemory = namedtuple(
|
||||||
|
"MotionMemory", ["source", "fps"]) # FIXME needs more fields
|
||||||
|
StmtUnrecognized = namedtuple("Unrecognized", ["content", "line_number"])
|
||||||
|
|
||||||
|
|
||||||
def parse_cmx3600_statements(path):
|
def parse_cmx3600_statements(file: TextIO) -> List[object]:
|
||||||
with open(path,'r') as file:
|
"""
|
||||||
|
Return a list of every statement in the file argument.
|
||||||
|
"""
|
||||||
lines = file.readlines()
|
lines = file.readlines()
|
||||||
line_numbers = count()
|
return [_parse_cmx3600_line(line.strip(), line_number)
|
||||||
return [parse_cmx3600_line(line.strip(), line_number) \
|
for (line_number, line) in enumerate(lines)]
|
||||||
for (line, line_number) in zip(lines,line_numbers)]
|
|
||||||
|
|
||||||
def edl_column_widths(event_field_length, source_field_length):
|
|
||||||
return [event_field_length,2, source_field_length,1,
|
def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
|
||||||
4,2, # chans
|
return [event_field_length, 2, source_field_length, 1,
|
||||||
4,1, # trans
|
4, 2, # chans
|
||||||
3,1, # trans op
|
4, 1, # trans
|
||||||
11,1,
|
3, 1, # trans op
|
||||||
11,1,
|
11, 1,
|
||||||
11,1,
|
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: str, line_number: int) -> object:
|
||||||
|
"""
|
||||||
|
Parses a single CMX EDL line.
|
||||||
|
|
||||||
|
:param line: A single EDL line.
|
||||||
|
:param line_number: The index of this line in the file.
|
||||||
|
"""
|
||||||
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} ")
|
||||||
|
x_event_form_p = re.compile("^([0-9]{4,5}) ")
|
||||||
|
|
||||||
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) is not 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 (m := x_event_form_p.match(line)) is not None:
|
||||||
return parse_standard_form(line, line_number)
|
assert m is not None
|
||||||
|
event_field_length = len(m[1])
|
||||||
|
return _parse_columns_for_standard_form(line, event_field_length,
|
||||||
|
8, line_number)
|
||||||
|
elif short_event_num_p.match(line) is not None:
|
||||||
|
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) -> StmtTitle:
|
||||||
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) -> StmtFCM:
|
||||||
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):
|
|
||||||
return parse_columns_for_standard_form(line, 6, source_field_length, line_number)
|
|
||||||
|
|
||||||
def parse_standard_form(line, line_number):
|
def _parse_long_standard_form(line, source_field_length, line_number):
|
||||||
return parse_columns_for_standard_form(line, 3, 8, line_number)
|
return _parse_columns_for_standard_form(line, 6, source_field_length,
|
||||||
|
line_number)
|
||||||
|
|
||||||
def parse_extended_audio_channels(line, line_number):
|
|
||||||
|
def _parse_standard_form(line, line_number):
|
||||||
|
return _parse_columns_for_standard_form(line, 3, 8, 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,44 +128,53 @@ 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) -> object:
|
||||||
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:"):
|
||||||
return StmtClipName(name=line[13:].strip(), affect="to", line_number=line_number)
|
return StmtClipName(name=line[13:].strip(), affect="to",
|
||||||
|
line_number=line_number)
|
||||||
elif line.startswith("SOURCE FILE:"):
|
elif line.startswith("SOURCE FILE:"):
|
||||||
return StmtSourceFile(filename=line[12:].strip() , line_number=line_number)
|
return StmtSourceFile(filename=line[12:].strip(),
|
||||||
|
line_number=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) -> StmtEffectsName:
|
||||||
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"):
|
||||||
is_video = True
|
is_video = True
|
||||||
|
|
||||||
split_mag = line[24:35]
|
split_mag = line[24:35]
|
||||||
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):
|
|
||||||
col_widths = edl_column_widths(event_field_length, source_field_length)
|
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)
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
column_strings = collimate(line,col_widths)
|
column_strings = collimate(line, col_widths)
|
||||||
|
|
||||||
return StmtEvent(event=column_strings[0],
|
return StmtEvent(event=column_strings[0],
|
||||||
source=column_strings[2].strip(),
|
source=column_strings[2].strip(),
|
||||||
@@ -155,10 +185,9 @@ 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)
|
||||||
|
|
||||||
|
|||||||
89
pycmx/transition.py
Normal file
89
pycmx/transition.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2023 Jamie Hardt
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
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) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
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) -> bool:
|
||||||
|
"`True` if this transition is a cut."
|
||||||
|
return self.transition == 'C'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dissolve(self) -> bool:
|
||||||
|
"`True` if this traansition is a dissolve."
|
||||||
|
return self.transition == 'D'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def wipe(self) -> bool:
|
||||||
|
"`True` if this transition is a wipe."
|
||||||
|
return self.transition.startswith('W')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def effect_duration(self) -> int:
|
||||||
|
"""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) -> Optional[int]:
|
||||||
|
"Wipes are identified by a particular number."
|
||||||
|
if self.wipe:
|
||||||
|
return int(self.transition[1:])
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_background(self) -> bool:
|
||||||
|
"`True` if this edit is a key background."
|
||||||
|
return self.transition == Transition.KeyBackground
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_foreground(self) -> bool:
|
||||||
|
"`True` if this edit is a key foreground."
|
||||||
|
return self.transition == Transition.Key
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_out(self) -> bool:
|
||||||
|
"""
|
||||||
|
`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 == Transition.KeyOut
|
||||||
@@ -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 []
|
||||||
@@ -13,52 +28,3 @@ def collimate(a_string, column_widths):
|
|||||||
element = a_string[:width]
|
element = a_string[:width]
|
||||||
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) )
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
56
pyproject.toml
Normal file
56
pyproject.toml
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "pycmx"
|
||||||
|
version = "1.3.0"
|
||||||
|
description = "Python CMX 3600 Edit Decision List Parser"
|
||||||
|
authors = ["Jamie Hardt <jamiehardt@me.com>"]
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = [
|
||||||
|
'parser',
|
||||||
|
'film',
|
||||||
|
'broadcast'
|
||||||
|
]
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 5 - Production/Stable',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Topic :: Multimedia',
|
||||||
|
'Topic :: Multimedia :: Video',
|
||||||
|
'Topic :: Text Processing',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Programming Language :: Python :: 3.9',
|
||||||
|
'Programming Language :: Python :: 3.10',
|
||||||
|
'Programming Language :: Python :: 3.11',
|
||||||
|
'Programming Language :: Python :: 3.12',
|
||||||
|
'Programming Language :: Python :: 3.13'
|
||||||
|
]
|
||||||
|
homepage = "https://github.com/iluvcapra/pycmx"
|
||||||
|
documentation = "https://pycmx.readthedocs.io/"
|
||||||
|
repository = "https://github.com/iluvcapra/pycmx.git"
|
||||||
|
urls.Tracker = "https://github.com/iluvcapra/pycmx/issues"
|
||||||
|
|
||||||
|
[tool.poetry.extras]
|
||||||
|
doc = ['sphinx', 'sphinx_rtd_theme']
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.8"
|
||||||
|
sphinx = { version='>= 5.3.0', optional=true}
|
||||||
|
sphinx_rtd_theme = {version ='>= 1.1.1', optional=true}
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
typeCheckingMode = "basic"
|
||||||
|
|
||||||
|
[tool.pylint]
|
||||||
|
max-line-length = 88
|
||||||
|
disable = [
|
||||||
|
"C0103", # (invalid-name)
|
||||||
|
"C0114", # (missing-module-docstring)
|
||||||
|
"C0115", # (missing-class-docstring)
|
||||||
|
"C0116", # (missing-function-docstring)
|
||||||
|
"R0903", # (too-few-public-methods)
|
||||||
|
"R0913", # (too-many-arguments)
|
||||||
|
"W0105", # (pointless-string-statement)
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
19
setup.py
19
setup.py
@@ -1,19 +0,0 @@
|
|||||||
from setuptools import setup
|
|
||||||
|
|
||||||
with open("README.md", "r") as fh:
|
|
||||||
long_description = fh.read()
|
|
||||||
|
|
||||||
setup(name='pycmx',
|
|
||||||
version='0.6',
|
|
||||||
author='Jamie Hardt',
|
|
||||||
author_email='jamiehardt@me.com',
|
|
||||||
description='CMX 3600 Edit Decision List Parser',
|
|
||||||
long_description_content_type="text/markdown",
|
|
||||||
long_description=long_description,
|
|
||||||
url='https://github.com/iluvcapra/pycmx',
|
|
||||||
classifiers=['Development Status :: 4 - Beta',
|
|
||||||
'License :: OSI Approved :: MIT License',
|
|
||||||
'Topic :: Multimedia',
|
|
||||||
'Topic :: Multimedia :: Video',
|
|
||||||
'Topic :: Text Processing'],
|
|
||||||
packages=['pycmx'])
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import test_parse
|
||||||
|
|||||||
13945
tests/edls/adobe_dai109_test.txt
Normal file
13945
tests/edls/adobe_dai109_test.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,72 +2,121 @@ from unittest import TestCase
|
|||||||
|
|
||||||
import pycmx
|
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(files, counts):
|
for fn, count in zip(type(self).files, counts):
|
||||||
edl = pycmx.parse_cmx3600(f"tests/edls/{fn}" )
|
with open("tests/edls/" + fn, 'r') as f:
|
||||||
actual = len(list(edl.events ))
|
edl = pycmx.parse_cmx3600(f)
|
||||||
self.assertTrue( actual == count , f"expected {count} in file {fn} but found {actual}")
|
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)
|
||||||
|
|
||||||
|
def test_event_sanity(self):
|
||||||
|
for fn in type(self).files:
|
||||||
|
path = "tests/edls/" + fn
|
||||||
|
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(
|
||||||
self.assertEqual( events[0].edits[0].source_in , "00:00:00:00")
|
events[0].edits[0].source_file, "OY_HEAD_LEADER.MOV")
|
||||||
self.assertEqual( events[0].edits[0].source_out , "00:00:00:00")
|
self.assertEqual(events[0].edits[0].source_in, "00:00:00:00")
|
||||||
self.assertEqual( events[0].edits[0].record_in , "01:00:00:00")
|
self.assertEqual(events[0].edits[0].source_out, "00:00:00:00")
|
||||||
self.assertEqual( events[0].edits[0].record_out , "01:00:08:00")
|
self.assertEqual(events[0].edits[0].record_in, "01:00:00:00")
|
||||||
self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut)
|
self.assertEqual(events[0].edits[0].record_out, "01:00:08:00")
|
||||||
|
self.assertTrue(
|
||||||
def test_channel_mop(self):
|
events[0].edits[0].transition.kind == pycmx.Transition.Cut)
|
||||||
edl = pycmx.parse_cmx3600("tests/edls/TEST.edl")
|
|
||||||
events = list( edl.events )
|
|
||||||
self.assertFalse( events[0].edits[0].channels.video)
|
|
||||||
self.assertFalse( events[0].edits[0].channels.a1)
|
|
||||||
self.assertTrue( events[0].edits[0].channels.a2)
|
|
||||||
self.assertTrue( events[2].edits[0].channels.get_audio_channel(7) )
|
|
||||||
|
|
||||||
|
def test_channel_map(self):
|
||||||
|
with open("tests/edls/TEST.edl", 'r') as f:
|
||||||
|
edl = pycmx.parse_cmx3600(f)
|
||||||
|
events = list(edl.events)
|
||||||
|
self.assertFalse(events[0].edits[0].channels.video)
|
||||||
|
self.assertFalse(events[0].edits[0].channels.a1)
|
||||||
|
self.assertTrue(events[0].edits[0].channels.a2)
|
||||||
|
self.assertTrue(events[2].edits[0].channels.get_audio_channel(7))
|
||||||
|
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(events[42].number, 43)
|
||||||
|
self.assertEqual(len(events[42].edits), 2)
|
||||||
|
|
||||||
self.assertEqual( int(events[42].number) , 43)
|
self.assertEqual(events[42].edits[0].source, "TC_R1_V1")
|
||||||
self.assertEqual( len(events[42].edits), 2)
|
self.assertEqual(events[42].edits[0].clip_name,
|
||||||
|
"TC R1 V1.2 TEMP1 FX ST.WAV")
|
||||||
|
self.assertEqual(events[42].edits[0].source_in, "00:00:00:00")
|
||||||
|
self.assertEqual(events[42].edits[0].source_out, "00:00:00:00")
|
||||||
|
self.assertEqual(events[42].edits[0].record_in, "01:08:56:09")
|
||||||
|
self.assertEqual(events[42].edits[0].record_out, "01:08:56:09")
|
||||||
|
self.assertTrue(
|
||||||
|
events[42].edits[0].transition.kind == pycmx.Transition.Cut)
|
||||||
|
|
||||||
|
self.assertEqual(events[42].edits[1].source, "TC_R1_V6")
|
||||||
|
self.assertEqual(events[42].edits[1].clip_name,
|
||||||
|
"TC R1 V6 TEMP2 ST FX.WAV")
|
||||||
|
self.assertEqual(events[42].edits[1].source_in, "00:00:00:00")
|
||||||
|
self.assertEqual(events[42].edits[1].source_out, "00:00:00:00")
|
||||||
|
self.assertEqual(events[42].edits[1].record_in, "01:08:56:09")
|
||||||
|
self.assertEqual(events[42].edits[1].record_out, "01:08:56:11")
|
||||||
|
self.assertTrue(
|
||||||
|
events[42].edits[1].transition.kind ==
|
||||||
|
pycmx.Transition.Dissolve)
|
||||||
|
|
||||||
self.assertEqual( events[42].edits[0].source , "TC_R1_V1")
|
def test_line_numbers(self):
|
||||||
self.assertEqual( events[42].edits[0].clip_name , "TC R1 V1.2 TEMP1 FX ST.WAV")
|
with open("tests/edls/ToD_R4_LOCK3.1_030618_Video.edl") as f:
|
||||||
self.assertEqual( events[42].edits[0].source_in , "00:00:00:00")
|
edl = pycmx.parse_cmx3600(f)
|
||||||
self.assertEqual( events[42].edits[0].source_out , "00:00:00:00")
|
|
||||||
self.assertEqual( events[42].edits[0].record_in , "01:08:56:09")
|
|
||||||
self.assertEqual( events[42].edits[0].record_out , "01:08:56:09")
|
|
||||||
self.assertTrue( events[42].edits[0].transition.kind == pycmx.Transition.Cut)
|
|
||||||
|
|
||||||
|
|
||||||
self.assertEqual( events[42].edits[1].source , "TC_R1_V6")
|
|
||||||
self.assertEqual( events[42].edits[1].clip_name , "TC R1 V6 TEMP2 ST FX.WAV")
|
|
||||||
self.assertEqual( events[42].edits[1].source_in , "00:00:00:00")
|
|
||||||
self.assertEqual( events[42].edits[1].source_out , "00:00:00:00")
|
|
||||||
self.assertEqual( events[42].edits[1].record_in , "01:08:56:09")
|
|
||||||
self.assertEqual( events[42].edits[1].record_out , "01:08:56:11")
|
|
||||||
self.assertTrue( events[42].edits[1].transition.kind == pycmx.Transition.Dissolve)
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
def test_adobe_wide(self):
|
||||||
|
with open("tests/edls/adobe_dai109_test.txt", 'r',
|
||||||
|
encoding='ISO-8859-1') as f:
|
||||||
|
edl = pycmx.parse_cmx3600(f)
|
||||||
|
events = list(edl.events)
|
||||||
|
self.assertEqual(len(events), 2839)
|
||||||
|
|||||||
Reference in New Issue
Block a user