59 Commits

Author SHA1 Message Date
610d406e97 flake8 2025-12-17 15:13:49 -08:00
28307608fc Added raw ASC_SOP line method 2025-12-17 15:12:34 -08:00
9ab3804c89 Removed explicit imports of pycmx 2025-12-17 15:09:05 -08:00
886561f2c5 test update 2025-12-16 19:37:24 -08:00
5e4bce7be8 documentation 2025-12-16 19:35:21 -08:00
7a2917c357 documentation 2025-12-16 19:34:21 -08:00
0dd5ab33a5 documentation 2025-12-16 19:31:57 -08:00
4471953696 documentation 2025-12-16 19:22:41 -08:00
45228dcdf4 documentation 2025-12-16 19:04:22 -08:00
09ccdb8c8d documentation 2025-12-16 19:04:02 -08:00
43dd48dea7 documentation 2025-12-16 18:59:40 -08:00
abfdfaffd8 documentation 2025-12-16 18:53:26 -08:00
48f41ef4dc documentation 2025-12-16 18:51:02 -08:00
139259777e documentation 2025-12-16 18:44:04 -08:00
f4f0ba9d74 documentation 2025-12-16 18:42:17 -08:00
a0c2bc77bf documentation 2025-12-16 18:29:08 -08:00
7d6a6e9a33 documentation 2025-12-16 18:27:00 -08:00
73d860aa49 documentation 2025-12-16 18:25:45 -08:00
Jamie Hardt
51e1946c5d Merge pull request #18 from iluvcapra/edit-class-type-annotations
Add type annotations to Edit class constructor and attributes for cla…
2025-12-16 18:16:55 -08:00
Jamie Hardt
16a61bfcb0 line lengths 2025-12-16 18:15:52 -08:00
Jamie Hardt
5a093e10d2 Add type annotations to Edit class constructor and attributes for clarity 2025-12-16 18:13:16 -08:00
58f28e3e2e documentation 2025-12-16 18:01:22 -08:00
5dc6da8f99 flakey 2025-12-16 17:49:14 -08:00
c9987dac91 docs 2025-12-16 17:48:21 -08:00
1d6c99aba3 docs 2025-12-16 17:45:37 -08:00
717b045967 docs 2025-12-16 17:44:45 -08:00
f0a445b2b2 docuentation 2025-12-16 17:43:11 -08:00
f968b777a8 Merge branch 'master' of https://github.com/iluvcapra/pycmx 2025-12-16 17:21:34 -08:00
89fdefa565 doc 2025-12-16 17:21:30 -08:00
Jamie Hardt
ead9f8aa13 Enhance docstring for unrecognized_statements
Added a newline for the sake of sphinx.
2025-12-16 17:13:33 -08:00
3248601445 autopep 2025-12-16 17:04:54 -08:00
82daa88b8d autopep 2025-12-16 17:03:10 -08:00
ed4b81adf3 corrupt remarks now in unrecognized statements 2025-12-16 17:01:44 -08:00
d17354682c autopep 2025-12-16 16:57:56 -08:00
f56a2aef4f tweaking generics for <3.10 support 2025-12-16 16:56:55 -08:00
7b875900f9 flakery 2025-12-16 16:54:45 -08:00
fa19b9841f flakery 2025-12-16 16:53:30 -08:00
437ebef9c6 flakery 2025-12-16 16:50:31 -08:00
fe0818bfcc Typing 2025-12-16 16:49:01 -08:00
69dee73299 Error handling during parsing 2025-12-16 16:38:38 -08:00
1d78f11b11 retyping some CDL items 2025-12-16 16:22:30 -08:00
d071d6c27e retyping some CDL items 2025-12-16 16:06:58 -08:00
785b7a9a08 retyping some CDL items 2025-12-16 16:00:04 -08:00
3cdae1761f retyping some CDL items 2025-12-16 15:57:34 -08:00
5c02d09a7a flake8 2025-12-16 14:06:09 -08:00
d0d30702ac flake8 2025-12-16 14:04:47 -08:00
cc07efef24 flake8 2025-12-16 14:03:02 -08:00
155011abfa flake8 2025-12-16 14:00:49 -08:00
e0d59e3ec7 flake8 2025-12-16 13:59:55 -08:00
8cdcccce45 flake8 2025-12-16 13:58:41 -08:00
656f546d12 copyright updates 2025-12-16 13:42:16 -08:00
14320c709c typing things 2025-12-16 13:25:13 -08:00
5ad938c54b typing things 2025-12-16 13:12:41 -08:00
ec8c3e30f5 typing things 2025-12-16 13:11:59 -08:00
30bbb05c2d typing things 2025-12-16 13:04:18 -08:00
dd78fcc1ac pylint 2025-12-16 13:01:53 -08:00
ecb09da7ba renamed type, typo 2025-12-16 12:53:37 -08:00
aed9560b1e Merge branch 'master' of https://github.com/iluvcapra/pycmx 2025-12-16 12:50:20 -08:00
Jamie Hardt
5e0b5f4708 Merge pull request #17 from iluvcapra/16-feat-asc_cdl-and-frmc
CDL Statements: ASC_SOP, ASC_SAT and FRMC support
2025-12-16 12:39:41 -08:00
14 changed files with 432 additions and 158 deletions

