40 Commits

Author SHA1 Message Date
Jamie Hardt
6da04b2c07 Nudged version 2025-01-07 11:52:26 -08:00
Jamie Hardt
89b6cde808 Merge pull request #12 from iluvcapra/feat-adobe
Added support for Adobe Premiere EDLs > 999 events
2025-01-07 11:49:54 -08:00
Jamie Hardt
5e4dde5aa2 Merge branch 'master' of https://github.com/iluvcapra/pycmx into feat-adobe 2025-01-05 22:34:30 -08:00
Jamie Hardt
b6379ec1fe tweaking doc version method 2025-01-05 12:44:03 -08:00
Jamie Hardt
04731d634f Updated version read logic 2025-01-05 12:37:56 -08:00
Jamie Hardt
2dcfdabf01 Merge branch 'master' of https://github.com/iluvcapra/pycmx into feat-adobe 2025-01-05 12:26:03 -08:00
Jamie Hardt
7ed423deaf Dropping max-complexity argument 2025-01-05 12:19:22 -08:00
Jamie Hardt
2be779fe53 Flake8 for docs, test, bin 2025-01-05 12:16:16 -08:00
Jamie Hardt
2adff6dd01 Flake8 lints, flake8 linting fully activated. 2025-01-05 12:03:38 -08:00
Jamie Hardt
7871acdfd0 Merge branch 'master' of https://github.com/iluvcapra/pycmx into feat-adobe 2025-01-05 11:33:57 -08:00
Jamie Hardt
8bb6dad1da Some documentation and autopep 2025-01-05 11:32:23 -08:00
Jamie Hardt
f88b82e8fb README updated 2025-01-05 10:47:57 -08:00
Jamie Hardt
a86ef7ed2e Added support for Adobe Premiere EDLs > 999 events 2025-01-04 13:20:21 -08:00
Jamie Hardt
1e1331eadb Adding a test case for #11 2025-01-02 22:07:20 -08:00
Jamie Hardt
7a0481dbf9 Update bsky_test.yml 2024-11-26 11:50:10 -08:00
Jamie Hardt
20ad04e862 Update bsky_test.yml 2024-11-26 11:43:39 -08:00
Jamie Hardt
9e54aa5fcf Update bsky_test.yml 2024-11-26 11:34:16 -08:00
Jamie Hardt
71aff9baf7 Update bsky_test.yml 2024-11-26 11:33:37 -08:00
Jamie Hardt
ab55cab160 Create bsky_test.yml 2024-11-26 11:29:43 -08:00
Jamie Hardt
a717589683 Bumped version and tweaked Bluesky message 2024-11-26 11:19:01 -08:00
Jamie Hardt
f48c164e1b Merge pull request #10 from iluvcapra/maint-poetry
Sprucing-up Build System and Workflows
2024-11-26 11:17:10 -08:00
Jamie Hardt
be1dc99e94 Update pyproject.toml
Fixed typo
2024-11-26 11:16:33 -08:00
Jamie Hardt
fbdfcddfff Re-activated Bluesky posting
Updated classifiers in pyproject.toml
2024-11-26 11:15:08 -08:00
Jamie Hardt
156828b648 Update pythonpublish.yml 2024-11-26 11:11:25 -08:00
Jamie Hardt
1894a143b1 Removed second dependencies block 2024-11-26 11:02:53 -08:00
Jamie Hardt
c0d278e079 Removing 3.7 support 2024-11-26 11:01:49 -08:00
Jamie Hardt
7d3a58bff8 Small change 2024-11-26 10:57:01 -08:00
Jamie Hardt
f1381f5f46 Merge branch 'maint-poetry' of https://github.com/iluvcapra/pycmx into maint-poetry 2024-11-26 10:54:37 -08:00
Jamie Hardt
dc00b52b61 Merge branch 'master' of https://github.com/iluvcapra/pycmx into maint-poetry 2024-11-26 10:53:39 -08:00
Jamie Hardt
571ffdefd7 Merge branch 'master' into maint-poetry 2024-11-26 10:52:55 -08:00
Jamie Hardt
a79cb02139 Updating workflows
Added 3.13 support, publish to Bluesky, Poetry build system
2024-11-26 10:50:38 -08:00
Jamie Hardt
ce1a0d32bb Update __init__.py
Nudged version to 1.2.2
2023-11-08 10:10:08 -08:00
Jamie Hardt
daa5d58a66 Create .readthedocs.yaml 2023-11-08 10:02:24 -08:00
Jamie Hardt
38ce1445a1 Merge pull request #9 from iluvcapra/maint-py3.12
Added Python 3.12 support
2023-11-08 08:17:04 -08:00
Jamie Hardt
552d007360 Merge branch 'master' into maint-py3.12 2023-11-08 08:16:42 -08:00
Jamie Hardt
d79fdcc6a8 Update pyproject.toml
Added Python 3.12 classifier
2023-11-08 08:15:47 -08:00
Jamie Hardt
3187a50a6b Update pyproject.toml 2023-11-08 08:15:26 -08:00
Jamie Hardt
8e8d4f5753 Update pyproject.toml
Added 3.12 classifier
2023-11-08 08:14:57 -08:00
Jamie Hardt
cc371cd486 Update python-package.yml
Added 3.12 to test matrix
2023-11-08 08:13:42 -08:00
Jamie Hardt
795a666a74 Update conf.py 2023-05-31 19:21:27 -07:00
20 changed files with 14398 additions and 349 deletions

