mirror of
https://github.com/iluvcapra/pycmx.git
synced 2025-12-31 08:50:54 +00:00
Compare commits
85 Commits
16-feat-as
...
4e81810584
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e81810584 | |||
| faf2596a57 | |||
| 1e9fbe339c | |||
| a8d00470d4 | |||
| fe1e59e731 | |||
| ec8a08074d | |||
|
|
ef683a7683 | ||
|
|
d778f64230 | ||
| 1b0ccd4ef7 | |||
| 498d5c8fea | |||
| af5d937aeb | |||
| 637f4ab9a4 | |||
| 4cd635ff17 | |||
| 26e1e38320 | |||
| 0e97742336 | |||
| 67ea12042f | |||
| 6b910a0920 | |||
| a8f35a9ffc | |||
|
|
242f2e08d5 | ||
| 36be259177 | |||
| 4b73dc7730 | |||
| 3cf31fa462 | |||
| 33bd5a0001 | |||
| ebdc73198c | |||
| 610d406e97 | |||
| 28307608fc | |||
| 9ab3804c89 | |||
| cf1b3fb42c | |||
| 6041d4158e | |||
| 886561f2c5 | |||
| 5e4bce7be8 | |||
| 7a2917c357 | |||
| 0dd5ab33a5 | |||
| 4471953696 | |||
| 45228dcdf4 | |||
| 09ccdb8c8d | |||
| 43dd48dea7 | |||
| abfdfaffd8 | |||
| 48f41ef4dc | |||
| 139259777e | |||
| f4f0ba9d74 | |||
| a0c2bc77bf | |||
| 7d6a6e9a33 | |||
| 73d860aa49 | |||
|
|
51e1946c5d | ||
|
|
16a61bfcb0 | ||
|
|
5a093e10d2 | ||
| 58f28e3e2e | |||
| 5dc6da8f99 | |||
| c9987dac91 | |||
| 1d6c99aba3 | |||
| 717b045967 | |||
| f0a445b2b2 | |||
| f968b777a8 | |||
| 89fdefa565 | |||
|
|
ead9f8aa13 | ||
| 3248601445 | |||
| 82daa88b8d | |||
| ed4b81adf3 | |||
| d17354682c | |||
| f56a2aef4f | |||
| 7b875900f9 | |||
| fa19b9841f | |||
| 437ebef9c6 | |||
| fe0818bfcc | |||
| 69dee73299 | |||
| 1d78f11b11 | |||
| d071d6c27e | |||
| 785b7a9a08 | |||
| 3cdae1761f | |||
| 5c02d09a7a | |||
| d0d30702ac | |||
| cc07efef24 | |||
| 155011abfa | |||
| e0d59e3ec7 | |||
| 8cdcccce45 | |||
| 656f546d12 | |||
| 14320c709c | |||
| 5ad938c54b | |||
| ec8c3e30f5 | |||
| 30bbb05c2d | |||
| dd78fcc1ac | |||
| ecb09da7ba | |||
| aed9560b1e | |||
|
|
5e0b5f4708 |
5
.flake8
5
.flake8
@@ -1,5 +0,0 @@
|
|||||||
[flake8]
|
|
||||||
per-file-ignores =
|
|
||||||
pycmx/__init__.py: F401
|
|
||||||
tests/__init__.py: F401
|
|
||||||
|
|
||||||
11
.github/workflows/python-package.yml
vendored
11
.github/workflows/python-package.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
@@ -26,13 +26,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
python -m pip install flake8 pytest
|
python -m pip install -e .[dev]
|
||||||
python -m pip install -e .
|
- name: Lint with ruff
|
||||||
- name: Lint with flake8
|
|
||||||
run: |
|
run: |
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
ruff check src/
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
flake8 . --count --max-line-length=79 --statistics
|
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: |
|
run: |
|
||||||
pytest
|
pytest
|
||||||
|
|||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
@@ -9,11 +9,11 @@ version: 2
|
|||||||
build:
|
build:
|
||||||
os: ubuntu-20.04
|
os: ubuntu-20.04
|
||||||
tools:
|
tools:
|
||||||
python: "3.10"
|
python: "3.13"
|
||||||
# You can also specify other tool versions:
|
jobs:
|
||||||
# nodejs: "16"
|
install:
|
||||||
# rust: "1.55"
|
- pip install --upgrade pip
|
||||||
# golang: "1.17"
|
- pip install --group 'doc' -e .
|
||||||
|
|
||||||
# Build documentation in the docs/ directory with Sphinx
|
# Build documentation in the docs/ directory with Sphinx
|
||||||
sphinx:
|
sphinx:
|
||||||
@@ -28,5 +28,3 @@ python:
|
|||||||
install:
|
install:
|
||||||
- method: pip
|
- method: pip
|
||||||
path: .
|
path: .
|
||||||
extra_requirements:
|
|
||||||
- doc
|
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -5,8 +5,7 @@
|
|||||||
|
|
||||||
# pycmx
|
# pycmx
|
||||||
|
|
||||||
The `pycmx` package provides a basic interface for parsing a CMX 3600 EDL and
|
The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
|
||||||
its most most common variations.
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -15,10 +14,13 @@ its most most common variations.
|
|||||||
read. Event number field and source name field sizes are determined
|
read. Event number field and source name field sizes are determined
|
||||||
dynamically for each statement for a high level of compliance at the expense
|
dynamically for each statement for a high level of compliance at the expense
|
||||||
of strictness.
|
of strictness.
|
||||||
|
* A more relaxed "tolerant" mode allows parsing of an EDL file where columns
|
||||||
|
use non-standard widths.
|
||||||
* Preserves relationship between events and individual edits/clips.
|
* Preserves relationship between events and individual edits/clips.
|
||||||
* Remark or comment fields with common recognized forms are read and
|
* Remark or comment fields with common recognized forms are read and
|
||||||
available to the client, including clip name and source file data.
|
available to the client, including clip name and source file data.
|
||||||
* ASC SOP, Saturation and FRMC statements are parsed and decoded.
|
* [ASC CDL][asc] and FRMC/VFX framecount statements are parsed and
|
||||||
|
decoded.
|
||||||
* Symbolically decodes transitions and audio channels.
|
* Symbolically decodes transitions and audio channels.
|
||||||
* Does not parse or validate timecodes, does not enforce framerates, does not
|
* Does not parse or validate timecodes, does not enforce framerates, does not
|
||||||
parameterize timecode or framerates in any way. This makes the parser more
|
parameterize timecode or framerates in any way. This makes the parser more
|
||||||
@@ -28,12 +30,14 @@ its most most common variations.
|
|||||||
list and give the client the ability to extend the package with their own
|
list and give the client the ability to extend the package with their own
|
||||||
parsing code.
|
parsing code.
|
||||||
|
|
||||||
|
[asc]: https://en.wikipedia.org/wiki/ASC_CDL
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Opening and Parsing EDL Files
|
### Opening and Parsing EDL Files
|
||||||
```
|
```
|
||||||
>>> import pycmx
|
>>> import pycmx
|
||||||
>>> with open("tests/edls/TEST.edl") as f
|
>>> with open("tests/edls/TEST.edl") as f:
|
||||||
... edl = pycmx.parse_cmx3600(f)
|
... edl = pycmx.parse_cmx3600(f)
|
||||||
...
|
...
|
||||||
>>> edl.title
|
>>> edl.title
|
||||||
|
|||||||
@@ -20,5 +20,6 @@ pycmx Classes
|
|||||||
.. autoclass:: pycmx.channel_map.ChannelMap
|
.. autoclass:: pycmx.channel_map.ChannelMap
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
.. automodule:: pycmx.cdl
|
||||||
|
:members:
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ sys.path.insert(0, os.path.abspath('../..'))
|
|||||||
# -- Project information -----------------------------------------------------
|
# -- Project information -----------------------------------------------------
|
||||||
|
|
||||||
project = u'pycmx'
|
project = u'pycmx'
|
||||||
copyright = u'(c) 2025, Jamie Hardt'
|
copyright = u'(c) 2018-2025, Jamie Hardt'
|
||||||
author = u'Jamie Hardt'
|
author = u'Jamie Hardt'
|
||||||
|
|
||||||
release = importlib.metadata.version("pycmx")
|
release = importlib.metadata.version("pycmx")
|
||||||
|
|||||||
@@ -3,8 +3,75 @@
|
|||||||
You can adapt this file completely to your liking, but it should at least
|
You can adapt this file completely to your liking, but it should at least
|
||||||
contain the root `toctree` directive.
|
contain the root `toctree` directive.
|
||||||
|
|
||||||
Welcome to pycmx's documentation!
|
pycmx - A CMX EDL Parser in Python
|
||||||
=================================
|
====================================
|
||||||
|
|
||||||
|
Features
|
||||||
|
---------
|
||||||
|
|
||||||
|
The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
|
||||||
|
|
||||||
|
* The major variations of the CMX 3600: the standard, "File32", "File128" and
|
||||||
|
long Adobe Premiere event numbers are automatically detected and properly
|
||||||
|
read. Event number field and source name field sizes are determined
|
||||||
|
dynamically for each statement for a high level of compliance at the expense
|
||||||
|
of strictness.
|
||||||
|
* A more relaxed "tolerant" mode allows parsing of an EDL file where columns
|
||||||
|
use non-standard widths.
|
||||||
|
* 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.
|
||||||
|
* `ASC CDL`_ and FRMC statements are parsed and decoded.
|
||||||
|
* 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.
|
||||||
|
|
||||||
|
.. _ASC CDL: https://en.wikipedia.org/wiki/ASC_CDL
|
||||||
|
|
||||||
|
Getting Started
|
||||||
|
----------------
|
||||||
|
|
||||||
|
Install `pycmx` with pip, or add it with `uv` or your favorite tool.
|
||||||
|
|
||||||
|
.. code-block:: sh
|
||||||
|
|
||||||
|
pip install pycmx
|
||||||
|
|
||||||
|
`pycmx` parses an EDL with the :func:`~pycmx.parse_cmx_events.parse_cmx3600`
|
||||||
|
function:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import pycmx
|
||||||
|
|
||||||
|
with open("tests/edls/TEST.edl") as f:
|
||||||
|
edl = pycmx.parse_cmx3600(f)
|
||||||
|
|
||||||
|
The `pycmx` parser reads each line from the input EDL and collects them into
|
||||||
|
`~pycmx.event.Event` objects. All individual edit actions that share the same
|
||||||
|
event number will be collected into a single Event, along with transitions and
|
||||||
|
any remark lines, including clip names, and CDL color commands.
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
for event in edl.events:
|
||||||
|
print("- - - Event Info - - -")
|
||||||
|
print("Event No:", event.number)
|
||||||
|
for edit in event.edits:
|
||||||
|
print("On Line No:", edit.line_number)
|
||||||
|
print("Transition In:", edit.transition.kind)
|
||||||
|
print("Source Name:", edit.source)
|
||||||
|
print("Source In:", edit.source_in)
|
||||||
|
print("Source Out:", edit.source_out)
|
||||||
|
print("Rec In:", edit.record_in)
|
||||||
|
print("Rec Out:", edit.record_out)
|
||||||
|
print("ASC SOP:", edit.asc_sop)
|
||||||
|
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 5
|
:maxdepth: 5
|
||||||
|
|||||||
159
pycmx/edit.py
159
pycmx/edit.py
@@ -1,159 +0,0 @@
|
|||||||
# pycmx
|
|
||||||
# (c) 2018 Jamie Hardt
|
|
||||||
|
|
||||||
from pycmx.statements import StmtCdlSat, StmtCdlSop, StmtFrmc
|
|
||||||
from .transition import Transition
|
|
||||||
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, asc_sop_statement=None,
|
|
||||||
asc_sat_statement=None, frmc_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
|
|
||||||
self.asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement
|
|
||||||
self.asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement
|
|
||||||
self.frmc_statement: Optional[StmtFrmc] = frmc_statement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def line_number(self) -> int:
|
|
||||||
"""
|
|
||||||
Get the line number for the "standard form" statement associated with
|
|
||||||
this edit. Line numbers a zero-indexed, such that the
|
|
||||||
"TITLE:" record is line zero.
|
|
||||||
"""
|
|
||||||
return self.edit_statement.line_number
|
|
||||||
|
|
||||||
@property
|
|
||||||
def channels(self) -> ChannelMap:
|
|
||||||
"""
|
|
||||||
Get the :obj:`ChannelMap` object associated with this Edit.
|
|
||||||
"""
|
|
||||||
cm = ChannelMap()
|
|
||||||
cm._append_event(self.edit_statement.channels)
|
|
||||||
if self.audio_ext is not None:
|
|
||||||
cm._append_ext(self.audio_ext)
|
|
||||||
return cm
|
|
||||||
|
|
||||||
@property
|
|
||||||
def transition(self) -> Transition:
|
|
||||||
"""
|
|
||||||
Get the :obj:`Transition` object associated with this edit.
|
|
||||||
"""
|
|
||||||
if self.trans_name_statement:
|
|
||||||
return Transition(self.edit_statement.trans,
|
|
||||||
self.edit_statement.trans_op,
|
|
||||||
self.trans_name_statement.name)
|
|
||||||
else:
|
|
||||||
return Transition(self.edit_statement.trans,
|
|
||||||
self.edit_statement.trans_op, None)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_in(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the source in timecode.
|
|
||||||
"""
|
|
||||||
return self.edit_statement.source_in
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_out(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the source out timecode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.edit_statement.source_out
|
|
||||||
|
|
||||||
@property
|
|
||||||
def record_in(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the record in timecode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.edit_statement.record_in
|
|
||||||
|
|
||||||
@property
|
|
||||||
def record_out(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the record out timecode.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return self.edit_statement.record_out
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source(self) -> str:
|
|
||||||
"""
|
|
||||||
Get the source column. This is the 8, 32 or 128-character string on the
|
|
||||||
event record line, this usually references the tape name of the source.
|
|
||||||
"""
|
|
||||||
return self.edit_statement.source
|
|
||||||
|
|
||||||
@property
|
|
||||||
def black(self) -> bool:
|
|
||||||
"""
|
|
||||||
Black video or silence should be used as the source for this event.
|
|
||||||
"""
|
|
||||||
return self.source == "BL"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def aux_source(self) -> bool:
|
|
||||||
"""
|
|
||||||
An auxiliary source is the source of this event.
|
|
||||||
"""
|
|
||||||
return self.source == "AX"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def source_file(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Get the source file, as attested by a "* SOURCE FILE" remark on the
|
|
||||||
EDL. This will return None if the information is not present.
|
|
||||||
"""
|
|
||||||
if self.source_file_statement is None:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return self.source_file_statement.filename
|
|
||||||
|
|
||||||
@property
|
|
||||||
def clip_name(self) -> Optional[str]:
|
|
||||||
"""
|
|
||||||
Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP
|
|
||||||
NAME" remark on the EDL. This will return None if the information is
|
|
||||||
not present.
|
|
||||||
"""
|
|
||||||
if self.clip_name_statement is None:
|
|
||||||
return None
|
|
||||||
else:
|
|
||||||
return self.clip_name_statement.name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def asc_sop(self) -> Optional[StmtCdlSop]:
|
|
||||||
"""
|
|
||||||
Get ASC CDL Slope-Offset-Power transfer function for clip, if present
|
|
||||||
"""
|
|
||||||
return self.asc_sop_statement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def asc_sat(self) -> Optional[StmtCdlSat]:
|
|
||||||
"""
|
|
||||||
Get ASC CDL saturation value for clip, if present
|
|
||||||
"""
|
|
||||||
return self.asc_sat_statement
|
|
||||||
|
|
||||||
@property
|
|
||||||
def frmc(self) -> Optional[StmtFrmc]:
|
|
||||||
"""
|
|
||||||
Get FRMC data
|
|
||||||
"""
|
|
||||||
return self.frmc_statement
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# pycmx
|
|
||||||
# (c) 2018 Jamie Hardt
|
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import TextIO, List
|
|
||||||
|
|
||||||
from .statements import (StmtCdlSat, StmtCdlSop, StmtFrmc, StmtRemark,
|
|
||||||
StmtTitle, StmtUnrecognized, StmtFCM, StmtAudioExt,
|
|
||||||
StmtClipName, StmtEffectsName, StmtEvent,
|
|
||||||
StmtSourceFile, StmtSplitEdit, StmtMotionMemory,
|
|
||||||
StmtSourceUMID)
|
|
||||||
from .util import collimate
|
|
||||||
|
|
||||||
|
|
||||||
def parse_cmx3600_statements(file: TextIO) -> List[object]:
|
|
||||||
"""
|
|
||||||
Return a list of every statement in the file argument.
|
|
||||||
"""
|
|
||||||
lines = file.readlines()
|
|
||||||
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) -> 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 _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.
|
|
||||||
"""
|
|
||||||
event_num_p = re.compile(r"^(\d+) ")
|
|
||||||
line_matcher = event_num_p.match(line)
|
|
||||||
|
|
||||||
if line.startswith("TITLE:"):
|
|
||||||
return _parse_title(line, line_number)
|
|
||||||
elif line.startswith("FCM:"):
|
|
||||||
return _parse_fcm(line, line_number)
|
|
||||||
elif line_matcher is not None:
|
|
||||||
event_field_len = len(line_matcher.group(1))
|
|
||||||
source_field_len = len(line) - (event_field_len + 65)
|
|
||||||
return _parse_columns_for_standard_form(line, event_field_len,
|
|
||||||
source_field_len, line_number)
|
|
||||||
elif line.startswith("AUD"):
|
|
||||||
return _parse_extended_audio_channels(line, line_number)
|
|
||||||
elif line.startswith("*"):
|
|
||||||
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"):
|
|
||||||
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:
|
|
||||||
return _parse_unrecognized(line, line_number)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_title(line, line_num) -> StmtTitle:
|
|
||||||
title = line[6:].strip()
|
|
||||||
return StmtTitle(title=title, line_number=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)
|
|
||||||
else:
|
|
||||||
return StmtFCM(drop=False, line_number=line_num)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_extended_audio_channels(line, line_number):
|
|
||||||
content = line.strip()
|
|
||||||
audio3 = True if "3" in content else False
|
|
||||||
audio4 = True if "4" in content else False
|
|
||||||
|
|
||||||
if audio3 or audio4:
|
|
||||||
return StmtAudioExt(audio3, audio4, line_number)
|
|
||||||
else:
|
|
||||||
return StmtUnrecognized(line, 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)
|
|
||||||
elif line.startswith("TO CLIP NAME:"):
|
|
||||||
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)
|
|
||||||
elif line.startswith("ASC_SOP"):
|
|
||||||
group_patterns: list[str] = re.findall(r'\((.*?)\)', line)
|
|
||||||
|
|
||||||
v1: list[list[tuple[str, str]]] = \
|
|
||||||
[re.findall(r'(-?\d+(\.\d+)?)', a) for a in group_patterns]
|
|
||||||
|
|
||||||
v: list[list[str]] = [[a[0] for a in b] for b in v1]
|
|
||||||
|
|
||||||
if len(v) != 3 or any([len(a) != 3 for a in v]):
|
|
||||||
return StmtRemark(line, line_number)
|
|
||||||
|
|
||||||
else:
|
|
||||||
return StmtCdlSop(slope_r=v[0][0], slope_g=v[0][1],
|
|
||||||
slope_b=v[0][2], offset_r=v[1][0],
|
|
||||||
offset_g=v[1][1], offset_b=v[1][2],
|
|
||||||
power_r=v[2][0], power_g=v[2][1],
|
|
||||||
power_b=v[2][2], line_number=line_number)
|
|
||||||
|
|
||||||
elif line.startswith("ASC_SAT"):
|
|
||||||
value = re.findall(r'(-?\d+(\.\d+)?)', line)
|
|
||||||
|
|
||||||
if len(value) != 1:
|
|
||||||
return StmtRemark(line, line_number)
|
|
||||||
|
|
||||||
else:
|
|
||||||
return StmtCdlSat(value=value[0][0], line_number=line_number)
|
|
||||||
|
|
||||||
elif line.startswith("FRMC"):
|
|
||||||
match = re.match(
|
|
||||||
r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)'
|
|
||||||
r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
|
|
||||||
|
|
||||||
if match is None:
|
|
||||||
return StmtRemark(line, line_number)
|
|
||||||
|
|
||||||
else:
|
|
||||||
return StmtFrmc(start=match.group(1), end=match.group(2),
|
|
||||||
duration=match.group(3), 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
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return StmtEvent(event=column_strings[0],
|
|
||||||
source=column_strings[2].strip(),
|
|
||||||
channels=column_strings[4].strip(),
|
|
||||||
trans=column_strings[6].strip(),
|
|
||||||
trans_op=column_strings[8].strip(),
|
|
||||||
source_in=column_strings[10].strip(),
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_source_umid_statement(line, line_number):
|
|
||||||
# trimmed = line[3:].strip()
|
|
||||||
return StmtSourceUMID(name=None, umid=None, line_number=line_number)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
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"])
|
|
||||||
StmtCdlSop = namedtuple("CdlSop", ['slope_r', 'slope_g', 'slope_b',
|
|
||||||
'offset_r', 'offset_g', 'offset_b',
|
|
||||||
'power_r', 'power_g', 'power_b',
|
|
||||||
'line_number'])
|
|
||||||
StmtCdlSat = namedtuple("SdlSat", ['value', 'line_number'])
|
|
||||||
StmtFrmc = namedtuple("Frmc", ['start', 'end', 'duration', '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"])
|
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "pycmx"
|
name = "pycmx"
|
||||||
version = "1.4.0"
|
version = "1.5.0"
|
||||||
description = "Python CMX 3600 Edit Decision List Parser"
|
description = "Python CMX 3600 Edit Decision List Parser"
|
||||||
authors = ["Jamie Hardt <jamiehardt@me.com>"]
|
authors = [{name = "Jamie Hardt", email= "<jamiehardt@me.com>"}]
|
||||||
license = "MIT"
|
license-files = ["LICENSE"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
keywords = [
|
keywords = [
|
||||||
'parser',
|
'parser',
|
||||||
'film',
|
'film',
|
||||||
'broadcast'
|
'broadcast'
|
||||||
]
|
]
|
||||||
|
requires-python = '>3.8'
|
||||||
classifiers = [
|
classifiers = [
|
||||||
'Development Status :: 5 - Production/Stable',
|
'Development Status :: 5 - Production/Stable',
|
||||||
'License :: OSI Approved :: MIT License',
|
'License :: OSI Approved :: MIT License',
|
||||||
@@ -21,36 +22,59 @@ classifiers = [
|
|||||||
'Programming Language :: Python :: 3.10',
|
'Programming Language :: Python :: 3.10',
|
||||||
'Programming Language :: Python :: 3.11',
|
'Programming Language :: Python :: 3.11',
|
||||||
'Programming Language :: Python :: 3.12',
|
'Programming Language :: Python :: 3.12',
|
||||||
'Programming Language :: Python :: 3.13'
|
'Programming Language :: Python :: 3.13',
|
||||||
|
'Programming Language :: Python :: 3.14'
|
||||||
]
|
]
|
||||||
homepage = "https://github.com/iluvcapra/pycmx"
|
|
||||||
documentation = "https://pycmx.readthedocs.io/"
|
|
||||||
repository = "https://github.com/iluvcapra/pycmx.git"
|
|
||||||
urls.Tracker = "https://github.com/iluvcapra/pycmx/issues"
|
|
||||||
|
|
||||||
[tool.poetry.extras]
|
[project.optional-dependencies]
|
||||||
|
doc = [
|
||||||
|
'sphinx >= 5.3.0',
|
||||||
|
'sphinx_rtd_theme >= 1.1.1',
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
'pytest',
|
||||||
|
'ruff>=0.14.10'
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/iluvcapra/pycmx"
|
||||||
|
Documentation = "https://pycmx.readthedocs.io/"
|
||||||
|
Repository = "https://github.com/iluvcapra/pycmx.git"
|
||||||
|
Tracker = "https://github.com/iluvcapra/pycmx/issues"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ['ruff', 'pytest']
|
||||||
doc = ['sphinx', 'sphinx_rtd_theme']
|
doc = ['sphinx', 'sphinx_rtd_theme']
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
|
||||||
python = "^3.8"
|
|
||||||
sphinx = { version='>= 5.3.0', optional=true}
|
|
||||||
sphinx_rtd_theme = {version ='>= 1.1.1', optional=true}
|
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
typeCheckingMode = "basic"
|
typeCheckingMode = "basic"
|
||||||
|
|
||||||
[tool.pylint]
|
[tool.ruff]
|
||||||
max-line-length = 88
|
line-length = 88
|
||||||
disable = [
|
indent-width = 4
|
||||||
"C0103", # (invalid-name)
|
|
||||||
"C0114", # (missing-module-docstring)
|
[tool.ruff.lint]
|
||||||
"C0115", # (missing-class-docstring)
|
select = ["E", "F", "W"]
|
||||||
"C0116", # (missing-function-docstring)
|
|
||||||
"R0903", # (too-few-public-methods)
|
[tool.ruff.format]
|
||||||
"R0913", # (too-many-arguments)
|
docstring-code-line-length = 88
|
||||||
"W0105", # (pointless-string-statement)
|
|
||||||
]
|
# [tool.pylint]
|
||||||
|
# max-line-length = 88
|
||||||
|
# disable = [
|
||||||
|
# "C0103", # (invalid-name)
|
||||||
|
# "C0114", # (missing-module-docstring)
|
||||||
|
# "C0115", # (missing-class-docstring)
|
||||||
|
# "C0116", # (missing-function-docstring)
|
||||||
|
# "R0903", # (too-few-public-methods)
|
||||||
|
# "R0913", # (too-many-arguments)
|
||||||
|
# "W0105", # (pointless-string-statement)
|
||||||
|
# ]
|
||||||
|
#
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["uv_build>=0.9.18,<0.10.0"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "uv_build"
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ from .parse_cmx_events import parse_cmx3600
|
|||||||
from .transition import Transition
|
from .transition import Transition
|
||||||
from .event import Event
|
from .event import Event
|
||||||
from .edit import Edit
|
from .edit import Edit
|
||||||
|
|
||||||
|
__all__ = ("parse_cmx3600", "Transition", "Event", "Edit")
|
||||||
48
src/pycmx/cdl.py
Normal file
48
src/pycmx/cdl.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2025 Jamie Hardt
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Generic, NamedTuple, TypeVar
|
||||||
|
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Rgb(Generic[T]):
|
||||||
|
"""
|
||||||
|
A tuple of three `T`s, where each is the respective red, green and blue
|
||||||
|
values of interest.
|
||||||
|
"""
|
||||||
|
|
||||||
|
red: T # : Red component
|
||||||
|
green: T # : Green component
|
||||||
|
blue: T # : Blue component
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AscSopComponents(Generic[T]):
|
||||||
|
"""
|
||||||
|
Fields in an ASC SOP (Slope-Offset-Power) color transfer function
|
||||||
|
statement.
|
||||||
|
|
||||||
|
The ASC SOP is a transfer function of the form:
|
||||||
|
|
||||||
|
:math:`y_{color} = (ax_{color} + b)^p`
|
||||||
|
|
||||||
|
for each color component the source, where the `slope` is `a`, `offset`
|
||||||
|
is `b` and `power` is `p`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
slope: Rgb[T] # : The linear/slope component `a`
|
||||||
|
offset: Rgb[T] # : The constant/offset component `b`
|
||||||
|
power: Rgb[T] # : The exponential/power component `p`
|
||||||
|
|
||||||
|
|
||||||
|
class FramecountTriple(NamedTuple):
|
||||||
|
"""
|
||||||
|
Fields in an FRMC statement
|
||||||
|
"""
|
||||||
|
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
duration: int
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018-2025 Jamie Hardt
|
||||||
|
|
||||||
from re import (compile, match)
|
from re import (compile, match)
|
||||||
from typing import Dict, Tuple, Generator
|
from typing import Dict, Tuple, Generator
|
||||||
213
src/pycmx/edit.py
Normal file
213
src/pycmx/edit.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018-2025 Jamie Hardt
|
||||||
|
|
||||||
|
from .cdl import AscSopComponents, FramecountTriple
|
||||||
|
from .statements import (
|
||||||
|
StmtCdlSat,
|
||||||
|
StmtCdlSop,
|
||||||
|
StmtFrmc,
|
||||||
|
StmtEvent,
|
||||||
|
StmtAudioExt,
|
||||||
|
StmtClipName,
|
||||||
|
StmtSourceFile,
|
||||||
|
StmtEffectsName,
|
||||||
|
)
|
||||||
|
from .transition import Transition
|
||||||
|
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: StmtEvent,
|
||||||
|
audio_ext_statement: Optional[StmtAudioExt],
|
||||||
|
clip_name_statement: Optional[StmtClipName],
|
||||||
|
source_file_statement: Optional[StmtSourceFile],
|
||||||
|
trans_name_statement: Optional[StmtEffectsName] = None,
|
||||||
|
asc_sop_statement: Optional[StmtCdlSop] = None,
|
||||||
|
asc_sat_statement: Optional[StmtCdlSat] = None,
|
||||||
|
frmc_statement: Optional[StmtFrmc] = None,
|
||||||
|
) -> None:
|
||||||
|
self._edit_statement: StmtEvent = edit_statement
|
||||||
|
self._audio_ext: Optional[StmtAudioExt] = audio_ext_statement
|
||||||
|
self._clip_name_statement: Optional[StmtClipName] = clip_name_statement
|
||||||
|
self._source_file_statement: Optional[StmtSourceFile] = \
|
||||||
|
source_file_statement
|
||||||
|
self._trans_name_statement: Optional[StmtEffectsName] = \
|
||||||
|
trans_name_statement
|
||||||
|
self._asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement
|
||||||
|
self._asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement
|
||||||
|
self._frmc_statement: Optional[StmtFrmc] = frmc_statement
|
||||||
|
|
||||||
|
@property
|
||||||
|
def line_number(self) -> int:
|
||||||
|
"""
|
||||||
|
Get the line number for the "standard form" statement associated with
|
||||||
|
this edit. Line numbers a zero-indexed, such that the "TITLE:" record
|
||||||
|
is line zero.
|
||||||
|
"""
|
||||||
|
return self._edit_statement.line_number
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channels(self) -> ChannelMap:
|
||||||
|
"""
|
||||||
|
Get the :obj:`ChannelMap` object associated with this Edit.
|
||||||
|
"""
|
||||||
|
cm = ChannelMap()
|
||||||
|
cm._append_event(self._edit_statement.channels)
|
||||||
|
if self._audio_ext is not None:
|
||||||
|
cm._append_ext(self._audio_ext)
|
||||||
|
return cm
|
||||||
|
|
||||||
|
@property
|
||||||
|
def transition(self) -> Transition:
|
||||||
|
"""
|
||||||
|
Get the :obj:`Transition` that initiates this edit.
|
||||||
|
"""
|
||||||
|
if self._trans_name_statement:
|
||||||
|
return Transition(
|
||||||
|
self._edit_statement.trans,
|
||||||
|
self._edit_statement.trans_op,
|
||||||
|
self._trans_name_statement.name,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return Transition(
|
||||||
|
self._edit_statement.trans, self._edit_statement.trans_op, None
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_in(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the source in timecode.
|
||||||
|
"""
|
||||||
|
return self._edit_statement.source_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_out(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the source out timecode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._edit_statement.source_out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_in(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the record in timecode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._edit_statement.record_in
|
||||||
|
|
||||||
|
@property
|
||||||
|
def record_out(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the record out timecode.
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._edit_statement.record_out
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
|
"""
|
||||||
|
Get the source column. This is the 8, 32 or 128-character string on the
|
||||||
|
event record line, this usually references the tape name of the source.
|
||||||
|
"""
|
||||||
|
return self._edit_statement.source
|
||||||
|
|
||||||
|
@property
|
||||||
|
def black(self) -> bool:
|
||||||
|
"""
|
||||||
|
The source field for thie edit was "BL". Black video or silence should
|
||||||
|
be used as the source for this event.
|
||||||
|
"""
|
||||||
|
return self.source == "BL"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aux_source(self) -> bool:
|
||||||
|
"""
|
||||||
|
The source field for this edit was "AX". An auxiliary source is the
|
||||||
|
source for this event.
|
||||||
|
"""
|
||||||
|
return self.source == "AX"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_file(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the source file, as attested by a "* SOURCE FILE" remark on the
|
||||||
|
EDL. This will return None if the information is not present.
|
||||||
|
"""
|
||||||
|
if self._source_file_statement is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self._source_file_statement.filename
|
||||||
|
|
||||||
|
@property
|
||||||
|
def clip_name(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Get the clip name, as attested by a "* FROM CLIP NAME" or "* TO CLIP
|
||||||
|
NAME" remark on the EDL. This will return None if the information is
|
||||||
|
not present.
|
||||||
|
"""
|
||||||
|
if self._clip_name_statement is None:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return self._clip_name_statement.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def asc_sop(self) -> Optional[AscSopComponents[float]]:
|
||||||
|
"""
|
||||||
|
Get ASC CDL Slope-Offset-Power color transfer function for the edit,
|
||||||
|
if present. The ASC SOP is a transfer function of the form:
|
||||||
|
|
||||||
|
:math:`y = (ax + b)^p`
|
||||||
|
|
||||||
|
for each color component the source, where the `slope` is `a`, `offset`
|
||||||
|
is `b` and `power` is `p`.
|
||||||
|
"""
|
||||||
|
if self._asc_sop_statement is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._asc_sop_statement.cdl_sop
|
||||||
|
|
||||||
|
@property
|
||||||
|
def asc_sop_raw(self) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
ASC CDL Slope-Offset-Power statement raw line.
|
||||||
|
"""
|
||||||
|
if self._asc_sop_statement is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._asc_sop_statement.line
|
||||||
|
|
||||||
|
@property
|
||||||
|
def asc_sat(self) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Get ASC CDL saturation value for clip, if present.
|
||||||
|
"""
|
||||||
|
if self._asc_sat_statement is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._asc_sat_statement.value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def framecounts(self) -> Optional[FramecountTriple]:
|
||||||
|
"""
|
||||||
|
Get frame count offset data, if it exists. If an FRMC statement exists
|
||||||
|
in the EDL for the event it will give an integer frame count for the
|
||||||
|
edit's source in and out times.
|
||||||
|
"""
|
||||||
|
if not self._frmc_statement:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return FramecountTriple(
|
||||||
|
start=self._frmc_statement.start,
|
||||||
|
end=self._frmc_statement.end,
|
||||||
|
duration=self._frmc_statement.duration,
|
||||||
|
)
|
||||||
@@ -1,22 +1,21 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018-2025 Jamie Hardt
|
||||||
|
|
||||||
from pycmx.statements import StmtTitle
|
from .statements import (StmtCorruptRemark, StmtTitle, StmtEvent,
|
||||||
from .parse_cmx_statements import (
|
StmtUnrecognized, StmtSourceUMID)
|
||||||
StmtUnrecognized, StmtEvent, StmtSourceUMID)
|
|
||||||
from .event import Event
|
from .event import Event
|
||||||
from .channel_map import ChannelMap
|
from .channel_map import ChannelMap
|
||||||
|
|
||||||
from typing import Generator
|
from typing import Any, Generator
|
||||||
|
|
||||||
|
|
||||||
class EditList:
|
class EditList:
|
||||||
"""
|
"""
|
||||||
Represents an entire edit decision list as returned by
|
Represents an entire edit decision list as returned by
|
||||||
:func:`~pycmx.parse_cmx3600()`.
|
:func:`~pycmx.parse_cmx_events.parse_cmx3600()`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, statements):
|
def __init__(self, statements: list):
|
||||||
self.title_statement: StmtTitle = statements[0]
|
self.title_statement: StmtTitle = statements[0]
|
||||||
self.event_statements = statements[1:]
|
self.event_statements = statements[1:]
|
||||||
|
|
||||||
@@ -32,11 +31,11 @@ class EditList:
|
|||||||
(s for s in self.event_statements if type(s) is StmtEvent), None)
|
(s for s in self.event_statements if type(s) is StmtEvent), None)
|
||||||
|
|
||||||
if first_event:
|
if first_event:
|
||||||
if first_event.format == 8:
|
if first_event.source_field_size == 8:
|
||||||
return '3600'
|
return '3600'
|
||||||
elif first_event.format == 32:
|
elif first_event.source_field_size == 32:
|
||||||
return 'File32'
|
return 'File32'
|
||||||
elif first_event.format == 128:
|
elif first_event.source_field_size == 128:
|
||||||
return 'File128'
|
return 'File128'
|
||||||
else:
|
else:
|
||||||
return 'unknown'
|
return 'unknown'
|
||||||
@@ -64,13 +63,16 @@ class EditList:
|
|||||||
return self.title_statement.title
|
return self.title_statement.title
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def unrecognized_statements(self) -> Generator[StmtUnrecognized,
|
def unrecognized_statements(self) -> Generator[Any, None, None]:
|
||||||
None, None]:
|
|
||||||
"""
|
"""
|
||||||
A generator for all the unrecognized statements in the list.
|
A generator for all the unrecognized statements and
|
||||||
|
corrupt remarks in the list.
|
||||||
|
|
||||||
|
:yields: either a :class:`StmtUnrecognized` or
|
||||||
|
:class:`StmtCorruptRemark`
|
||||||
"""
|
"""
|
||||||
for s in self.event_statements:
|
for s in self.event_statements:
|
||||||
if type(s) is StmtUnrecognized:
|
if type(s) is StmtUnrecognized or type(s) in StmtCorruptRemark:
|
||||||
yield s
|
yield s
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2023 Jamie Hardt
|
# (c) 2023-2025 Jamie Hardt
|
||||||
|
|
||||||
from pycmx.statements import StmtFrmc
|
from .statements import (StmtFrmc, StmtEvent, StmtClipName, StmtSourceFile,
|
||||||
from .parse_cmx_statements import (
|
StmtAudioExt, StmtUnrecognized, StmtEffectsName,
|
||||||
StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized,
|
StmtCdlSop, StmtCdlSat)
|
||||||
StmtEffectsName, StmtCdlSop, StmtCdlSat)
|
|
||||||
from .edit import Edit
|
from .edit import Edit
|
||||||
|
|
||||||
from typing import List, Generator, Optional, Tuple, Any
|
from typing import List, Generator, Optional, Tuple, Any
|
||||||
@@ -69,8 +68,7 @@ class Event:
|
|||||||
trans_names.append(trans_statement)
|
trans_names.append(trans_statement)
|
||||||
the_zip.append(trans_names)
|
the_zip.append(trans_names)
|
||||||
except IndexError:
|
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],
|
audio_ext_statement=e1[1],
|
||||||
clip_name_statement=n1,
|
clip_name_statement=n1,
|
||||||
@@ -105,11 +103,16 @@ class Event:
|
|||||||
|
|
||||||
def _statements_with_audio_ext(self) -> Generator[
|
def _statements_with_audio_ext(self) -> Generator[
|
||||||
Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]:
|
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:
|
if len(self.statements) == 1 and type(self.statements[0]) is StmtEvent:
|
||||||
yield (s1, s2)
|
yield (self.statements[0], None)
|
||||||
elif type(s1) is StmtEvent:
|
|
||||||
yield (s1, None)
|
else:
|
||||||
|
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)
|
||||||
|
|
||||||
def _asc_sop_statement(self) -> Optional[StmtCdlSop]:
|
def _asc_sop_statement(self) -> Optional[StmtCdlSop]:
|
||||||
return next((s for s in self.statements if type(s) is StmtCdlSop),
|
return next((s for s in self.statements if type(s) is StmtCdlSop),
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018-2025 Jamie Hardt
|
||||||
|
|
||||||
# from collections import namedtuple
|
from typing import TextIO
|
||||||
|
|
||||||
from .parse_cmx_statements import (parse_cmx3600_statements)
|
from .parse_cmx_statements import (parse_cmx3600_statements)
|
||||||
from .edit_list import EditList
|
from .edit_list import EditList
|
||||||
|
|
||||||
from typing import TextIO
|
|
||||||
|
|
||||||
|
def parse_cmx3600(f: TextIO, tolerant: bool = False) -> EditList:
|
||||||
def parse_cmx3600(f: TextIO) -> EditList:
|
|
||||||
"""
|
"""
|
||||||
Parse a CMX 3600 EDL.
|
Parse a CMX 3600 EDL.
|
||||||
|
|
||||||
:param TextIO f: a file-like object, an opened CMX 3600 .EDL file.
|
:param TextIO f: a file-like object, an opened CMX 3600 .EDL file.
|
||||||
|
:param bool tolerant: If `True`, a relaxed event line parsing method will
|
||||||
|
be used, in the case the default method fails.
|
||||||
:returns: An :class:`pycmx.edit_list.EditList`.
|
:returns: An :class:`pycmx.edit_list.EditList`.
|
||||||
"""
|
"""
|
||||||
statements = parse_cmx3600_statements(f)
|
statements = parse_cmx3600_statements(f, tolerant)
|
||||||
return EditList(statements)
|
return EditList(statements)
|
||||||
266
src/pycmx/parse_cmx_statements.py
Normal file
266
src/pycmx/parse_cmx_statements.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2018-2025 Jamie Hardt
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TextIO, List
|
||||||
|
|
||||||
|
from .cdl import AscSopComponents, Rgb
|
||||||
|
|
||||||
|
from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
|
||||||
|
StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
|
||||||
|
StmtAudioExt, StmtClipName, StmtEffectsName,
|
||||||
|
StmtEvent, StmtSourceFile, StmtSplitEdit)
|
||||||
|
from .util import collimate
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cmx3600_statements(file: TextIO,
|
||||||
|
tolerant: bool = False) -> List[object]:
|
||||||
|
"""
|
||||||
|
Return a list of every statement in the file argument.
|
||||||
|
"""
|
||||||
|
lines = file.readlines()
|
||||||
|
return [_parse_cmx3600_line(line.strip(), line_number, tolerant)
|
||||||
|
for (line_number, line) in enumerate(lines)]
|
||||||
|
|
||||||
|
|
||||||
|
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 _parse_cmx3600_line(line: str, line_number: int,
|
||||||
|
tolerant: bool = False) -> object:
|
||||||
|
"""
|
||||||
|
Parses a single CMX EDL line.
|
||||||
|
|
||||||
|
:param line: A single EDL line.
|
||||||
|
:param line_number: The index of this line in the file.
|
||||||
|
"""
|
||||||
|
event_num_p = re.compile(r"^(\d+) ")
|
||||||
|
line_matcher = event_num_p.match(line)
|
||||||
|
|
||||||
|
if line.startswith("TITLE:"):
|
||||||
|
return _parse_title(line, line_number)
|
||||||
|
if line.startswith("FCM:"):
|
||||||
|
return _parse_fcm(line, line_number)
|
||||||
|
if line_matcher is not None:
|
||||||
|
event_field_len = len(line_matcher.group(1))
|
||||||
|
|
||||||
|
source_field_len = len(line) - (event_field_len + 65)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _parse_columns_for_standard_form(
|
||||||
|
line, event_field_len, source_field_len, line_number)
|
||||||
|
|
||||||
|
except EventFormError:
|
||||||
|
if tolerant:
|
||||||
|
return _parse_columns_tolerant(line, line_number)
|
||||||
|
else:
|
||||||
|
return StmtUnrecognized(line, line_number)
|
||||||
|
|
||||||
|
if line.startswith("AUD"):
|
||||||
|
return _parse_extended_audio_channels(line, line_number)
|
||||||
|
if line.startswith("*"):
|
||||||
|
return _parse_remark(line[1:].strip(), line_number)
|
||||||
|
if line.startswith(">>> SOURCE"):
|
||||||
|
return _parse_source_umid_statement(line, line_number)
|
||||||
|
if line.startswith("EFFECTS NAME IS"):
|
||||||
|
return _parse_effects_name(line, line_number)
|
||||||
|
if line.startswith("SPLIT:"):
|
||||||
|
return _parse_split(line, line_number)
|
||||||
|
if line.startswith("M2"):
|
||||||
|
pass
|
||||||
|
# return _parse_motion_memory(line, line_number)
|
||||||
|
|
||||||
|
return _parse_unrecognized(line, line_number)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_title(line, line_num) -> StmtTitle:
|
||||||
|
title = line[6:].strip()
|
||||||
|
return StmtTitle(title=title, line_number=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=False, line_number=line_num)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_extended_audio_channels(line, line_number):
|
||||||
|
content = line.strip()
|
||||||
|
audio3 = "3" in content
|
||||||
|
audio4 = "4" in content
|
||||||
|
|
||||||
|
if audio3 or audio4:
|
||||||
|
return StmtAudioExt(audio3, audio4, line_number)
|
||||||
|
else:
|
||||||
|
return StmtUnrecognized(line, 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)
|
||||||
|
elif line.startswith("TO CLIP NAME:"):
|
||||||
|
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)
|
||||||
|
elif line.startswith("ASC_SOP"):
|
||||||
|
group_patterns: list[str] = re.findall(r'\((.*?)\)', line)
|
||||||
|
|
||||||
|
v1: list[list[tuple[str, str]]] = \
|
||||||
|
[re.findall(r'(-?\d+(\.\d+)?)', a) for a in group_patterns]
|
||||||
|
|
||||||
|
v: list[list[str]] = [[a[0] for a in b] for b in v1]
|
||||||
|
|
||||||
|
if len(v) != 3 or any([len(a) != 3 for a in v]):
|
||||||
|
return StmtRemark(line, line_number)
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return StmtCdlSop(line=line,
|
||||||
|
cdl_sop=AscSopComponents(
|
||||||
|
slope=Rgb(red=float(v[0][0]),
|
||||||
|
green=float(v[0][1]),
|
||||||
|
blue=float(v[0][2])),
|
||||||
|
offset=Rgb(red=float(v[1][0]),
|
||||||
|
green=float(v[1][1]),
|
||||||
|
blue=float(v[1][2])),
|
||||||
|
power=Rgb(red=float(v[2][0]),
|
||||||
|
green=float(v[2][1]),
|
||||||
|
blue=float(v[2][2]))
|
||||||
|
),
|
||||||
|
line_number=line_number)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return StmtCorruptRemark('ASC_SOP', e, line_number)
|
||||||
|
|
||||||
|
elif line.startswith("ASC_SAT"):
|
||||||
|
value = re.findall(r'(-?\d+(\.\d+)?)', line)
|
||||||
|
|
||||||
|
if len(value) != 1:
|
||||||
|
return StmtRemark(line, line_number)
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return StmtCdlSat(value=float(value[0][0]),
|
||||||
|
line_number=line_number)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return StmtCorruptRemark('ASC_SAT', e, line_number)
|
||||||
|
|
||||||
|
elif line.startswith("FRMC"):
|
||||||
|
match = re.match(r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)'
|
||||||
|
r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
|
||||||
|
|
||||||
|
if match is None:
|
||||||
|
return StmtCorruptRemark('FRMC', None, line_number)
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return StmtFrmc(start=int(match.group(1)),
|
||||||
|
end=int(match.group(2)),
|
||||||
|
duration=int(match.group(3)),
|
||||||
|
line_number=line_number)
|
||||||
|
except ValueError as e:
|
||||||
|
return StmtCorruptRemark('FRMC', e, 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: str, line_number):
|
||||||
|
split_type = line[10:21]
|
||||||
|
is_video = split_type.startswith("VIDEO")
|
||||||
|
|
||||||
|
split_delay = line[24:35]
|
||||||
|
return StmtSplitEdit(video=is_video, delay=split_delay,
|
||||||
|
line_number=line_number)
|
||||||
|
|
||||||
|
|
||||||
|
# def _parse_motion_memory(line, line_number):
|
||||||
|
# return StmtMotionMemory(source="", fps="")
|
||||||
|
#
|
||||||
|
|
||||||
|
class EventFormError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_unrecognized(line, line_number):
|
||||||
|
return StmtUnrecognized(content=line, line_number=line_number)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_columns_for_standard_form(line: str, event_field_length: int,
|
||||||
|
source_field_length: int,
|
||||||
|
line_number: int):
|
||||||
|
# breakpoint()
|
||||||
|
col_widths = _edl_column_widths(event_field_length, source_field_length)
|
||||||
|
|
||||||
|
if sum(col_widths) > len(line):
|
||||||
|
raise EventFormError()
|
||||||
|
|
||||||
|
column_strings = collimate(line, col_widths)
|
||||||
|
|
||||||
|
channels = column_strings[4].strip()
|
||||||
|
trans = column_strings[6].strip()
|
||||||
|
|
||||||
|
if len(channels) == 0 or len(trans) == 0:
|
||||||
|
raise EventFormError()
|
||||||
|
|
||||||
|
return StmtEvent(event=column_strings[0],
|
||||||
|
source=column_strings[2].strip(),
|
||||||
|
channels=channels,
|
||||||
|
trans=trans,
|
||||||
|
trans_op=column_strings[8].strip(),
|
||||||
|
source_in=column_strings[10].strip(),
|
||||||
|
source_out=column_strings[12].strip(),
|
||||||
|
record_in=column_strings[14].strip(),
|
||||||
|
record_out=column_strings[16].strip(),
|
||||||
|
line_number=line_number,
|
||||||
|
source_field_size=source_field_length)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_columns_tolerant(line: str, line_number: int):
|
||||||
|
pattern = re.compile(r'^\s*(\d+)\s+(.{8,128}?)\s+'
|
||||||
|
r'(V|A|A2|AA|NONE|AA/V|A2/V|B)\s+'
|
||||||
|
r'(C|D|W|KB|K|KO)\s+(\d*)\s+(\d\d.\d\d.\d\d.\d\d)\s'
|
||||||
|
r'(\d\d.\d\d.\d\d.\d\d)\s(\d\d.\d\d.\d\d.\d\d)\s'
|
||||||
|
r'(\d\d.\d\d.\d\d.\d\d)'
|
||||||
|
)
|
||||||
|
|
||||||
|
match = pattern.match(line)
|
||||||
|
if match:
|
||||||
|
return StmtEvent(event=int(match.group(1)), source=match.group(2),
|
||||||
|
channels=match.group(3), trans=match.group(4),
|
||||||
|
trans_op=match.group(5), source_in=match.group(6),
|
||||||
|
source_out=match.group(7), record_in=match.group(8),
|
||||||
|
record_out=match.group(9), line_number=line_number,
|
||||||
|
source_field_size=len(match.group(2)))
|
||||||
|
else:
|
||||||
|
return StmtUnrecognized(line, line_number)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_source_umid_statement(line, line_number):
|
||||||
|
# trimmed = line[3:].strip()
|
||||||
|
# return StmtSourceUMID(name=None, umid=None, line_number=line_number)
|
||||||
|
...
|
||||||
102
src/pycmx/statements.py
Normal file
102
src/pycmx/statements.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# pycmx
|
||||||
|
# (c) 2025 Jamie Hardt
|
||||||
|
|
||||||
|
from typing import Any, NamedTuple
|
||||||
|
|
||||||
|
from .cdl import AscSopComponents
|
||||||
|
|
||||||
|
|
||||||
|
class StmtTitle(NamedTuple):
|
||||||
|
title: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtFCM(NamedTuple):
|
||||||
|
drop: bool
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtEvent(NamedTuple):
|
||||||
|
event: int
|
||||||
|
source: str
|
||||||
|
channels: str
|
||||||
|
trans: str
|
||||||
|
trans_op: str
|
||||||
|
source_in: str
|
||||||
|
source_out: str
|
||||||
|
record_in: str
|
||||||
|
record_out: str
|
||||||
|
source_field_size: int
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtAudioExt(NamedTuple):
|
||||||
|
audio3: bool
|
||||||
|
audio4: bool
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtClipName(NamedTuple):
|
||||||
|
name: str
|
||||||
|
affect: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtSourceFile(NamedTuple):
|
||||||
|
filename: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtCdlSop(NamedTuple):
|
||||||
|
line: str
|
||||||
|
cdl_sop: AscSopComponents[float]
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtCdlSat(NamedTuple):
|
||||||
|
value: float
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtFrmc(NamedTuple):
|
||||||
|
start: int
|
||||||
|
end: int
|
||||||
|
duration: int
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtRemark(NamedTuple):
|
||||||
|
text: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtEffectsName(NamedTuple):
|
||||||
|
name: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtSourceUMID(NamedTuple):
|
||||||
|
name: str
|
||||||
|
umid: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtSplitEdit(NamedTuple):
|
||||||
|
video: bool
|
||||||
|
delay: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtUnrecognized(NamedTuple):
|
||||||
|
content: str
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
class StmtCorruptRemark(NamedTuple):
|
||||||
|
selector: str
|
||||||
|
exception: Any
|
||||||
|
line_number: int
|
||||||
|
|
||||||
|
|
||||||
|
# StmtMotionMemory = namedtuple(
|
||||||
|
# "MotionMemory", ["source", "fps"]) # FIXME needs more fields
|
||||||
@@ -24,7 +24,7 @@ class Transition:
|
|||||||
@property
|
@property
|
||||||
def kind(self) -> Optional[str]:
|
def kind(self) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Return the kind of transition: Cut, Wipe, etc
|
Return the kind of transition: Cut, Wipe, etc.
|
||||||
"""
|
"""
|
||||||
if self.cut:
|
if self.cut:
|
||||||
return Transition.Cut
|
return Transition.Cut
|
||||||
@@ -56,7 +56,8 @@ class Transition:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def effect_duration(self) -> int:
|
def effect_duration(self) -> int:
|
||||||
"""The duration of this transition, in frames of the record target.
|
"""
|
||||||
|
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.
|
In the event of a key event, this is the duration of the fade in.
|
||||||
"""
|
"""
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# pycmx
|
# pycmx
|
||||||
# (c) 2018 Jamie Hardt
|
# (c) 2018-2025 Jamie Hardt
|
||||||
|
|
||||||
# Utility functions
|
# Utility functions
|
||||||
|
|
||||||
@@ -1 +1,2 @@
|
|||||||
from . import test_parse
|
from . import test_parse
|
||||||
|
from . import test_issue_19
|
||||||
|
|||||||
39
tests/edls/ISSUE_19_unusual01.edl
Normal file
39
tests/edls/ISSUE_19_unusual01.edl
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
TITLE: Final Master Generated by LTedlMixer...
|
||||||
|
0001 Z125C001_220217_ROLX V C 15:51:58:10 15:52:02:16 00:00:00:00 00:00:04:06
|
||||||
|
0002 B505C014_230224_RNBP V C 20:19:58:21 20:20:00:21 00:00:04:06 00:00:06:06
|
||||||
|
0003 B505C014_230224_RNBP V C 20:19:59:21 20:20:01:22 00:00:06:06 00:00:08:07
|
||||||
|
0004 B505C014_230224_RNBP V C 20:20:01:23 20:20:02:01 00:00:08:07 00:00:08:09
|
||||||
|
0005 B505C014_230224_RNBP V C 20:20:02:01 20:20:06:10 00:00:08:09 00:00:12:18
|
||||||
|
0006 B505C011_230224_RNBP V C 19:44:21:04 19:44:27:08 00:00:12:18 00:00:18:22
|
||||||
|
0007 B505C016_230224_RNBP V C 20:24:54:14 20:24:58:19 00:00:18:22 00:00:23:03
|
||||||
|
0008 Y022C029_211201_YNJI V C 12:42:37:04 12:42:39:14 00:00:23:03 00:00:25:13
|
||||||
|
0009 A054C025_211022_R24B V C 12:30:50:11 12:30:54:16 00:00:25:13 00:00:29:18
|
||||||
|
0010 Z040C026_211206_ROLX V C 14:42:25:21 14:42:28:17 00:00:29:18 00:00:32:14
|
||||||
|
0011 J001_C002_20211007_R V C 12:38:48:18 12:38:51:13 00:00:32:14 00:00:35:09
|
||||||
|
0012 C006C005_211007_RO2A V C 11:49:08:02 11:49:15:13 00:00:35:09 00:00:42:20
|
||||||
|
0013 A021C020_211007_R24B V C 18:14:52:00 18:14:58:22 00:00:42:20 00:00:49:18
|
||||||
|
0014 A023C013_211008_R24B V C 11:12:57:23 11:12:59:18 00:00:49:18 00:00:51:13
|
||||||
|
0015 U001C010_211029_R268 V C 04:38:42:21 04:38:49:21 00:00:51:13 00:00:58:13
|
||||||
|
0016 A021C009_211007_R24B V C 17:06:12:10 17:06:19:14 00:00:58:13 00:01:05:17
|
||||||
|
0017 A055C008_211022_R24B V C 15:16:03:10 15:16:05:09 00:01:05:17 00:01:07:16
|
||||||
|
0018 A055C008_211022_R24B V C 15:16:05:09 15:16:12:20 00:01:07:16 00:01:15:03
|
||||||
|
0019 A055C008_211022_R24B V C 15:16:12:20 15:16:14:19 00:01:15:03 00:01:17:02
|
||||||
|
0020 A056C011_211022_R24B V C 17:40:13:01 17:40:16:04 00:01:17:02 00:01:20:05
|
||||||
|
0021 A024C011_211008_R24B V C 17:32:07:02 17:32:10:01 00:01:20:05 00:01:23:04
|
||||||
|
0022 B070C001_211203_RP40 V C 17:39:20:20 17:39:22:14 00:01:23:04 00:01:24:22
|
||||||
|
0023 A055C019_211022_R24B V C 16:31:05:06 16:31:12:10 00:01:24:22 00:01:32:02
|
||||||
|
0024 A248C012_220224_R1Y2 V C 16:01:35:08 16:01:40:04 00:01:32:02 00:01:36:22
|
||||||
|
0025 A127C005_211206_R24B V C 10:58:23:06 10:58:24:09 00:01:36:22 00:01:38:01
|
||||||
|
0026 A040C006_211015_R24B V C 13:00:09:04 13:00:23:17 00:01:38:01 00:01:52:14
|
||||||
|
0027 A041C006_211015_R24B V C 16:10:32:08 16:10:35:12 00:01:52:14 00:01:55:18
|
||||||
|
0028 A040C006_211015_R24B V C 13:00:34:13 13:00:37:03 00:01:55:18 00:01:58:08
|
||||||
|
0029 A041C005_211015_R24B V C 15:57:22:05 15:57:27:11 00:01:58:08 00:02:03:14
|
||||||
|
0030 A040C008_211015_R24B V C 13:09:51:18 13:09:55:07 00:02:03:14 00:02:07:03
|
||||||
|
0031 A040C016_211015_R24B V C 14:09:15:11 14:09:20:02 00:02:07:03 00:02:11:18
|
||||||
|
0032 Z089C007_220122_ROLX V C 17:03:34:23 17:03:59:15 00:02:11:18 00:02:36:10
|
||||||
|
0033 A507C008_230227_RNHZ V C 09:55:35:10 09:55:41:02 00:02:36:10 00:02:42:02
|
||||||
|
0034 B049C021_211111_RP40 V C 17:38:55:11 17:38:56:17 00:02:42:02 00:02:43:08
|
||||||
|
0035 Z036C012_211202_ROLX V C 17:30:23:12 17:30:25:05 00:02:43:08 00:02:45:01
|
||||||
|
0036 A157C023_220112_R24B V C 14:13:18:04 14:13:20:06 00:02:45:01 00:02:47:03
|
||||||
|
0037 A095C014_211110_R24B V C 19:34:35:16 19:34:37:10 00:02:47:03 00:02:48:21
|
||||||
|
0038 Z089C010_220122_ROLX V C 17:28:55:21 17:28:58:09 00:02:48:21 00:02:51:09
|
||||||
25
tests/test_issue_19.py
Normal file
25
tests/test_issue_19.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
from pycmx import parse_cmx3600
|
||||||
|
|
||||||
|
|
||||||
|
class Issue19Test(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.f = open("tests/edls/ISSUE_19_unusual01.edl")
|
||||||
|
|
||||||
|
def test_parse(self):
|
||||||
|
edl = parse_cmx3600(self.f, tolerant=True)
|
||||||
|
for event in edl.events:
|
||||||
|
self.assertIsNotNone(event.edits)
|
||||||
|
if event.number == 1:
|
||||||
|
self.assertEqual(len(event.edits), 1)
|
||||||
|
self.assertEqual(event.edits[0].source, "Z125C001_220217_ROLX")
|
||||||
|
self.assertEqual(event.edits[0].channels.v, True)
|
||||||
|
self.assertEqual(event.edits[0].transition.kind, "C")
|
||||||
|
self.assertEqual(event.edits[0].transition.operand, "")
|
||||||
|
self.assertEqual(event.edits[0].source_in, "15:51:58:10")
|
||||||
|
self.assertEqual(event.edits[0].record_out, "00:00:04:06")
|
||||||
|
break
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.f.close()
|
||||||
@@ -143,36 +143,36 @@ class TestParse(TestCase):
|
|||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
for event in edl.events:
|
for event in edl.events:
|
||||||
if event.number == 1:
|
if event.number == 1:
|
||||||
sop = event.edits[0].asc_sop_statement
|
sop = event.edits[0].asc_sop
|
||||||
self.assertIsNotNone(sop)
|
self.assertIsNotNone(sop)
|
||||||
assert sop
|
assert sop
|
||||||
self.assertEqual(sop.slope_r, "0.9405")
|
self.assertEqual(sop.slope.red, float("0.9405"))
|
||||||
self.assertEqual(sop.offset_g, "-0.0276")
|
self.assertEqual(sop.offset.green, float("-0.0276"))
|
||||||
|
|
||||||
sat = event.edits[0].asc_sat_statement
|
sat = event.edits[0].asc_sat
|
||||||
self.assertIsNotNone(sat)
|
self.assertIsNotNone(sat)
|
||||||
assert sat
|
assert sat
|
||||||
self.assertEqual(sat.value, '0.9640')
|
self.assertEqual(sat, float('0.9640'))
|
||||||
|
|
||||||
def test_frmc(self):
|
def test_frmc(self):
|
||||||
with open("tests/edls/cdl_frmc_example01.edl", "r") as f:
|
with open("tests/edls/cdl_frmc_example01.edl", "r") as f:
|
||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
for event in edl.events:
|
for event in edl.events:
|
||||||
if event.number == 1:
|
if event.number == 1:
|
||||||
frmc = event.edits[0].frmc_statement
|
frmc = event.edits[0].framecounts
|
||||||
self.assertIsNotNone(frmc)
|
self.assertIsNotNone(frmc)
|
||||||
assert frmc
|
assert frmc
|
||||||
self.assertEqual(frmc.start, "1001")
|
self.assertEqual(frmc.start, 1001)
|
||||||
self.assertEqual(frmc.end, "1102")
|
self.assertEqual(frmc.end, 1102)
|
||||||
self.assertEqual(frmc.duration, "102")
|
self.assertEqual(frmc.duration, 102)
|
||||||
|
|
||||||
with open("tests/edls/cdl_frmc_example02.edl", "r") as f:
|
with open("tests/edls/cdl_frmc_example02.edl", "r") as f:
|
||||||
edl = pycmx.parse_cmx3600(f)
|
edl = pycmx.parse_cmx3600(f)
|
||||||
for event in edl.events:
|
for event in edl.events:
|
||||||
if event.number == 6:
|
if event.number == 6:
|
||||||
frmc = event.edits[0].frmc_statement
|
frmc = event.edits[0]._frmc_statement
|
||||||
self.assertIsNotNone(frmc)
|
self.assertIsNotNone(frmc)
|
||||||
assert frmc
|
assert frmc
|
||||||
self.assertEqual(frmc.start, "1001")
|
self.assertEqual(frmc.start, 1001)
|
||||||
self.assertEqual(frmc.end, "1486")
|
self.assertEqual(frmc.end, 1486)
|
||||||
self.assertEqual(frmc.duration, "486")
|
self.assertEqual(frmc.duration, 486)
|
||||||
|
|||||||
Reference in New Issue
Block a user