mirror of
https://github.com/iluvcapra/pycmx.git
synced 2026-07-02 04:10:59 +00:00
Compare commits
193 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 15d14914ea | |||
| f44d5c470c | |||
| ca873af772 | |||
| ab40ba1fa0 | |||
| 782b9f7425 | |||
| 483efdcc32 | |||
| 6867f9ac4a | |||
| 24272569e3 | |||
| 16afb8fc64 | |||
| d1e3eb85d3 | |||
| 8d3bef2c09 | |||
| e0b7025fff | |||
| fbe9e9eeb9 | |||
| 168fd16473 | |||
| e4b6036ab7 | |||
| ce3d8088a1 | |||
| 9f41758b37 | |||
| 07407baf96 | |||
| aa309a4458 | |||
| 2b8dd4c1c9 | |||
| 387158b07c | |||
| 741c9d95e8 | |||
| 53764900ba | |||
| 66791081be | |||
| 5e49c19ac2 | |||
| 4593729e3a | |||
| 703ba1140a | |||
| 0f06c4de5c | |||
| 920af8a86d |
@@ -0,0 +1,41 @@
|
|||||||
|
# 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.7", "3.8", "3.9", "3.10", "3.11"]
|
||||||
|
|
||||||
|
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
|
||||||
|
python3 -m pip install -e .
|
||||||
|
# if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||||
|
- 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
|
||||||
|
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
||||||
|
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
||||||
|
- name: Test with pytest
|
||||||
|
run: |
|
||||||
|
pytest
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
name: Upload Python Package
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3.5.2
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4.6.0
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install setuptools build wheel twine
|
||||||
|
- name: Build and publish
|
||||||
|
env:
|
||||||
|
TWINE_USERNAME: __token__
|
||||||
|
TWINE_PASSWORD: ${{ secrets.PYPI_APIKEY }}
|
||||||
|
run: |
|
||||||
|
python -m build .
|
||||||
|
twine upload dist/*
|
||||||
|
- name: Report to Mastodon
|
||||||
|
uses: cbrgm/mastodon-github-action@v1.0.1
|
||||||
|
with:
|
||||||
|
message: |
|
||||||
|
I just released a new version of pycmx, my library for reading CMX EDLs!
|
||||||
|
#sounddesign #filmmaking #python
|
||||||
|
${{ github.server_url }}/${{ github.repository }}
|
||||||
|
env:
|
||||||
|
MASTODON_URL: ${{ secrets.MASTODON_URL }}
|
||||||
|
MASTODON_ACCESS_TOKEN: ${{ secrets.MASTODON_ACCESS_TOKEN }}
|
||||||
+13
@@ -7,3 +7,16 @@
|
|||||||
# Python egg metadata, regenerated from source files by setuptools.
|
# Python egg metadata, regenerated from source files by setuptools.
|
||||||
/*.egg-info
|
/*.egg-info
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
# Vim Swapfiles
|
||||||
|
*.swp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
venv/
|
||||||
|
|
||||||
|
.coverage
|
||||||
|
lcov.info
|
||||||
|
|
||||||
|
|
||||||
|
# venv
|
||||||
|
venv/
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
language: python
|
|
||||||
python:
|
|
||||||
- "3.6"
|
|
||||||
script:
|
|
||||||
- "python3 setup.py test"
|
|
||||||
install:
|
|
||||||
- "pip3 install setuptools"
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
[](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
|
||||||
|
|
||||||
@@ -6,60 +9,79 @@ 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.
|
||||||
* 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.
|
||||||
|
* Symbolically decodes transitions and audio channels.
|
||||||
|
* Does not parse or validate timecodes, does not enforce framerates, does not
|
||||||
|
parameterize timecode or framerates in any way. This makes the parser more
|
||||||
|
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
|
||||||
>>> events = pycmx.parse_cmx3600("INS4_R1_010417.edl")
|
>>> with open("tests/edls/TEST.edl") as f
|
||||||
>>> print(events[5:8])
|
... edl = pycmx.parse_cmx3600(f)
|
||||||
[CmxEvent(title='INS4_R1_010417', number='000006',
|
...
|
||||||
clip_name='V1A-6A', source_name='A192C008_160909_R1BY',
|
>>> edl.title
|
||||||
channels=CmxChannelMap(v=True, audio_channels=set()),
|
'DC7 R1_v8.2'
|
||||||
source_start='19:26:38:13', source_finish='19:27:12:03',
|
|
||||||
record_start='01:00:57:15', record_finish='01:01:31:05',
|
|
||||||
fcm_drop=False),
|
|
||||||
CmxEvent(title='INS4_R1_010417', number='000007',
|
|
||||||
clip_name='1-4A', source_name='A188C004_160908_R1BY',
|
|
||||||
channels=CmxChannelMap(v=True, audio_channels=set()),
|
|
||||||
source_start='19:29:48:01', source_finish='19:30:01:00',
|
|
||||||
record_start='01:01:31:05', record_finish='01:01:44:04',
|
|
||||||
fcm_drop=False),
|
|
||||||
CmxEvent(title='INS4_R1_010417', number='000008',
|
|
||||||
clip_name='2G-3', source_name='A056C007_160819_R1BY',
|
|
||||||
channels=CmxChannelMap(v=True, audio_channels=set()),
|
|
||||||
source_start='19:56:27:14', source_finish='19:56:41:00',
|
|
||||||
record_start='01:01:44:04', record_finish='01:01:57:14',
|
|
||||||
fcm_drop=False)]
|
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Known Issues/Roadmap
|
### Reading Events and Edits
|
||||||
|
|
||||||
To be addressed:
|
`EditList.events` is a generator...
|
||||||
* Does not decode transitions.
|
|
||||||
* Does not decode "M2" speed changes.
|
|
||||||
* Does not decode repair notes, audio notes or other Avid-specific notes.
|
|
||||||
* Does not decode Avid marker list.
|
|
||||||
|
|
||||||
May not be addressed:
|
```
|
||||||
|
>>> events = list( edl.events )
|
||||||
|
>>> len(events)
|
||||||
|
120
|
||||||
|
>>> events[43].number
|
||||||
|
'044'
|
||||||
|
```
|
||||||
|
|
||||||
* Does not parse source list at end of EDL.
|
...and events contain 1...n edits.
|
||||||
|
|
||||||
Probably beyond the scope of this module:
|
```
|
||||||
* Does not parse timecode entries.
|
>>> events[43].edits[0].source_in
|
||||||
* Does not parse color correction notes. For this functionality we refer you to [pycdl](https://pypi.org/project/pycdl/) or [cdl-convert](https://pypi.org/project/cdl-convert/).
|
'00:00:00:00'
|
||||||
|
>>> events[43].edits[0].transition.cut
|
||||||
|
True
|
||||||
|
>>> events[43].edits[0].record_out
|
||||||
|
'01:10:21:10'
|
||||||
|
```
|
||||||
|
|
||||||
## Should I Use This?
|
### Acessing Transitions and Enabled Channels
|
||||||
|
|
||||||
|
```
|
||||||
|
>>> events[41].edits[0].transition.dissolve
|
||||||
|
False
|
||||||
|
>>> events[41].edits[1].transition.dissolve
|
||||||
|
True
|
||||||
|
>>> events[41].edits[0].clip_name
|
||||||
|
'TC R1 V1.2 TEMP1 DX M.WAV'
|
||||||
|
>>> events[41].edits[1].clip_name
|
||||||
|
'TC R1 V6 TEMP2 M DX.WAV'
|
||||||
|
|
||||||
|
# parsed channel maps are also
|
||||||
|
# available to the client
|
||||||
|
>>> events[2].edits[0].channels.get_audio_channel(7)
|
||||||
|
True
|
||||||
|
>>> events[2].edits[0].channels.get_audio_channel(6)
|
||||||
|
False
|
||||||
|
>>> for c in events[2].edits[0].channels.channels:
|
||||||
|
... print(f"Audio channel {c} is present")
|
||||||
|
...
|
||||||
|
Audio channel 7 is present
|
||||||
|
>>> events[2].edits[0].channels.video
|
||||||
|
False
|
||||||
|
```
|
||||||
|
|
||||||
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!
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Binary file not shown.
@@ -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.
@@ -0,0 +1 @@
|
|||||||
|
build/*
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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('../..'))
|
||||||
|
print(sys.path)
|
||||||
|
|
||||||
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
|
project = u'pycmx'
|
||||||
|
copyright = u'2022, 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 = '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], 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
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
Parse Function
|
||||||
|
==============
|
||||||
|
|
||||||
|
|
||||||
|
.. autofunction:: pycmx.parse_cmx_events.parse_cmx3600
|
||||||
|
|
||||||
|
|
||||||
@@ -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`
|
||||||
+14
-3
@@ -1,5 +1,16 @@
|
|||||||
# 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 import parse_cmx3600
|
This module (c) 2022 Jamie Hardt. For more information on your rights to
|
||||||
|
copy and reuse this software, refer to the LICENSE file included with the
|
||||||
|
distribution.
|
||||||
|
"""
|
||||||
|
|
||||||
__version__ = '0.5'
|
__version__ = '1.2.0'
|
||||||
|
|
||||||
|
from .parse_cmx_events import parse_cmx3600
|
||||||
|
from .transition import Transition
|
||||||
|
from .event import Event
|
||||||
|
from .edit import Edit
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from re import (compile, match)
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
class ChannelMap:
|
||||||
|
"""
|
||||||
|
Represents a set of all the channels to which an event applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_chan_map : Dict[str, Tuple] = {
|
||||||
|
"V" : (True, False, False),
|
||||||
|
"A" : (False, True, False),
|
||||||
|
"A2" : (False, False, True),
|
||||||
|
"AA" : (False, True, True),
|
||||||
|
"B" : (True, True, False),
|
||||||
|
"AA/V" : (True, True, True),
|
||||||
|
"A2/V" : (True, False, True)
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, v=False, audio_channels=set()):
|
||||||
|
self._audio_channel_set = audio_channels
|
||||||
|
self.v = v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video(self):
|
||||||
|
'True if video is included'
|
||||||
|
return self.v
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio(self):
|
||||||
|
'True if an audio channel is included'
|
||||||
|
return len(self._audio_channel_set) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self):
|
||||||
|
'A generator for each audio channel'
|
||||||
|
for c in self._audio_channel_set:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a1(self):
|
||||||
|
"""True if A1 is included"""
|
||||||
|
return self.get_audio_channel(1)
|
||||||
|
|
||||||
|
@a1.setter
|
||||||
|
def a1(self,val):
|
||||||
|
self.set_audio_channel(1,val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a2(self):
|
||||||
|
"""True if A2 is included"""
|
||||||
|
return self.get_audio_channel(2)
|
||||||
|
|
||||||
|
@a2.setter
|
||||||
|
def a2(self,val):
|
||||||
|
self.set_audio_channel(2,val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a3(self):
|
||||||
|
"""True if A3 is included"""
|
||||||
|
return self.get_audio_channel(3)
|
||||||
|
|
||||||
|
@a3.setter
|
||||||
|
def a3(self,val):
|
||||||
|
self.set_audio_channel(3,val)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def a4(self):
|
||||||
|
"""True if A4 is included"""
|
||||||
|
return self.get_audio_channel(4)
|
||||||
|
|
||||||
|
@a4.setter
|
||||||
|
def a4(self,val):
|
||||||
|
self.set_audio_channel(4,val)
|
||||||
|
|
||||||
|
def get_audio_channel(self,chan_num):
|
||||||
|
"""True if chan_num is included"""
|
||||||
|
return (chan_num in self._audio_channel_set)
|
||||||
|
|
||||||
|
def set_audio_channel(self,chan_num,enabled):
|
||||||
|
"""If enabled is true, chan_num will be included"""
|
||||||
|
if enabled:
|
||||||
|
self._audio_channel_set.add(chan_num)
|
||||||
|
elif self.get_audio_channel(chan_num):
|
||||||
|
self._audio_channel_set.remove(chan_num)
|
||||||
|
|
||||||
|
def _append_event(self, event_str):
|
||||||
|
alt_channel_re = compile(r'^A(\d+)')
|
||||||
|
if event_str in self._chan_map:
|
||||||
|
channels = self._chan_map[event_str]
|
||||||
|
self.v = channels[0]
|
||||||
|
self.a1 = channels[1]
|
||||||
|
self.a2 = channels[2]
|
||||||
|
else:
|
||||||
|
matchresult = match(alt_channel_re, event_str)
|
||||||
|
if matchresult:
|
||||||
|
self.set_audio_channel(int( matchresult.group(1)), True )
|
||||||
|
|
||||||
|
def _append_ext(self, audio_ext):
|
||||||
|
self.a3 = audio_ext.audio3
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
class CmxEvent:
|
|
||||||
def __init__(self,title,number,clip_name,source_name,channels,
|
|
||||||
transition,source_start,source_finish,
|
|
||||||
record_start, record_finish, fcm_drop, remarks = [] ,
|
|
||||||
unrecognized = [], line_number = None):
|
|
||||||
self.title = title
|
|
||||||
self.number = number
|
|
||||||
self.clip_name = clip_name
|
|
||||||
self.source_name = source_name
|
|
||||||
self.channels = channels
|
|
||||||
self.transition = transition
|
|
||||||
self.source_start = source_start
|
|
||||||
self.source_finish = source_finish
|
|
||||||
self.record_start = record_start
|
|
||||||
self.record_finish = record_finish
|
|
||||||
self.fcm_drop = fcm_drop
|
|
||||||
self.remarks = remarks
|
|
||||||
self.unrecgonized = unrecognized
|
|
||||||
self.black = (source_name == 'BL')
|
|
||||||
self.aux_source = (source_name == 'AX')
|
|
||||||
self.line_number = line_number
|
|
||||||
|
|
||||||
|
|
||||||
def can_accept(self):
|
|
||||||
return {'AudioExt','Remark','SourceFile','ClipName','EffectsName'}
|
|
||||||
|
|
||||||
def accept_statement(self, statement):
|
|
||||||
statement_type = type(statement).__name__
|
|
||||||
if statement_type == 'AudioExt':
|
|
||||||
self.channels.appendExt(statement)
|
|
||||||
elif statement_type == 'Remark':
|
|
||||||
self.remarks.append(statement.text)
|
|
||||||
elif statement_type == 'SourceFile':
|
|
||||||
self.source_name = statement.filename
|
|
||||||
elif statement_type == 'ClipName':
|
|
||||||
self.clip_name = statement.name
|
|
||||||
elif statement_type == 'EffectsName':
|
|
||||||
self.transition.name = statement.name
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"""CmxEvent(title={self.title.__repr__()},number={self.number.__repr__()},\
|
|
||||||
clip_name={self.clip_name.__repr__()},source_name={self.source_name.__repr__()},\
|
|
||||||
channels={self.channels.__repr__()},transition={self.transition.__repr__()},\
|
|
||||||
source_start={self.source_start.__repr__()},source_finish={self.source_finish.__repr__()},\
|
|
||||||
record_start={self.source_start.__repr__()},record_finish={self.record_finish.__repr__()},\
|
|
||||||
fcm_drop={self.fcm_drop.__repr__()},remarks={self.remarks.__repr__()},line_number={self.line_number.__repr__()})"""
|
|
||||||
|
|
||||||
|
|
||||||
class CmxTransition:
|
|
||||||
def __init__(self, transition, operand):
|
|
||||||
self.transition = transition
|
|
||||||
self.operand = operand
|
|
||||||
self.name = ''
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cut(self):
|
|
||||||
return self.transition == 'C'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def dissolve(self):
|
|
||||||
return self.transition == 'D'
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wipe(self):
|
|
||||||
return self.transition.startswith('W')
|
|
||||||
|
|
||||||
|
|
||||||
@property
|
|
||||||
def effect_duration(self):
|
|
||||||
return int(self.operand)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def wipe_number(self):
|
|
||||||
if self.wipe:
|
|
||||||
return int(self.transition[1:])
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_background(self):
|
|
||||||
return self.transition == 'KB'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_foreground(self):
|
|
||||||
return self.transition == 'K'
|
|
||||||
|
|
||||||
@property
|
|
||||||
def key_out(self):
|
|
||||||
return self.transition == 'KO'
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"""CmxTransition(transition={self.transition.__repr__()},operand={self.operand.__repr__()})"""
|
|
||||||
|
|
||||||
+126
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 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.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from .parse_cmx_statements import (StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized, StmtEffectsName)
|
||||||
|
from .edit import Edit
|
||||||
|
|
||||||
|
class Event:
|
||||||
|
"""
|
||||||
|
Represents a collection of :class:`Edit`s, all with the same event number.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, statements):
|
||||||
|
self.statements = statements
|
||||||
|
|
||||||
|
@property
|
||||||
|
def number(self):
|
||||||
|
"""
|
||||||
|
Return the event number.
|
||||||
|
"""
|
||||||
|
return int(self._edit_statements()[0].event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def edits(self):
|
||||||
|
"""
|
||||||
|
Returns the edits. Most events will have a single edit, a single event
|
||||||
|
will have multiple edits when a dissolve, wipe or key transition needs
|
||||||
|
to be performed.
|
||||||
|
"""
|
||||||
|
edits_audio = list( self._statements_with_audio_ext() )
|
||||||
|
clip_names = self._clip_name_statements()
|
||||||
|
source_files= self._source_file_statements()
|
||||||
|
|
||||||
|
the_zip = [edits_audio]
|
||||||
|
|
||||||
|
if len(edits_audio) == 2:
|
||||||
|
cn = [None, None]
|
||||||
|
for clip_name in clip_names:
|
||||||
|
if clip_name.affect == 'from':
|
||||||
|
cn[0] = clip_name
|
||||||
|
elif clip_name.affect == 'to':
|
||||||
|
cn[1] = clip_name
|
||||||
|
|
||||||
|
the_zip.append(cn)
|
||||||
|
else:
|
||||||
|
if len(edits_audio) == len(clip_names):
|
||||||
|
the_zip.append(clip_names)
|
||||||
|
else:
|
||||||
|
the_zip.append([None] * len(edits_audio) )
|
||||||
|
|
||||||
|
if len(edits_audio) == len(source_files):
|
||||||
|
the_zip.append(source_files)
|
||||||
|
elif len(source_files) == 1:
|
||||||
|
the_zip.append( source_files * len(edits_audio) )
|
||||||
|
else:
|
||||||
|
the_zip.append([None] * len(edits_audio) )
|
||||||
|
|
||||||
|
# attach trans name to last event
|
||||||
|
try:
|
||||||
|
trans_statement = self._trans_name_statements()[0]
|
||||||
|
trans_names = [None] * (len(edits_audio) - 1)
|
||||||
|
trans_names.append(trans_statement)
|
||||||
|
the_zip.append(trans_names)
|
||||||
|
except IndexError:
|
||||||
|
the_zip.append([None] * len(edits_audio) )
|
||||||
|
|
||||||
|
|
||||||
|
return [ Edit(e1[0],e1[1],n1,s1,u1) for (e1,n1,s1,u1) in zip(*the_zip) ]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def unrecognized_statements(self):
|
||||||
|
"""
|
||||||
|
A generator for all the unrecognized statements in the event.
|
||||||
|
"""
|
||||||
|
for s in self.statements:
|
||||||
|
if type(s) is StmtUnrecognized:
|
||||||
|
yield s
|
||||||
|
|
||||||
|
def _trans_name_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtEffectsName]
|
||||||
|
|
||||||
|
def _edit_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtEvent]
|
||||||
|
|
||||||
|
def _clip_name_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtClipName]
|
||||||
|
|
||||||
|
def _source_file_statements(self):
|
||||||
|
return [s for s in self.statements if type(s) is StmtSourceFile]
|
||||||
|
|
||||||
|
def _statements_with_audio_ext(self):
|
||||||
|
for (s1, s2) in zip(self.statements, self.statements[1:]):
|
||||||
|
if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
|
||||||
|
yield (s1,s2)
|
||||||
|
elif type(s1) is StmtEvent:
|
||||||
|
yield (s1, None)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
# pycmx
|
|
||||||
# (c) 2018 Jamie Hardt
|
|
||||||
|
|
||||||
from .parse_cmx_statements import parse_cmx3600_statements
|
|
||||||
from .cmx_event import CmxEvent, CmxTransition
|
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from re import compile, match
|
|
||||||
|
|
||||||
class NamedTupleParser:
|
|
||||||
|
|
||||||
def __init__(self, tuple_list):
|
|
||||||
self.tokens = tuple_list
|
|
||||||
self.current_token = None
|
|
||||||
|
|
||||||
def peek(self):
|
|
||||||
return self.tokens[0]
|
|
||||||
|
|
||||||
def at_end(self):
|
|
||||||
return len(self.tokens) == 0
|
|
||||||
|
|
||||||
def next_token(self):
|
|
||||||
self.current_token = self.peek()
|
|
||||||
self.tokens = self.tokens[1:]
|
|
||||||
|
|
||||||
def accept(self, type_name):
|
|
||||||
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):
|
|
||||||
assert( self.accept(type_name) )
|
|
||||||
|
|
||||||
|
|
||||||
class CmxChannelMap:
|
|
||||||
"""
|
|
||||||
Represents a set of all the channels to which an event applies.
|
|
||||||
"""
|
|
||||||
|
|
||||||
chan_map = { "V" : (True, False, False),
|
|
||||||
"A" : (False, True, False),
|
|
||||||
"A2" : (False, False, True),
|
|
||||||
"AA" : (False, True, True),
|
|
||||||
"B" : (True, True, False),
|
|
||||||
"AA/V" : (True, True, True),
|
|
||||||
"A2/V" : (True, False, True)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def __init__(self, v=False, audio_channels=set()):
|
|
||||||
self._audio_channel_set = audio_channels
|
|
||||||
self.v = v
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a1(self):
|
|
||||||
return self.get_audio_channel(1)
|
|
||||||
|
|
||||||
@a1.setter
|
|
||||||
def a1(self,val):
|
|
||||||
self.set_audio_channel(1,val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a2(self):
|
|
||||||
return self.get_audio_channel(2)
|
|
||||||
|
|
||||||
@a2.setter
|
|
||||||
def a2(self,val):
|
|
||||||
self.set_audio_channel(2,val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a3(self):
|
|
||||||
return self.get_audio_channel(3)
|
|
||||||
|
|
||||||
@a3.setter
|
|
||||||
def a3(self,val):
|
|
||||||
self.set_audio_channel(3,val)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def a4(self):
|
|
||||||
return self.get_audio_channel(4)
|
|
||||||
|
|
||||||
@a4.setter
|
|
||||||
def a4(self,val):
|
|
||||||
self.set_audio_channel(4,val)
|
|
||||||
|
|
||||||
|
|
||||||
def get_audio_channel(self,chan_num):
|
|
||||||
return (chan_num in self._audio_channel_set)
|
|
||||||
|
|
||||||
def set_audio_channel(self,chan_num,enabled):
|
|
||||||
if enabled:
|
|
||||||
self._audio_channel_set.add(chan_num)
|
|
||||||
elif self.get_audio_channel(chan_num):
|
|
||||||
self._audio_channel_set.remove(chan_num)
|
|
||||||
|
|
||||||
|
|
||||||
def appendEvent(self, event_str):
|
|
||||||
alt_channel_re = compile('^A(\d+)')
|
|
||||||
if event_str in self.chan_map:
|
|
||||||
channels = self.chan_map[event_str]
|
|
||||||
self.v = channels[0]
|
|
||||||
self.a1 = channels[1]
|
|
||||||
self.a2 = channels[2]
|
|
||||||
else:
|
|
||||||
matchresult = match(alt_channel_re, event_str)
|
|
||||||
if matchresult:
|
|
||||||
self.set_audio_channel(int( matchresult.group(1)), True )
|
|
||||||
|
|
||||||
def appendExt(self, audio_ext):
|
|
||||||
self.a3 = ext.audio3
|
|
||||||
self.a4 = ext.audio4
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return f"CmxChannelMap(v={self.v.__repr__()}, audio_channels={self._audio_channel_set.__repr__()})"
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cmx3600(file):
|
|
||||||
"""Accepts the path to a CMX EDL and returns a list of all events contained therein."""
|
|
||||||
statements = parse_cmx3600_statements(file)
|
|
||||||
parser = NamedTupleParser(statements)
|
|
||||||
parser.expect('Title')
|
|
||||||
title = parser.current_token.title
|
|
||||||
return event_list(title, parser)
|
|
||||||
|
|
||||||
|
|
||||||
def event_list(title, parser):
|
|
||||||
state = {"fcm_drop" : False}
|
|
||||||
|
|
||||||
events_result = []
|
|
||||||
this_event = None
|
|
||||||
|
|
||||||
while not parser.at_end():
|
|
||||||
if parser.accept('FCM'):
|
|
||||||
state['fcm_drop'] = parser.current_token.drop
|
|
||||||
elif parser.accept('Event'):
|
|
||||||
if this_event != None:
|
|
||||||
events_result.append(this_event)
|
|
||||||
|
|
||||||
raw_event = parser.current_token
|
|
||||||
channels = CmxChannelMap(v=False, audio_channels=set([]))
|
|
||||||
channels.appendEvent(raw_event.channels)
|
|
||||||
|
|
||||||
this_event = CmxEvent(title=title,number=int(raw_event.event), clip_name=None ,
|
|
||||||
source_name=raw_event.source,
|
|
||||||
channels=channels,
|
|
||||||
transition=CmxTransition(raw_event.trans, raw_event.trans_op),
|
|
||||||
source_start= raw_event.source_in,
|
|
||||||
source_finish= raw_event.source_out,
|
|
||||||
record_start= raw_event.record_in,
|
|
||||||
record_finish= raw_event.record_out,
|
|
||||||
fcm_drop= state['fcm_drop'],
|
|
||||||
line_number = raw_event.line_number)
|
|
||||||
elif parser.accept('AudioExt') or parser.accept('ClipName') or \
|
|
||||||
parser.accept('SourceFile') or parser.accept('EffectsName') or \
|
|
||||||
parser.accept('Remark'):
|
|
||||||
this_event.accept_statement(parser.current_token)
|
|
||||||
elif parser.accept('Trailer'):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
parser.next_token()
|
|
||||||
|
|
||||||
if this_event != None:
|
|
||||||
events_result.append(this_event)
|
|
||||||
|
|
||||||
return events_result
|
|
||||||
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from .parse_cmx_statements import (parse_cmx3600_statements, StmtEvent,StmtFCM )
|
||||||
|
from .edit_list import EditList
|
||||||
|
|
||||||
|
def parse_cmx3600(f):
|
||||||
|
"""
|
||||||
|
Parse a CMX 3600 EDL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
f : a file-like object, anything that's readlines-able.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
An :class:`pycmx.edit_list.EditList`.
|
||||||
|
"""
|
||||||
|
statements = parse_cmx3600_statements(f)
|
||||||
|
return EditList(statements)
|
||||||
|
|
||||||
@@ -1,34 +1,38 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018 Jamie Hardt
|
||||||
|
|
||||||
# Parsed Statement Data Structures
|
|
||||||
#
|
|
||||||
# These represent individual lines that have been typed and have undergone some light symbolic parsing.
|
|
||||||
|
|
||||||
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","trans_op","source_in","source_out","record_in","record_out","line_number"])
|
StmtEvent = namedtuple("Event",["event","source","channels","trans",\
|
||||||
|
"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","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"])
|
||||||
|
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:
|
"""
|
||||||
|
Return a list of every statement in the file argument.
|
||||||
|
"""
|
||||||
lines = file.readlines()
|
lines = file.readlines()
|
||||||
line_numbers = count()
|
line_numbers = count()
|
||||||
return [parse_cmx3600_line(line.strip(), line_number) for (line, line_number) in zip(lines,line_numbers)]
|
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
|
||||||
@@ -38,53 +42,63 @@ def edl_column_widths(event_field_length, source_field_length):
|
|||||||
11,1,
|
11,1,
|
||||||
11]
|
11]
|
||||||
|
|
||||||
def parse_cmx3600_line(line, line_number):
|
def _edl_m2_column_widths():
|
||||||
|
return [2, # "M2"
|
||||||
|
3,3, #
|
||||||
|
8,8,1,4,2,1,4,13,3,1,1]
|
||||||
|
|
||||||
|
|
||||||
|
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:"):
|
||||||
|
return _parse_split(line, line_number)
|
||||||
|
elif line.startswith("M2"):
|
||||||
|
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)
|
||||||
@@ -95,23 +109,39 @@ 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() , line_number=line_number)
|
return StmtClipName(name=line[15:].strip() , affect="from", line_number=line_number)
|
||||||
|
elif line.startswith("TO CLIP NAME:"):
|
||||||
|
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):
|
||||||
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_unrecognized(line, line_number):
|
def _parse_split(line, line_number):
|
||||||
|
split_type = line[10:21]
|
||||||
|
is_video = False
|
||||||
|
if split_type.startswith("VIDEO"):
|
||||||
|
is_video = True
|
||||||
|
|
||||||
|
split_mag = line[24:35]
|
||||||
|
return StmtSplitEdit(video=is_video, magnitude=split_mag, line_number=line_number)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_motion_memory(line, line_number):
|
||||||
|
return StmtMotionMemory(source = "", fps="")
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -127,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 == Transition.KeyBackground
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key_foreground(self):
|
||||||
|
"`True` if this edit is a key foreground."
|
||||||
|
return self.transition == 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 == Transition.KeyOut
|
||||||
+16
-2
@@ -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,4 +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:])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["flit_core >=3.2,<4"]
|
||||||
|
build-backend = "flit_core.buildapi"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pycmx"
|
||||||
|
authors = [{name = "Jamie Hardt", email = "jamiehardt@me.com"}]
|
||||||
|
readme = "README.md"
|
||||||
|
dynamic = ["version", "description"]
|
||||||
|
requires-python = "~=3.7"
|
||||||
|
classifiers = [
|
||||||
|
'Development Status :: 5 - Production/Stable',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Topic :: Multimedia',
|
||||||
|
'Topic :: Multimedia :: Video',
|
||||||
|
'Topic :: Text Processing',
|
||||||
|
'Programming Language :: Python :: 3.7',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Programming Language :: Python :: 3.9',
|
||||||
|
'Programming Language :: Python :: 3.10',
|
||||||
|
'Programming Language :: Python :: 3.11'
|
||||||
|
]
|
||||||
|
dependencies = [
|
||||||
|
|
||||||
|
]
|
||||||
|
keywords = [
|
||||||
|
'parser',
|
||||||
|
'film',
|
||||||
|
'broadcast'
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.flit.module]
|
||||||
|
name = "pycmx"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
doc = [
|
||||||
|
'sphinx >= 5.3.0',
|
||||||
|
'sphinx_rtd_theme >= 1.1.1',
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Home = "https://github.com/iluvcapra/pycmx"
|
||||||
|
Documentation = "https://pycmx.readthedocs.io/"
|
||||||
|
Source = "https://github.com/iluvcapra/pycmx.git"
|
||||||
|
Issues = "https://github.com/iluvcapra/pycmx/issues"
|
||||||
|
|
||||||
|
[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)
|
||||||
|
]
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from setuptools import setup
|
|
||||||
|
|
||||||
with open("README.md", "r") as fh:
|
|
||||||
long_description = fh.read()
|
|
||||||
|
|
||||||
setup(name='pycmx',
|
|
||||||
version='0.5',
|
|
||||||
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
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+98
-19
@@ -4,31 +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, 148 ]
|
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):
|
||||||
events = pycmx.parse_cmx3600(f"tests/edls/{fn}" )
|
for fn in type(self).files:
|
||||||
self.assertTrue( len(events) == count , f"expected {len(events)} but found {count}")
|
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_audio_channels(self):
|
|
||||||
events = pycmx.parse_cmx3600(f"tests/edls/TEST.edl" )
|
|
||||||
self.assertTrue(events[0].channels.a2)
|
def test_events(self):
|
||||||
self.assertFalse(events[0].channels.a1)
|
with open("tests/edls/TEST.edl",'r') as f:
|
||||||
self.assertTrue(events[2].channels.get_audio_channel(7))
|
edl = pycmx.parse_cmx3600(f)
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(1))
|
events = list( edl.events )
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(2))
|
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(3))
|
self.assertEqual( events[0].number , 1)
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(4))
|
self.assertEqual( events[0].edits[0].source , "OY_HEAD_")
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(10))
|
self.assertEqual( events[0].edits[0].clip_name , "HEAD LEADER MONO")
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(11))
|
self.assertEqual( events[0].edits[0].source_file , "OY_HEAD_LEADER.MOV")
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(12))
|
self.assertEqual( events[0].edits[0].source_in , "00:00:00:00")
|
||||||
self.assertFalse(events[2].channels.get_audio_channel(13))
|
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_out , "01:00:08:00")
|
||||||
|
self.assertTrue( events[0].edits[0].transition.kind == pycmx.Transition.Cut)
|
||||||
|
|
||||||
|
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):
|
||||||
|
with open("tests/edls/TEST.edl",'r') as f:
|
||||||
|
edl = pycmx.parse_cmx3600(f)
|
||||||
|
events = list( edl.events )
|
||||||
|
|
||||||
|
self.assertEqual( events[42].number , 43)
|
||||||
|
self.assertEqual( len(events[42].edits), 2)
|
||||||
|
|
||||||
|
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].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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user