5
.flake8 Normal file
View 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
View 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

View File

@@ -16,8 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
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 }}
@@ -28,14 +27,12 @@ jobs:
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
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
# 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
flake8 . --count --max-line-length=79 --statistics
- name: Test with pytest
run: |
pytest

View File

@@ -3,7 +3,6 @@ name: Upload Python Package
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
@@ -15,9 +14,9 @@ jobs:
environment:
name: release
steps:
- uses: actions/checkout@v3.5.2
- uses: actions/checkout@v4.2.2
- name: Set up Python
uses: actions/setup-python@v4.6.0
uses: actions/setup-python@v5.3.0
with:
python-version: '3.x'
- name: Install dependencies
@@ -25,16 +24,19 @@ jobs:
python -m pip install --upgrade pip
pip install build
- name: Build package
run: python -m build
- name: pypi-publish
uses: pypa/gh-action-pypi-publish@v1.8.6
- name: Report to Mastodon
uses: cbrgm/mastodon-github-action@v1.0.1
run: python -m build .
- name: Publish to Pypi
uses: pypa/gh-action-pypi-publish@v1.12.2
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 }}
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

32
.readthedocs.yaml Normal file
View 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

View File

@@ -5,12 +5,14 @@
# 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
* The major variations of the CMX 3600: the standard, "File32" and "File128"
formats are automatically detected and properly read.
* The major variations of the CMX 3600: the standard, "File32", "File128" and
long Adobe Premiere event numbers 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
available to the client, including clip name and source file data.
@@ -83,5 +85,3 @@ Audio channel 7 is present
>>> events[2].edits[0].channels.video
False
```

View File

@@ -12,6 +12,7 @@ logging.basicConfig(format=FORMAT)
log = logging.getLogger(__name__)
def all_video_edits(edl):
for event in edl.events:
for edit in event.edits:
@@ -30,19 +31,22 @@ def get_scene_name(edit, pattern):
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'])
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']) )
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'] ))
outfile.write("%-12s\t%-12s\t%s\n" %
(o['start'], o['end'], o['scene']))
def scene_list(infile, outfile, out_format, pattern):
@@ -51,23 +55,23 @@ def scene_list(infile, outfile, out_format, pattern):
current_scene_name = None
grouped_edits = [ ]
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([ ])
grouped_edits.append([])
current_scene_name = this_scene_name
grouped_edits[-1].append(edit)
out_list = [ ]
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 ) }
'scene': get_scene_name(group[0], pattern)}
)
if out_format == 'cmx':
@@ -80,22 +84,28 @@ def scene_list(infile, outfile, out_format, pattern):
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'),
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]+)". ' + \
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='?',
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)
scene_list(infile=infile, outfile=args.outfile,
out_format=args.format, pattern=args.pattern)
if __name__ == '__main__':

View File

@@ -12,22 +12,22 @@
# 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('../..'))
import pycmx
# -- Project information -----------------------------------------------------
project = u'pycmx'
copyright = u'(c) 2023, Jamie Hardt'
copyright = u'(c) 2025, Jamie Hardt'
author = u'Jamie Hardt'
release = importlib.metadata.version("pycmx")
version = release
# The short X.Y version
version = pycmx.__version__
# The full version, including alpha/beta/rc tags
release = pycmx.__version__
# -- General configuration ---------------------------------------------------
@@ -145,7 +145,7 @@ latex_documents = [
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'pycmx', u'pycmx Documentation',
[author], 1)
[author], "3p")
]

View File

@@ -2,13 +2,11 @@
"""
pycmx is a parser for CMX 3600-style EDLs.
This module (c) 2023 Jamie Hardt. For more information on your rights to
This module (c) 2025 Jamie Hardt. For more information on your rights to
copy and reuse this software, refer to the LICENSE file included with the
distribution.
"""
__version__ = '1.2.1'
from .parse_cmx_events import parse_cmx3600
from .transition import Transition
from .event import Event

View File

@@ -4,19 +4,20 @@
from re import (compile, match)
from typing import Dict, Tuple, Generator
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)
_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()):
@@ -46,7 +47,7 @@ class ChannelMap:
@a1.setter
def a1(self, val: bool):
self.set_audio_channel(1,val)
self.set_audio_channel(1, val)
@property
def a2(self) -> bool:
@@ -55,7 +56,7 @@ class ChannelMap:
@a2.setter
def a2(self, val: bool):
self.set_audio_channel(2,val)
self.set_audio_channel(2, val)
@property
def a3(self) -> bool:
@@ -64,7 +65,7 @@ class ChannelMap:
@a3.setter
def a3(self, val: bool):
self.set_audio_channel(3,val)
self.set_audio_channel(3, val)
@property
def a4(self) -> bool:
@@ -72,14 +73,14 @@ class ChannelMap:
return self.get_audio_channel(4)
@a4.setter
def a4(self,val: bool):
self.set_audio_channel(4,val)
def a4(self, val: bool):
self.set_audio_channel(4, val)
def get_audio_channel(self, chan_num) -> bool:
"""True if chan_num is included"""
return (chan_num in self._audio_channel_set)
def set_audio_channel(self,chan_num, enabled: bool):
def set_audio_channel(self, chan_num, enabled: bool):
"""If enabled is true, chan_num will be included"""
if enabled:
self._audio_channel_set.add(chan_num)
@@ -96,7 +97,7 @@ class ChannelMap:
else:
matchresult = match(alt_channel_re, event_str)
if matchresult:
self.set_audio_channel(int( matchresult.group(1)), True )
self.set_audio_channel(int(matchresult.group(1)), True)
def _append_ext(self, audio_ext):
self.a3 = audio_ext.audio3
@@ -109,5 +110,4 @@ class ChannelMap:
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)
return ChannelMap(v=out_v, audio_channels=out_a)

View File

@@ -7,12 +7,16 @@ from .channel_map import ChannelMap
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):
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
@@ -35,7 +39,7 @@ class Edit:
"""
cm = ChannelMap()
cm._append_event(self.edit_statement.channels)
if self.audio_ext != None:
if self.audio_ext is not None:
cm._append_ext(self.audio_ext)
return cm
@@ -45,9 +49,12 @@ class Edit:
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)
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)
return Transition(self.edit_statement.trans,
self.edit_statement.trans_op, None)
@property
def source_in(self) -> str:
@@ -124,5 +131,3 @@ class Edit:
return None
else:
return self.clip_name_statement.name

View File

@@ -1,16 +1,20 @@
# pycmx
# (c) 2018 Jamie Hardt
from .parse_cmx_statements import (StmtUnrecognized, StmtFCM, StmtEvent, StmtSourceUMID)
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()`.
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:]
@@ -20,8 +24,11 @@ class EditList:
"""
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)
first_event = next(
(s for s in self.event_statements if type(s) is StmtEvent), None)
if first_event:
if first_event.format == 8:
@@ -35,7 +42,6 @@ class EditList:
else:
return 'unknown'
@property
def channels(self) -> ChannelMap:
"""
@@ -49,7 +55,6 @@ class EditList:
return retval
@property
def title(self) -> str:
"""
@@ -57,9 +62,9 @@ class EditList:
"""
return self.title_statement.title
@property
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, None]:
def unrecognized_statements(self) -> Generator[StmtUnrecognized,
None, None]:
"""
A generator for all the unrecognized statements in the list.
"""
@@ -67,17 +72,13 @@ class EditList:
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'
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 type(stmt) is StmtEvent:
if current_event_num is None:
current_event_num = stmt.event
event_statements.append(stmt)
@@ -105,5 +106,3 @@ class EditList:
for stmt in self.event_statements:
if type(stmt) is StmtSourceUMID:
yield stmt