View File

@@ -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
@@ -18,7 +17,8 @@ its most most common variations.
* 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,6 +28,8 @@ 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

View File

@@ -20,5 +20,6 @@ pycmx Classes
.. autoclass:: pycmx.channel_map.ChannelMap .. autoclass:: pycmx.channel_map.ChannelMap
:members: :members:
.. automodule:: pycmx.cdl
:members:

View File

@@ -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")

View File

@@ -1,10 +1,75 @@
.. pycmx documentation master file, created by .. pycmx documentation master file, created by
sphinx-quickstart on Wed Dec 26 21:51:43 2018. sphinx-quickstart on Wed Dec 26 21:51:43 2018.
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.
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.
* 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)
Welcome to pycmx's documentation!
=================================
.. toctree:: .. toctree::
:maxdepth: 5 :maxdepth: 5

48
pycmx/cdl.py Normal file
View 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

View File

@@ -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

View File

@@ -1,7 +1,17 @@
# pycmx # pycmx
# (c) 2018 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from pycmx.statements import StmtCdlSat, StmtCdlSop, StmtFrmc from .cdl import AscSopComponents, FramecountTriple
from .statements import (
StmtCdlSat,
StmtCdlSop,
StmtFrmc,
StmtEvent,
StmtAudioExt,
StmtClipName,
StmtSourceFile,
StmtEffectsName,
)
from .transition import Transition from .transition import Transition
from .channel_map import ChannelMap from .channel_map import ChannelMap
@@ -14,19 +24,28 @@ class Edit:
recorder timecode in and out, a transition and channels. recorder timecode in and out, a transition and channels.
""" """
def __init__(self, edit_statement, audio_ext_statement, def __init__(
clip_name_statement, source_file_statement, self,
trans_name_statement=None, asc_sop_statement=None, edit_statement: StmtEvent,
asc_sat_statement=None, frmc_statement=None): audio_ext_statement: Optional[StmtAudioExt],
clip_name_statement: Optional[StmtClipName],
self.edit_statement = edit_statement source_file_statement: Optional[StmtSourceFile],
self.audio_ext = audio_ext_statement trans_name_statement: Optional[StmtEffectsName] = None,
self.clip_name_statement = clip_name_statement asc_sop_statement: Optional[StmtCdlSop] = None,
self.source_file_statement = source_file_statement asc_sat_statement: Optional[StmtCdlSat] = None,
self.trans_name_statement = trans_name_statement frmc_statement: Optional[StmtFrmc] = None,
self.asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement ) -> None:
self.asc_sat_statement: Optional[StmtCdlSat] = asc_sat_statement # Assigning types for the attributes explicitly
self.frmc_statement: Optional[StmtFrmc] = frmc_statement 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 @property
def line_number(self) -> int: def line_number(self) -> int:
@@ -35,7 +54,7 @@ class Edit:
this edit. Line numbers a zero-indexed, such that the this edit. Line numbers a zero-indexed, such that the
"TITLE:" record is line zero. "TITLE:" record is line zero.
""" """
return self.edit_statement.line_number return self._edit_statement.line_number
@property @property
def channels(self) -> ChannelMap: def channels(self) -> ChannelMap:
@@ -43,30 +62,33 @@ class Edit:
Get the :obj:`ChannelMap` object associated with this Edit. Get the :obj:`ChannelMap` object associated with this Edit.
""" """
cm = ChannelMap() cm = ChannelMap()
cm._append_event(self.edit_statement.channels) cm._append_event(self._edit_statement.channels)
if self.audio_ext is not None: if self._audio_ext is not None:
cm._append_ext(self.audio_ext) cm._append_ext(self._audio_ext)
return cm return cm
@property @property
def transition(self) -> Transition: def transition(self) -> Transition:
""" """
Get the :obj:`Transition` object associated with this edit. Get the :obj:`Transition` that initiates this edit.
""" """
if self.trans_name_statement: if self._trans_name_statement:
return Transition(self.edit_statement.trans, return Transition(
self.edit_statement.trans_op, self._edit_statement.trans,
self.trans_name_statement.name) self._edit_statement.trans_op,
self._trans_name_statement.name,
)
else: else:
return Transition(self.edit_statement.trans, return Transition(
self.edit_statement.trans_op, None) self._edit_statement.trans, self._edit_statement.trans_op, None
)
@property @property
def source_in(self) -> str: def source_in(self) -> str:
""" """
Get the source in timecode. Get the source in timecode.
""" """
return self.edit_statement.source_in return self._edit_statement.source_in
@property @property
def source_out(self) -> str: def source_out(self) -> str:
@@ -74,7 +96,7 @@ class Edit:
Get the source out timecode. Get the source out timecode.
""" """
return self.edit_statement.source_out return self._edit_statement.source_out
@property @property
def record_in(self) -> str: def record_in(self) -> str:
@@ -82,7 +104,7 @@ class Edit:
Get the record in timecode. Get the record in timecode.
""" """
return self.edit_statement.record_in return self._edit_statement.record_in
@property @property
def record_out(self) -> str: def record_out(self) -> str:
@@ -90,7 +112,7 @@ class Edit:
Get the record out timecode. Get the record out timecode.
""" """
return self.edit_statement.record_out return self._edit_statement.record_out
@property @property
def source(self) -> str: def source(self) -> str:
@@ -98,19 +120,21 @@ class Edit:
Get the source column. This is the 8, 32 or 128-character string on the 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. event record line, this usually references the tape name of the source.
""" """
return self.edit_statement.source return self._edit_statement.source
@property @property
def black(self) -> bool: def black(self) -> bool:
""" """
Black video or silence should be used as the source for this event. 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" return self.source == "BL"
@property @property
def aux_source(self) -> bool: def aux_source(self) -> bool:
""" """
An auxiliary source is the source of this event. The source field for this edit was "AX". An auxiliary source is the
source for this event.
""" """
return self.source == "AX" return self.source == "AX"
@@ -120,10 +144,10 @@ class Edit:
Get the source file, as attested by a "* SOURCE FILE" remark on the Get the source file, as attested by a "* SOURCE FILE" remark on the
EDL. This will return None if the information is not present. EDL. This will return None if the information is not present.
""" """
if self.source_file_statement is None: if self._source_file_statement is None:
return None return None
else: else:
return self.source_file_statement.filename return self._source_file_statement.filename
@property @property
def clip_name(self) -> Optional[str]: def clip_name(self) -> Optional[str]:
@@ -132,28 +156,59 @@ class Edit:
NAME" remark on the EDL. This will return None if the information is NAME" remark on the EDL. This will return None if the information is
not present. not present.
""" """
if self.clip_name_statement is None: if self._clip_name_statement is None:
return None return None
else: else:
return self.clip_name_statement.name return self._clip_name_statement.name
@property @property
def asc_sop(self) -> Optional[StmtCdlSop]: def asc_sop(self) -> Optional[AscSopComponents[float]]:
""" """
Get ASC CDL Slope-Offset-Power transfer function for clip, if present 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`.
""" """
return self.asc_sop_statement if self._asc_sop_statement is None:
return None
return self._asc_sop_statement.cdl_sop
@property @property
def asc_sat(self) -> Optional[StmtCdlSat]: 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 Get ASC CDL saturation value for clip, if present
""" """
return self.asc_sat_statement if self._asc_sat_statement is None:
return None
return self._asc_sat_statement.value
@property @property
def frmc(self) -> Optional[StmtFrmc]: def framecounts(self) -> Optional[FramecountTriple]:
""" """
Get FRMC data 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.
""" """
return self.frmc_statement if not self._frmc_statement:
return None
return FramecountTriple(
start=self._frmc_statement.start,
end=self._frmc_statement.end,
duration=self._frmc_statement.duration,
)

View File

@@ -1,13 +1,12 @@
# 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:
@@ -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

View File

@@ -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

View File

@@ -1,13 +1,11 @@
# 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) -> EditList: def parse_cmx3600(f: TextIO) -> EditList:
""" """

View File

@@ -1,14 +1,15 @@
# pycmx # pycmx
# (c) 2018 Jamie Hardt # (c) 2018-2025 Jamie Hardt
import re import re
from typing import TextIO, List from typing import TextIO, List
from .statements import (StmtCdlSat, StmtCdlSop, StmtFrmc, StmtRemark, from .cdl import AscSopComponents, Rgb
StmtTitle, StmtUnrecognized, StmtFCM, StmtAudioExt,
StmtClipName, StmtEffectsName, StmtEvent, from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
StmtSourceFile, StmtSplitEdit, StmtMotionMemory, StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
StmtSourceUMID) StmtAudioExt, StmtClipName, StmtEffectsName,
StmtEvent, StmtSourceFile, StmtSplitEdit)
from .util import collimate from .util import collimate
@@ -49,27 +50,28 @@ def _parse_cmx3600_line(line: str, line_number: int) -> object:
if line.startswith("TITLE:"): if line.startswith("TITLE:"):
return _parse_title(line, line_number) return _parse_title(line, line_number)
elif line.startswith("FCM:"): if line.startswith("FCM:"):
return _parse_fcm(line, line_number) return _parse_fcm(line, line_number)
elif line_matcher is not None: if line_matcher is not None:
event_field_len = len(line_matcher.group(1)) event_field_len = len(line_matcher.group(1))
source_field_len = len(line) - (event_field_len + 65) source_field_len = len(line) - (event_field_len + 65)
return _parse_columns_for_standard_form(line, event_field_len, return _parse_columns_for_standard_form(line, event_field_len,
source_field_len, line_number) source_field_len, line_number)
elif line.startswith("AUD"): if line.startswith("AUD"):
return _parse_extended_audio_channels(line, line_number) return _parse_extended_audio_channels(line, line_number)
elif line.startswith("*"): if line.startswith("*"):
return _parse_remark(line[1:].strip(), line_number) return _parse_remark(line[1:].strip(), line_number)
elif line.startswith(">>> SOURCE"): if line.startswith(">>> SOURCE"):
return _parse_source_umid_statement(line, line_number) return _parse_source_umid_statement(line, line_number)
elif line.startswith("EFFECTS NAME IS"): if line.startswith("EFFECTS NAME IS"):
return _parse_effects_name(line, line_number) return _parse_effects_name(line, line_number)
elif line.startswith("SPLIT:"): if line.startswith("SPLIT:"):
return _parse_split(line, line_number) return _parse_split(line, line_number)
elif line.startswith("M2"): if line.startswith("M2"):
return _parse_motion_memory(line, line_number) pass
else: # return _parse_motion_memory(line, line_number)
return _parse_unrecognized(line, line_number)
return _parse_unrecognized(line, line_number)
def _parse_title(line, line_num) -> StmtTitle: def _parse_title(line, line_num) -> StmtTitle:
@@ -81,14 +83,14 @@ def _parse_fcm(line, line_num) -> StmtFCM:
val = line[4:].strip() val = line[4:].strip()
if val == "DROP FRAME": if val == "DROP FRAME":
return StmtFCM(drop=True, line_number=line_num) return StmtFCM(drop=True, line_number=line_num)
else:
return StmtFCM(drop=False, line_number=line_num) return StmtFCM(drop=False, line_number=line_num)
def _parse_extended_audio_channels(line, line_number): def _parse_extended_audio_channels(line, line_number):
content = line.strip() content = line.strip()
audio3 = True if "3" in content else False audio3 = "3" in content
audio4 = True if "4" in content else False audio4 = "4" in content
if audio3 or audio4: if audio3 or audio4:
return StmtAudioExt(audio3, audio4, line_number) return StmtAudioExt(audio3, audio4, line_number)
@@ -118,11 +120,23 @@ def _parse_remark(line, line_number) -> object:
return StmtRemark(line, line_number) return StmtRemark(line, line_number)
else: else:
return StmtCdlSop(slope_r=v[0][0], slope_g=v[0][1], try:
slope_b=v[0][2], offset_r=v[1][0], return StmtCdlSop(line=line,
offset_g=v[1][1], offset_b=v[1][2], cdl_sop=AscSopComponents(
power_r=v[2][0], power_g=v[2][1], slope=Rgb(red=float(v[0][0]),
power_b=v[2][2], line_number=line_number) 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"): elif line.startswith("ASC_SAT"):
value = re.findall(r'(-?\d+(\.\d+)?)', line) value = re.findall(r'(-?\d+(\.\d+)?)', line)
@@ -131,19 +145,28 @@ def _parse_remark(line, line_number) -> object:
return StmtRemark(line, line_number) return StmtRemark(line, line_number)
else: else:
return StmtCdlSat(value=value[0][0], line_number=line_number) 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"): elif line.startswith("FRMC"):
match = re.match( match = re.match(r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)'
r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)' r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
if match is None: if match is None:
return StmtRemark(line, line_number) return StmtCorruptRemark('FRMC', None, line_number)
else: else:
return StmtFrmc(start=match.group(1), end=match.group(2), try:
duration=match.group(3), line_number=line_number) 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: else:
return StmtRemark(text=line, line_number=line_number) return StmtRemark(text=line, line_number=line_number)
@@ -154,27 +177,26 @@ def _parse_effects_name(line, line_number) -> StmtEffectsName:
return StmtEffectsName(name=name, line_number=line_number) return StmtEffectsName(name=name, line_number=line_number)
def _parse_split(line, line_number): def _parse_split(line: str, line_number):
split_type = line[10:21] split_type = line[10:21]
is_video = False is_video = split_type.startswith("VIDEO")
if split_type.startswith("VIDEO"):
is_video = True
split_mag = line[24:35] split_delay = line[24:35]
return StmtSplitEdit(video=is_video, magnitude=split_mag, return StmtSplitEdit(video=is_video, delay=split_delay,
line_number=line_number) line_number=line_number)
def _parse_motion_memory(line, line_number): # def _parse_motion_memory(line, line_number):
return StmtMotionMemory(source="", fps="") # return StmtMotionMemory(source="", fps="")
#
def _parse_unrecognized(line, line_number): def _parse_unrecognized(line, line_number):
return StmtUnrecognized(content=line, line_number=line_number) return StmtUnrecognized(content=line, line_number=line_number)
def _parse_columns_for_standard_form(line, event_field_length, def _parse_columns_for_standard_form(line: str, event_field_length: int,
source_field_length, line_number): source_field_length: int,
line_number: int):
col_widths = _edl_column_widths(event_field_length, source_field_length) col_widths = _edl_column_widths(event_field_length, source_field_length)
if sum(col_widths) > len(line): if sum(col_widths) > len(line):
@@ -191,9 +213,11 @@ def _parse_columns_for_standard_form(line, event_field_length,
source_out=column_strings[12].strip(), source_out=column_strings[12].strip(),
record_in=column_strings[14].strip(), record_in=column_strings[14].strip(),
record_out=column_strings[16].strip(), record_out=column_strings[16].strip(),
line_number=line_number, format=source_field_length) line_number=line_number,
source_field_size=source_field_length)
def _parse_source_umid_statement(line, line_number): 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) # return StmtSourceUMID(name=None, umid=None, line_number=line_number)
...

View File

@@ -1,24 +1,104 @@
from collections import namedtuple # pycmx
# (c) 2025 Jamie Hardt
StmtTitle = namedtuple("Title", ["title", "line_number"]) from typing import Any, NamedTuple
StmtFCM = namedtuple("FCM", ["drop", "line_number"])
StmtEvent = namedtuple("Event", ["event", "source", "channels", "trans", from .cdl import AscSopComponents
"trans_op", "source_in", "source_out",
"record_in", "record_out", "format", # type str = str
"line_number"])
StmtAudioExt = namedtuple("AudioExt", ["audio3", "audio4", "line_number"])
StmtClipName = namedtuple("ClipName", ["name", "affect", "line_number"]) class StmtTitle(NamedTuple):
StmtSourceFile = namedtuple("SourceFile", ["filename", "line_number"]) title: str
StmtCdlSop = namedtuple("CdlSop", ['slope_r', 'slope_g', 'slope_b', line_number: int
'offset_r', 'offset_g', 'offset_b',
'power_r', 'power_g', 'power_b',
'line_number']) class StmtFCM(NamedTuple):
StmtCdlSat = namedtuple("SdlSat", ['value', 'line_number']) drop: bool
StmtFrmc = namedtuple("Frmc", ['start', 'end', 'duration', 'line_number']) line_number: int
StmtRemark = namedtuple("Remark", ["text", "line_number"])
StmtEffectsName = namedtuple("EffectsName", ["name", "line_number"])
StmtSourceUMID = namedtuple("Source", ["name", "umid", "line_number"]) class StmtEvent(NamedTuple):
StmtSplitEdit = namedtuple("SplitEdit", ["video", "magnitude", "line_number"]) event: int
StmtMotionMemory = namedtuple( source: str
"MotionMemory", ["source", "fps"]) # FIXME needs more fields channels: str
StmtUnrecognized = namedtuple("Unrecognized", ["content", "line_number"]) 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

View File

@@ -1,5 +1,5 @@
# pycmx # pycmx
# (c) 2018 Jamie Hardt # (c) 2018-2025 Jamie Hardt
# Utility functions # Utility functions

View File

@@ -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)