View File

@@ -1,15 +1,18 @@
# pycmx
# (c) 2023 Jamie Hardt
from .parse_cmx_statements import (StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized, StmtEffectsName)
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.
"""
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
event number. """
def __init__(self, statements):
self.statements = statements
@@ -28,9 +31,9 @@ class Event:
will have multiple edits when a dissolve, wipe or key transition needs
to be performed.
"""
edits_audio = list( self._statements_with_audio_ext() )
edits_audio = list(self._statements_with_audio_ext())
clip_names = self._clip_name_statements()
source_files= self._source_file_statements()
source_files = self._source_file_statements()
the_zip: List[List[Any]] = [edits_audio]
@@ -49,14 +52,14 @@ class Event:
if len(edits_audio) == len(clip_names):
the_zip.append(clip_names)
else:
the_zip.append([None] * len(edits_audio) )
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) )
the_zip.append(source_files * len(edits_audio))
else:
the_zip.append([None] * len(edits_audio) )
the_zip.append([None] * len(edits_audio))
# attach trans name to last event
try:
@@ -65,16 +68,18 @@ class Event:
trans_names.append(trans_statement)
the_zip.append(trans_names)
except IndexError:
the_zip.append([None] * len(edits_audio) )
the_zip.append([None] * len(edits_audio))
return [ Edit(edit_statement=e1[0],
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) ]
trans_name_statement=u1)
for (e1, n1, s1, u1) in zip(*the_zip)]
@property
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, None]:
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None,
None]:
"""
A generator for all the unrecognized statements in the event.
"""
@@ -94,11 +99,10 @@ class Event:
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]:
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)
yield (s1, s2)
elif type(s1) is StmtEvent:
yield (s1, None)

View File

@@ -8,13 +8,13 @@ from .edit_list import EditList
from typing import TextIO
def parse_cmx3600(f: TextIO):
def parse_cmx3600(f: TextIO) -> EditList:
"""
Parse a CMX 3600 EDL.
:param TextIO f: a file-like object, anything that's readlines-able.
: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)

View File

@@ -2,27 +2,28 @@
# (c) 2018 Jamie Hardt
import re
import sys
from collections import namedtuple
from itertools import count
from typing import TextIO, List
from .util import collimate
StmtTitle = namedtuple("Title",["title","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","format","line_number"])
StmtAudioExt = namedtuple("AudioExt",["audio3","audio4","line_number"])
StmtClipName = namedtuple("ClipName",["name","affect","line_number"])
StmtSourceFile = namedtuple("SourceFile",["filename","line_number"])
StmtRemark = namedtuple("Remark",["text","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"])
StmtTitle = namedtuple("Title", ["title", "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", "format",
"line_number"])
StmtAudioExt = namedtuple("AudioExt", ["audio3", "audio4", "line_number"])
StmtClipName = namedtuple("ClipName", ["name", "affect", "line_number"])
StmtSourceFile = namedtuple("SourceFile", ["filename", "line_number"])
StmtRemark = namedtuple("Remark", ["text", "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(file: TextIO) -> List[object]:
@@ -30,47 +31,58 @@ def parse_cmx3600_statements(file: TextIO) -> List[object]:
Return a list of every statement in the file argument.
"""
lines = file.readlines()
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_number, line) in enumerate(lines)]
def _edl_column_widths(event_field_length, source_field_length):
return [event_field_length,2, source_field_length,1,
4,2, # chans
4,1, # trans
3,1, # trans op
11,1,
11,1,
11,1,
def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
return [event_field_length, 2, source_field_length, 1,
4, 2, # chans
4, 1, # trans
3, 1, # trans op
11, 1,
11, 1,
11, 1,
11]
def _edl_m2_column_widths():
return [2, # "M2"
3,3, #
8,8,1,4,2,1,4,13,3,1,1]
# 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):
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} ")
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:"):
return _parse_title(line,line_number)
return _parse_title(line, line_number)
elif line.startswith("FCM:"):
return _parse_fcm(line, line_number)
elif long_event_num_p.match(line) != None:
length_file_128 = sum(_edl_column_widths(6,128))
elif long_event_num_p.match(line) is not None:
length_file_128 = sum(_edl_column_widths(6, 128))
if len(line) < length_file_128:
return _parse_long_standard_form(line, 32, line_number)
else:
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:
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"):
return _parse_extended_audio_channels(line,line_number)
return _parse_extended_audio_channels(line, line_number)
elif line.startswith("*"):
return _parse_remark( line[1:].strip(), line_number)
return _parse_remark(line[1:].strip(), line_number)
elif line.startswith(">>> SOURCE"):
return _parse_source_umid_statement(line, line_number)
elif line.startswith("EFFECTS NAME IS"):
@@ -83,23 +95,28 @@ def _parse_cmx3600_line(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()
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()
if val == "DROP FRAME":
return StmtFCM(drop= True, line_number=line_num)
return StmtFCM(drop=True, line_number=line_num)
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_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):
return _parse_columns_for_standard_form(line, 3, 8, line_number)
def _parse_extended_audio_channels(line, line_number):
content = line.strip()
if content == "AUD 3":
@@ -111,20 +128,26 @@ def _parse_extended_audio_channels(line, line_number):
else:
return StmtUnrecognized(content=line, line_number=line_number)
def _parse_remark(line, line_number) -> object:
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:"):
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:"):
return StmtSourceFile(filename=line[12:].strip() , line_number=line_number)
return StmtSourceFile(filename=line[12:].strip(),
line_number=line_number)
else:
return StmtRemark(text=line, line_number=line_number)
def _parse_effects_name(line, line_number) -> StmtEffectsName:
name = line[16:].strip()
return StmtEffectsName(name=name, line_number=line_number)
def _parse_split(line, line_number):
split_type = line[10:21]
is_video = False
@@ -132,23 +155,26 @@ def _parse_split(line, line_number):
is_video = True
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):
return StmtMotionMemory(source = "", fps="")
return StmtMotionMemory(source="", fps="")
def _parse_unrecognized(line, 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)
if sum(col_widths) > len(line):
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],
source=column_strings[2].strip(),
@@ -159,11 +185,9 @@ def _parse_columns_for_standard_form(line, event_field_length, source_field_leng
source_out=column_strings[12].strip(),
record_in=column_strings[14].strip(),
record_out=column_strings[16].strip(),
line_number=line_number,
format=source_field_length)
line_number=line_number, format=source_field_length)
def _parse_source_umid_statement(line, line_number):
trimmed = line[3:].strip()
# trimmed = line[3:].strip()
return StmtSourceUMID(name=None, umid=None, line_number=line_number)

View File

@@ -3,6 +3,7 @@
from typing import Optional
class Transition:
"""
A CMX transition: a wipe, dissolve or cut.
@@ -86,5 +87,3 @@ class Transition:
the key foreground and replaced with the key background.
"""
return self.transition == Transition.KeyOut

View File

@@ -28,4 +28,3 @@ def collimate(a_string, column_widths):
element = a_string[:width]
rest = a_string[width:]
return [element] + collimate(rest, column_widths[1:])

View File

@@ -1,48 +1,40 @@
[build-system]
requires = ["flit_core >=3.2,<4"]
build-backend = "flit_core.buildapi"
[project]
[tool.poetry]
name = "pycmx"
authors = [{name = "Jamie Hardt", email = "jamiehardt@me.com"}]
version = "1.3.0"
description = "Python CMX 3600 Edit Decision List Parser"
authors = ["Jamie Hardt <jamiehardt@me.com>"]
license = "MIT"
readme = "README.md"
dynamic = ["version", "description"]
requires-python = "~=3.7"
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.7',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11'
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13'
]
dependencies = [
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"
]
keywords = [
'parser',
'film',
'broadcast'
]
[tool.poetry.extras]
doc = ['sphinx', 'sphinx_rtd_theme']
[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.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"
@@ -58,3 +50,7 @@ disable = [
"R0913", # (too-many-arguments)
"W0105", # (pointless-string-statement)
]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,10 @@ from unittest import TestCase
import pycmx
class TestParse(TestCase):
files = ["INS4_R1_010417.edl" ,
files = ["INS4_R1_010417.edl",
"INS4_R1_DX_092117.edl",
"STP R1 v082517.edl",
"ToD_R4_LOCK3.1_030618_Video.edl",
@@ -15,99 +16,107 @@ class TestParse(TestCase):
def test_event_counts(self):
counts = [ 287, 466, 250 , 376, 120 , 3 , 466 ]
counts = [287, 466, 250, 376, 120, 3, 466]
for fn, count in zip(type(self).files, counts):
with open("tests/edls/" + fn ,'r') as f:
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))
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:
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 )
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:
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 )
self.assertTrue(len(event.edits) > 0)
self.assertTrue(event.number == index + 1)
def test_events(self):
with open("tests/edls/TEST.edl",'r') as f:
with open("tests/edls/TEST.edl", 'r') as f:
edl = pycmx.parse_cmx3600(f)
events = list( edl.events )
events = list(edl.events)
self.assertEqual( events[0].number , 1)
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].source_file , "OY_HEAD_LEADER.MOV")
self.assertEqual( events[0].edits[0].source_in , "00:00:00:00")
self.assertEqual( events[0].edits[0].source_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)
self.assertEqual(events[0].number, 1)
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].source_file, "OY_HEAD_LEADER.MOV")
self.assertEqual(events[0].edits[0].source_in, "00:00:00:00")
self.assertEqual(events[0].edits[0].source_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:
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)
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:
with open("tests/edls/TEST.edl", 'r') as f:
edl = pycmx.parse_cmx3600(f)
events = list( edl.events )
events = list(edl.events)
self.assertEqual( events[42].number , 43)
self.assertEqual( len(events[42].edits), 2)
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[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)
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)
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:
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
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)