37 Commits

Author SHA1 Message Date
jamie 4f0a9db555 Nudged version 2026-09-17 15:12:55 -07:00
jamie d61adad4c3 Ruff fixes 2026-09-17 15:10:47 -07:00
jamie 03b4119ccf Ruff fixes 2026-09-17 15:05:58 -07:00
jamie 5e560dfa0e Ruff fixes 2026-09-17 14:49:46 -07:00
jamie daedabd7cc Ruff autofixes 2026-09-17 14:44:46 -07:00
jamie 9fd5d36611 Merge branch 'master' of https://github.com/iluvcapra/pycmx 2026-09-17 14:42:17 -07:00
jamie e8556958b5 some ruff 2026-09-17 14:42:09 -07:00
Jamie Hardt 98e21e7a09 Update CONTRIBUTING.md 2026-05-03 12:17:15 -07:00
Jamie Hardt 694b99d081 Update CONTRIBUTING.md
Updated Agents policy
2026-05-03 12:16:59 -07:00
Jamie Hardt 2e4a0439c1 Update .readthedocs.yaml
Updated rtd build to ubuntu-lts-latest
2026-03-30 11:41:05 -07:00
jamie 688402d195 some notes to myslf 2025-12-20 13:44:29 -08:00
jamie 4e81810584 ruff configuration 2025-12-18 22:07:08 -08:00
jamie faf2596a57 ruff for linting 2025-12-18 21:45:24 -08:00
jamie 1e9fbe339c ruff for linting 2025-12-18 21:44:28 -08:00
jamie a8d00470d4 ruff for linting 2025-12-18 21:43:12 -08:00
jamie fe1e59e731 fix __all__ for module 2025-12-18 21:36:11 -08:00
jamie ec8a08074d doc twiddles 2025-12-18 20:50:06 -08:00
Jamie Hardt ef683a7683 Update pip install command for documentation 2025-12-18 20:46:33 -08:00
Jamie Hardt d778f64230 Merge pull request #21 from iluvcapra/uv-build
PEP 621 compliance
2025-12-18 20:43:40 -08:00
jamie 1b0ccd4ef7 doc twiddles 2025-12-18 20:41:44 -08:00
jamie 498d5c8fea doc tiwddles 2025-12-18 17:24:26 -08:00
jamie af5d937aeb doc tiwddles 2025-12-18 17:19:20 -08:00
jamie 637f4ab9a4 tweaking rtd config 2025-12-18 16:00:25 -08:00
jamie 4cd635ff17 nudging docs python version 2025-12-18 15:54:48 -08:00
jamie 26e1e38320 nudging docs python version 2025-12-18 15:46:01 -08:00
jamie 0e97742336 added 3.14 to matrix, some dependency work 2025-12-18 13:30:52 -08:00
jamie 67ea12042f updated .flake8 path 2025-12-18 12:35:53 -08:00
jamie 6b910a0920 pyproject.toml now PEP 621 compliant
Update build system to uv
2025-12-18 12:27:21 -08:00
jamie a8f35a9ffc twiddle, nudge version number 2025-12-18 10:18:04 -08:00
Jamie Hardt 242f2e08d5 Merge pull request #20 from iluvcapra/19-unusual-edl
Tolerant Parsing Mode
2025-12-18 10:06:07 -08:00
jamie 36be259177 documentation 2025-12-18 09:40:26 -08:00
jamie 4b73dc7730 removed breakpoints 2025-12-17 19:49:02 -08:00
jamie 3cf31fa462 autopep 2025-12-17 19:42:32 -08:00
jamie 33bd5a0001 autopep 2025-12-17 19:39:38 -08:00
jamie ebdc73198c implementation of tolerant parsing 2025-12-17 19:38:53 -08:00
jamie cf1b3fb42c Leaving this for now 2025-12-17 15:05:40 -08:00
jamie 6041d4158e Added problem edl 2025-12-17 14:21:51 -08:00
28 changed files with 866 additions and 612 deletions
-5
View File
@@ -1,5 +0,0 @@
[flake8]
per-file-ignores =
pycmx/__init__.py: F401
tests/__init__.py: F401
+4 -7
View File
@@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
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:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
@@ -26,13 +26,10 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install flake8 pytest
python -m pip install -e .
- name: Lint with flake8
python -m pip install -e .[dev]
- name: Lint with ruff
run: |
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --max-line-length=79 --statistics
ruff check src/
- name: Test with pytest
run: |
pytest
+1
View File
@@ -0,0 +1 @@
3.13
+6 -8
View File
@@ -7,13 +7,13 @@ version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-20.04
os: ubuntu-lts-latest
tools:
python: "3.10"
# You can also specify other tool versions:
# nodejs: "16"
# rust: "1.55"
# golang: "1.17"
python: "3.13"
jobs:
install:
- pip install --upgrade pip
- pip install --group 'doc' -e .
# Build documentation in the docs/ directory with Sphinx
sphinx:
@@ -28,5 +28,3 @@ python:
install:
- method: pip
path: .
extra_requirements:
- doc
+32 -1
View File
@@ -9,5 +9,36 @@ The best way to contribute code to this project is to find this project on [Gith
If you have EDLs you are having trouble working with becuase of unusual formatting, please send me a copy! Contact us
through [Github].
[github]: https://github.com/iluvcapra/pycmx
## Regarding use of Agents
`pycmx` is an open-source project that is offered free for no commerical gain, and
is developed and maintained for educational and creative reasons.
If you use an agent or LLM to produce code for it you are missing out on the benefits
of contributing to an open-source project, particularly community, collaboration with
other developers and designers, and being able to learn and experiment without the
burden of deadlines or worrying about business cases or profits.
This project is supposed to be fun, do not let machines have fun for you.
We can't prevent you from using LLMs to contribute to this project but we ask you
abide by the following eitiquette when doing so:
* All communication with the maintainers must be written by a human in their own
voice. Never use an LLM to craft thread comments, discussion posts, issues, emails
or other correspondence with other developers or the maintainers.
* PRs must be submitted by a person. Do not allow an agent to submit its own PRs to
this project.
* Especially if you are a new contributor to this project, please submit only one PR
at a time and please restrict the subject matter of the PR to a specific unit,
module or tool. All submissions have to be reviewed and understood by the
maintainers before they can be merged.
Obviously we can't verify if you follow all of these rules but certain telltale
traits of LLM-predicted text or code will raise a flag: lack of brevity in
descriptions or code comments, large amounts of text describing your process or
steps that add little to understanding the changes you've made, use of an
obsequious tone or being excessively accomodating, immediately doing requests
without further discussion or clarifications.
+3 -1
View File
@@ -14,6 +14,8 @@ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
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.
@@ -35,7 +37,7 @@ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
### Opening and Parsing EDL Files
```
>>> import pycmx
>>> with open("tests/edls/TEST.edl") as f
>>> with open("tests/edls/TEST.edl") as f:
... edl = pycmx.parse_cmx3600(f)
...
>>> edl.title
+56 -35
View File
@@ -1,12 +1,11 @@
import pycmx
import re
import argparse
import logging
import re
import sys
import logging
import pycmx
FORMAT = '%(asctime)-15s %(message)s'
FORMAT = "%(asctime)-15s %(message)s"
logging.basicConfig(format=FORMAT)
@@ -21,7 +20,7 @@ def all_video_edits(edl):
def get_scene_name(edit, pattern):
scene_extractor = re.compile(pattern, re.I)
scene_extractor = re.compile(pattern, re.IGNORECASE)
if edit.clip_name is None:
return None
else:
@@ -37,16 +36,15 @@ def output_cmx(outfile, out_list):
outfile.write("FCM: NON-DROP FRAME\r\n")
for i, o in enumerate(out_list):
line = '%03i AX V C ' % (i)
line += '00:00:00:00 00:00:00:00 %s %s\r\n' % (o['start'], o['end'])
line = "{i: 03d} AX V C "
line += f"00:00:00:00 00:00:00:00 {o['start']} {o['end']}\r\n"
outfile.write(line)
outfile.write("* FROM CLIP NAME: %s\r\n" % (o['scene']))
outfile.write(f"* FROM CLIP NAME: {o['scene']}\r\n")
def output_cols(outfile, out_list):
for o in out_list:
outfile.write("%-12s\t%-12s\t%s\n" %
(o['start'], o['end'], o['scene']))
outfile.write(f"{o['start']:>12}\t{o['end']:>12}\t{o['scene']}")
def scene_list(infile, outfile, out_format, pattern):
@@ -68,15 +66,17 @@ def scene_list(infile, outfile, out_format, pattern):
out_list = []
for group in grouped_edits:
out_list.append({
'start': group[0].record_in,
'end': group[-1].record_out,
'scene': get_scene_name(group[0], pattern)}
out_list.append(
{
"start": group[0].record_in,
"end": group[-1].record_out,
"scene": get_scene_name(group[0], pattern),
}
)
if out_format == 'cmx':
if out_format == "cmx":
output_cmx(outfile, out_list)
if out_format == 'cols':
if out_format == "cols":
output_cols(outfile, out_list)
else:
log.warning(f"Format {out_format} unrecognized. Will use cmx.\n")
@@ -85,28 +85,49 @@ def scene_list(infile, outfile, out_format, pattern):
def scene_list_cli():
parser = argparse.ArgumentParser(
description='Read video events from an input CMX EDL and output '
'events merged into scenes.')
parser.add_argument('-o', '--outfile', default=sys.stdout,
type=argparse.FileType('w'),
help='Output file. Default is stdout.')
parser.add_argument('-f', '--format', default='cmx', type=str,
help='Output format. Options are cols and cmx, cmx '
'is the default.')
parser.add_argument('-p', '--pattern', default='V?([A-Z]*[0-9]+)',
help='RE pattern for extracting scene name from clip '
'name. The default is "V?([A-Z]*[0-9]+)". ' +
'This pattern will be matched case-insensitively.')
parser.add_argument('input_edl', default=sys.stdin,
type=argparse.FileType('r'), nargs='?',
help='Input file. Default is stdin.')
description="Read video events from an input CMX EDL and output "
"events merged into scenes."
)
parser.add_argument(
"-o",
"--outfile",
default=sys.stdout,
type=argparse.FileType("w"),
help="Output file. Default is stdout.",
)
parser.add_argument(
"-f",
"--format",
default="cmx",
type=str,
help="Output format. Options are cols and cmx, cmx is the default.",
)
parser.add_argument(
"-p",
"--pattern",
default="V?([A-Z]*[0-9]+)",
help="RE pattern for extracting scene name from clip "
'name. The default is "V?([A-Z]*[0-9]+)". '
+ "This pattern will be matched case-insensitively.",
)
parser.add_argument(
"input_edl",
default=sys.stdin,
type=argparse.FileType("r"),
nargs="?",
help="Input file. Default is stdin.",
)
args = parser.parse_args()
infile = args.input_edl
scene_list(infile=infile, outfile=args.outfile,
out_format=args.format, pattern=args.pattern)
scene_list(
infile=infile,
outfile=args.outfile,
out_format=args.format,
pattern=args.pattern,
)
if __name__ == '__main__':
if __name__ == "__main__":
scene_list_cli()
+30 -31
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.
#
@@ -15,14 +14,15 @@
import importlib
import os
import sys
sys.path.insert(0, os.path.abspath('../..'))
sys.path.insert(0, os.path.abspath("../.."))
# -- Project information -----------------------------------------------------
project = u'pycmx'
copyright = u'(c) 2018-2025, Jamie Hardt'
author = u'Jamie Hardt'
project = "pycmx"
copyright = "(c) 2018-2025, Jamie Hardt"
author = "Jamie Hardt"
release = importlib.metadata.version("pycmx")
version = release
@@ -40,31 +40,31 @@ version = release
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.todo',
'sphinx.ext.coverage',
'sphinx.ext.viewcode',
'sphinx.ext.githubpages',
"sphinx.ext.autodoc",
"sphinx.ext.todo",
"sphinx.ext.coverage",
"sphinx.ext.viewcode",
"sphinx.ext.githubpages",
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
source_suffix = '.rst'
source_suffix = ".rst"
# The master toctree document.
master_doc = 'index'
master_doc = "index"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = 'em'
language = "em"
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
@@ -80,18 +80,18 @@ pygments_style = None
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'
html_theme = "sphinx_rtd_theme"
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#
html_theme_options = {'collapse_navigation': False}
html_theme_options = {"collapse_navigation": False}
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
html_static_path = ["_static"]
# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
@@ -107,7 +107,7 @@ html_static_path = ['_static']
# -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder.
htmlhelp_basename = 'pycmxdoc'
htmlhelp_basename = "pycmxdoc"
# -- Options for LaTeX output ------------------------------------------------
@@ -116,15 +116,12 @@ latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#
# 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#
# 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#
# 'preamble': '',
# Latex figure (float) alignment
#
# 'figure_align': 'htbp',
@@ -134,8 +131,7 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, 'pycmx.tex', u'pycmx Documentation',
u'Jamie Hardt', 'manual'),
(master_doc, "pycmx.tex", "pycmx Documentation", "Jamie Hardt", "manual"),
]
@@ -143,10 +139,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, 'pycmx', u'pycmx Documentation',
[author], "3p")
]
man_pages = [(master_doc, "pycmx", "pycmx Documentation", [author], "3p")]
# -- Options for Texinfo output ----------------------------------------------
@@ -155,9 +148,15 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
(master_doc, 'pycmx', u'pycmx Documentation',
author, 'pycmx', 'One line description of project.',
'Miscellaneous'),
(
master_doc,
"pycmx",
"pycmx Documentation",
author,
"pycmx",
"One line description of project.",
"Miscellaneous",
),
]
@@ -176,7 +175,7 @@ epub_title = project
# epub_uid = ''
# A list of files that should not be packed into the epub file.
epub_exclude_files = ['search.html']
epub_exclude_files = ["search.html"]
# -- Extension configuration -------------------------------------------------
+3 -1
View File
@@ -1,7 +1,7 @@
.. pycmx documentation master file, created by
sphinx-quickstart on Wed Dec 26 21:51:43 2018.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
contain the root `toctree` directive.
pycmx - A CMX EDL Parser in Python
====================================
@@ -16,6 +16,8 @@ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
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.
-122
View File
@@ -1,122 +0,0 @@
# pycmx
# (c) 2023-2025 Jamie Hardt
from .statements import (StmtFrmc, StmtEvent, StmtClipName, StmtSourceFile,
StmtAudioExt, StmtUnrecognized, StmtEffectsName,
StmtCdlSop, StmtCdlSat)
from .edit import Edit
from typing import List, Generator, Optional, Tuple, Any
class Event:
"""
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
event number. """
def __init__(self, statements):
self.statements = statements
@property
def number(self) -> int:
"""
Return the event number.
"""
return int(self._edit_statements()[0].event)
@property
def edits(self) -> List[Edit]:
"""
Returns the edits. Most events will have a single edit, a single event
will have multiple edits when a dissolve, wipe or key transition needs
to be performed.
"""
edits_audio = list(self._statements_with_audio_ext())
clip_names = self._clip_name_statements()
source_files = self._source_file_statements()
the_zip: List[List[Any]] = [edits_audio]
if len(edits_audio) == 2:
start_name: Optional[StmtClipName] = None
end_name: Optional[StmtClipName] = None
for clip_name in clip_names:
if clip_name.affect == 'from':
start_name = clip_name
elif clip_name.affect == 'to':
end_name = clip_name
the_zip.append([start_name, end_name])
else:
if len(edits_audio) == len(clip_names):
the_zip.append(clip_names)
else:
the_zip.append([None] * len(edits_audio))
if len(edits_audio) == len(source_files):
the_zip.append(source_files)
elif len(source_files) == 1:
the_zip.append(source_files * len(edits_audio))
else:
the_zip.append([None] * len(edits_audio))
# attach trans name to last event
try:
trans_statement = self._trans_name_statements()[0]
trans_names: List[Optional[Any]] = [None] * (len(edits_audio) - 1)
trans_names.append(trans_statement)
the_zip.append(trans_names)
except IndexError:
the_zip.append([None] * len(edits_audio))
return [Edit(edit_statement=e1[0],
audio_ext_statement=e1[1],
clip_name_statement=n1,
source_file_statement=s1,
trans_name_statement=u1,
asc_sop_statement=self._asc_sop_statement(),
asc_sat_statement=self._asc_sat_statement(),
frmc_statement=self._frmc_statement())
for (e1, n1, s1, u1) in zip(*the_zip)]
@property
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None,
None]:
"""
A generator for all the unrecognized statements in the event.
"""
for s in self.statements:
if type(s) is StmtUnrecognized:
yield s
def _trans_name_statements(self) -> List[StmtEffectsName]:
return [s for s in self.statements if type(s) is StmtEffectsName]
def _edit_statements(self) -> List[StmtEvent]:
return [s for s in self.statements if type(s) is StmtEvent]
def _clip_name_statements(self) -> List[StmtClipName]:
return [s for s in self.statements if type(s) is StmtClipName]
def _source_file_statements(self) -> List[StmtSourceFile]:
return [s for s in self.statements if type(s) is StmtSourceFile]
def _statements_with_audio_ext(self) -> Generator[
Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]:
for (s1, s2) in zip(self.statements, self.statements[1:]):
if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
yield (s1, s2)
elif type(s1) is StmtEvent:
yield (s1, None)
def _asc_sop_statement(self) -> Optional[StmtCdlSop]:
return next((s for s in self.statements if type(s) is StmtCdlSop),
None)
def _asc_sat_statement(self) -> Optional[StmtCdlSat]:
return next((s for s in self.statements if type(s) is StmtCdlSat),
None)
def _frmc_statement(self) -> Optional[StmtFrmc]:
return next((s for s in self.statements if type(s) is StmtFrmc), None)
-18
View File
@@ -1,18 +0,0 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from typing import TextIO
from .parse_cmx_statements import (parse_cmx3600_statements)
from .edit_list import EditList
def parse_cmx3600(f: TextIO) -> EditList:
"""
Parse a CMX 3600 EDL.
:param TextIO f: a file-like object, an opened CMX 3600 .EDL file.
:returns: An :class:`pycmx.edit_list.EditList`.
"""
statements = parse_cmx3600_statements(f)
return EditList(statements)
-223
View File
@@ -1,223 +0,0 @@
# 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) -> 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)
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)
return _parse_columns_for_standard_form(line, event_field_len,
source_field_len, 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="")
#
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):
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,
source_field_size=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)
...
+36 -28
View File
@@ -1,18 +1,18 @@
[tool.poetry]
[project]
name = "pycmx"
version = "1.4.0"
version = "1.5.1"
description = "Python CMX 3600 Edit Decision List Parser"
authors = ["Jamie Hardt <jamiehardt@me.com>"]
license = "MIT"
authors = [{name = "Jamie Hardt", email= "jamiehardt@me.com"}]
license-files = ["LICENSE"]
readme = "README.md"
keywords = [
'parser',
'film',
'broadcast'
]
requires-python = '>3.8'
classifiers = [
'Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: MIT License',
'Topic :: Multimedia',
'Topic :: Multimedia :: Video',
'Topic :: Text Processing',
@@ -21,36 +21,44 @@ classifiers = [
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'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.16.8'
]
[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']
[tool.poetry.dependencies]
python = "^3.8"
sphinx = { version='>= 5.3.0', optional=true}
sphinx_rtd_theme = {version ='>= 1.1.1', optional=true}
[tool.pyright]
typeCheckingMode = "basic"
[tool.pylint]
max-line-length = 88
disable = [
"C0103", # (invalid-name)
"C0114", # (missing-module-docstring)
"C0115", # (missing-class-docstring)
"C0116", # (missing-function-docstring)
"R0903", # (too-few-public-methods)
"R0913", # (too-many-arguments)
"W0105", # (pointless-string-statement)
]
[tool.ruff]
line-length = 88
indent-width = 4
[tool.ruff.lint]
# select = ["E", "F", "W"]
[tool.ruff.format]
docstring-code-line-length = 88
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["uv_build>=0.9.18,<0.12"]
build-backend = "uv_build"
+4 -3
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
pycmx is a parser for CMX 3600-style EDLs.
@@ -7,7 +6,9 @@ copy and reuse this software, refer to the LICENSE file included with the
distribution.
"""
from .edit import Edit
from .event import Event
from .parse_cmx_events import parse_cmx3600
from .transition import Transition
from .event import Event
from .edit import Edit
__all__ = ("Edit", "Event", "Transition", "parse_cmx3600")
+1 -1
View File
@@ -4,7 +4,7 @@
from dataclasses import dataclass
from typing import Generic, NamedTuple, TypeVar
T = TypeVar('T')
T = TypeVar("T")
@dataclass
@@ -1,8 +1,10 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from re import (compile, match)
from typing import Dict, Tuple, Generator
from __future__ import annotations
from re import compile, match
from typing import Generator
class ChannelMap:
@@ -10,35 +12,33 @@ class ChannelMap:
Represents a set of all the channels to which an event applies.
"""
_chan_map: Dict[str, Tuple] = {
"V": (True, False, False),
"A": (False, True, False),
"A2": (False, False, True),
"AA": (False, True, True),
"B": (True, True, False),
"AA/V": (True, True, True),
"A2/V": (True, False, True)
}
def __init__(self, v=False, audio_channels=set()):
self._audio_channel_set = audio_channels
def __init__(self, v=False, audio_channels=None):
self._audio_channel_set = audio_channels or set()
self.v = v
self._chan_map: dict[str, tuple] = {
"V": (True, False, False),
"A": (False, True, False),
"A2": (False, False, True),
"AA": (False, True, True),
"B": (True, True, False),
"AA/V": (True, True, True),
"A2/V": (True, False, True),
}
@property
def video(self) -> bool:
'True if video is included'
"True if video is included"
return self.v
@property
def audio(self) -> bool:
'True if an audio channel is included'
"True if an audio channel is included"
return len(self._audio_channel_set) > 0
@property
def channels(self) -> Generator[int, None, None]:
'A generator for each audio channel'
for c in self._audio_channel_set:
yield c
"A generator for each audio channel"
yield from self._audio_channel_set
@property
def a1(self) -> bool:
@@ -78,7 +78,7 @@ class ChannelMap:
def get_audio_channel(self, chan_num) -> bool:
"""True if chan_num is included"""
return (chan_num in self._audio_channel_set)
return chan_num in self._audio_channel_set
def set_audio_channel(self, chan_num, enabled: bool):
"""If enabled is true, chan_num will be included"""
@@ -88,7 +88,7 @@ class ChannelMap:
self._audio_channel_set.remove(chan_num)
def _append_event(self, event_str):
alt_channel_re = compile(r'^A(\d+)')
alt_channel_re = compile(r"^A(\d+)")
if event_str in self._chan_map:
channels = self._chan_map[event_str]
self.v = channels[0]
+31 -34
View File
@@ -1,21 +1,21 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from __future__ import annotations
from .cdl import AscSopComponents, FramecountTriple
from .channel_map import ChannelMap
from .statements import (
StmtAudioExt,
StmtCdlSat,
StmtCdlSop,
StmtFrmc,
StmtEvent,
StmtAudioExt,
StmtClipName,
StmtSourceFile,
StmtEffectsName,
StmtEvent,
StmtFrmc,
StmtSourceFile,
)
from .transition import Transition
from .channel_map import ChannelMap
from typing import Optional
class Edit:
@@ -27,32 +27,29 @@ class Edit:
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,
audio_ext_statement: StmtAudioExt | None,
clip_name_statement: StmtClipName | None,
source_file_statement: StmtSourceFile | None,
trans_name_statement: StmtEffectsName | None = None,
asc_sop_statement: StmtCdlSop | None = None,
asc_sat_statement: StmtCdlSat | None = None,
frmc_statement: StmtFrmc | None = None,
) -> None:
# Assigning types for the attributes explicitly
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
self._audio_ext: StmtAudioExt | None = audio_ext_statement
self._clip_name_statement: StmtClipName | None = clip_name_statement
self._source_file_statement: StmtSourceFile | None = source_file_statement
self._trans_name_statement: StmtEffectsName | None = trans_name_statement
self._asc_sop_statement: StmtCdlSop | None = asc_sop_statement
self._asc_sat_statement: StmtCdlSat | None = asc_sat_statement
self._frmc_statement: StmtFrmc | None = 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.
this edit. Line numbers a zero-indexed, such that the "TITLE:" record
is line zero.
"""
return self._edit_statement.line_number
@@ -139,7 +136,7 @@ class Edit:
return self.source == "AX"
@property
def source_file(self) -> Optional[str]:
def source_file(self) -> str | None:
"""
Get the source file, as attested by a "* SOURCE FILE" remark on the
EDL. This will return None if the information is not present.
@@ -150,7 +147,7 @@ class Edit:
return self._source_file_statement.filename
@property
def clip_name(self) -> Optional[str]:
def clip_name(self) -> str | None:
"""
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
@@ -162,7 +159,7 @@ class Edit:
return self._clip_name_statement.name
@property
def asc_sop(self) -> Optional[AscSopComponents[float]]:
def asc_sop(self) -> AscSopComponents[float] | None:
"""
Get ASC CDL Slope-Offset-Power color transfer function for the edit,
if present. The ASC SOP is a transfer function of the form:
@@ -178,9 +175,9 @@ class Edit:
return self._asc_sop_statement.cdl_sop
@property
def asc_sop_raw(self) -> Optional[str]:
def asc_sop_raw(self) -> str | None:
"""
ASC CDL Slope-Offset-Power statement raw line
ASC CDL Slope-Offset-Power statement raw line.
"""
if self._asc_sop_statement is None:
return None
@@ -188,9 +185,9 @@ class Edit:
return self._asc_sop_statement.line
@property
def asc_sat(self) -> Optional[float]:
def asc_sat(self) -> float | None:
"""
Get ASC CDL saturation value for clip, if present
Get ASC CDL saturation value for clip, if present.
"""
if self._asc_sat_statement is None:
return None
@@ -198,7 +195,7 @@ class Edit:
return self._asc_sat_statement.value
@property
def framecounts(self) -> Optional[FramecountTriple]:
def framecounts(self) -> FramecountTriple | None:
"""
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
+22 -14
View File
@@ -1,21 +1,28 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from .statements import (StmtCorruptRemark, StmtTitle, StmtEvent,
StmtUnrecognized, StmtSourceUMID)
from .event import Event
from .channel_map import ChannelMap
from __future__ import annotations
from typing import Any, Generator
from .channel_map import ChannelMap
from .event import Event
from .statements import (
StmtCorruptRemark,
StmtEvent,
StmtSourceUMID,
StmtTitle,
StmtUnrecognized,
)
class EditList:
"""
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.event_statements = statements[1:]
@@ -28,19 +35,20 @@ class EditList:
Adobe EDLs with more than 999 events will be reported as "3600".
"""
first_event = next(
(s for s in self.event_statements if type(s) is StmtEvent), None)
(s for s in self.event_statements if type(s) is StmtEvent), None
)
if first_event:
if first_event.source_field_size == 8:
return '3600'
return "3600"
elif first_event.source_field_size == 32:
return 'File32'
return "File32"
elif first_event.source_field_size == 128:
return 'File128'
return "File128"
else:
return 'unknown'
return "unknown"
else:
return 'unknown'
return "unknown"
@property
def channels(self) -> ChannelMap:
@@ -72,12 +80,12 @@ class EditList:
:class:`StmtCorruptRemark`
"""
for s in self.event_statements:
if type(s) is StmtUnrecognized or type(s) in StmtCorruptRemark:
if type(s) is StmtUnrecognized or type(s) is StmtCorruptRemark:
yield s
@property
def events(self) -> Generator[Event, None, None]:
'A generator for all the events in the edit list'
"A generator for all the events in the edit list"
current_event_num = None
event_statements = []
for stmt in self.event_statements:
+157
View File
@@ -0,0 +1,157 @@
# pycmx
# (c) 2023-2025 Jamie Hardt
from __future__ import annotations
from typing import Any, Generator
from .edit import Edit
from .statements import (
StmtAudioExt,
StmtCdlSat,
StmtCdlSop,
StmtClipName,
StmtEffectsName,
StmtEvent,
StmtFrmc,
StmtSourceFile,
StmtUnrecognized,
)
class Event:
"""
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
event number."""
def __init__(self, statements):
self.statements = statements
@property
def number(self) -> int:
"""
Return the event number.
"""
return int(self._edit_statements()[0].event)
@property
def edits(self) -> list[Edit]:
"""
Returns the edits. Most events will have a single edit, a single event
will have multiple edits when a dissolve, wipe or key transition needs
to be performed.
"""
# FTR this is a totall bonkers way of doing this, I wrote this when
# I was still learning Python and I'm sure there's easier ways to do
# it. The job is complicated because multiple edits can occur in one
# event and then other statements can modify the event in different
# ways.
edits_audio = list(self._statements_with_audio_ext())
clip_names = self._clip_name_statements()
source_files = self._source_file_statements()
# We first get the edit events combined with their extra audio
# channel statements, if any.
# The list the_zip contains one element for each initialization
# parameter in Edit()
the_zip: list[list[Any]] = [edits_audio]
# If there are two Clip Name statements and two edits, we look for
# "FROM" and "TO" clip name lines. Otherwise we just look for on
# each per edit.
if len(edits_audio) == 2:
start_name: StmtClipName | None = None
end_name: StmtClipName | None = None
for clip_name in clip_names:
if clip_name.affect == "from":
start_name = clip_name
elif clip_name.affect == "to":
end_name = clip_name
the_zip.append([start_name, end_name])
else:
if len(edits_audio) == len(clip_names):
the_zip.append(clip_names)
else:
the_zip.append([None] * len(edits_audio))
# if there's one source file statemnent per clip, we allocate them to
# each edit in order. Otherwise if there's only one, we assign the one
# to all the edits. If there's no source_file statements, we provide
# None.
if len(edits_audio) == len(source_files):
the_zip.append(source_files)
elif len(source_files) == 1:
the_zip.append(source_files * len(edits_audio))
else:
the_zip.append([None] * len(edits_audio))
# attach effects name to last event
try:
trans_statement = self._trans_name_statements()[0]
trans_names: list[Any] = [None] * (len(edits_audio) - 1)
trans_names.append(trans_statement)
the_zip.append(trans_names)
except IndexError:
the_zip.append([None] * len(edits_audio))
return [
Edit(
edit_statement=e1[0],
audio_ext_statement=e1[1],
clip_name_statement=n1,
source_file_statement=s1,
trans_name_statement=u1,
asc_sop_statement=self._asc_sop_statement(),
asc_sat_statement=self._asc_sat_statement(),
frmc_statement=self._frmc_statement(),
)
for (e1, n1, s1, u1) in zip(*the_zip)
]
@property
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, None]:
"""
A generator for all the unrecognized statements in the event.
"""
for s in self.statements:
if type(s) is StmtUnrecognized:
yield s
def _trans_name_statements(self) -> list[StmtEffectsName]:
return [s for s in self.statements if type(s) is StmtEffectsName]
def _edit_statements(self) -> list[StmtEvent]:
return [s for s in self.statements if type(s) is StmtEvent]
def _clip_name_statements(self) -> list[StmtClipName]:
return [s for s in self.statements if type(s) is StmtClipName]
def _source_file_statements(self) -> list[StmtSourceFile]:
return [s for s in self.statements if type(s) is StmtSourceFile]
def _statements_with_audio_ext(
self,
) -> Generator[tuple[StmtEvent, StmtAudioExt | None], None, None]:
if len(self.statements) == 1 and type(self.statements[0]) is StmtEvent:
yield (self.statements[0], 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) -> StmtCdlSop | None:
return next((s for s in self.statements if type(s) is StmtCdlSop), None)
def _asc_sat_statement(self) -> StmtCdlSat | None:
return next((s for s in self.statements if type(s) is StmtCdlSat), None)
def _frmc_statement(self) -> StmtFrmc | None:
return next((s for s in self.statements if type(s) is StmtFrmc), None)
+20
View File
@@ -0,0 +1,20 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from typing import TextIO
from .edit_list import EditList
from .parse_cmx_statements import parse_cmx3600_statements
def parse_cmx3600(f: TextIO, tolerant: bool = False) -> EditList:
"""
Parse a CMX 3600 EDL.
: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`.
"""
statements = parse_cmx3600_statements(f, tolerant)
return EditList(statements)
+317
View File
@@ -0,0 +1,317 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from __future__ import annotations
import re
from typing import TextIO
from .cdl import AscSopComponents, Rgb
from .statements import (
StmtAudioExt,
StmtCdlSat,
StmtCdlSop,
StmtClipName,
StmtCorruptRemark,
StmtEffectsName,
StmtEvent,
StmtFCM,
StmtFrmc,
StmtRemark,
StmtSourceFile,
StmtSplitEdit,
StmtTitle,
StmtUnrecognized,
)
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=int(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)
...
@@ -5,8 +5,6 @@ from typing import Any, NamedTuple
from .cdl import AscSopComponents
# type str = str
class StmtTitle(NamedTuple):
title: str
@@ -1,7 +1,7 @@
# pycmx
# (c) 2023 Jamie Hardt
from typing import Optional
from __future__ import annotations
class Transition:
@@ -22,9 +22,9 @@ class Transition:
self.name = name
@property
def kind(self) -> Optional[str]:
def kind(self) -> str | None:
"""
Return the kind of transition: Cut, Wipe, etc
Return the kind of transition: Cut, Wipe, etc.
"""
if self.cut:
return Transition.Cut
@@ -42,28 +42,29 @@ class Transition:
@property
def cut(self) -> bool:
"`True` if this transition is a cut."
return self.transition == 'C'
return self.transition == "C"
@property
def dissolve(self) -> bool:
"`True` if this traansition is a dissolve."
return self.transition == 'D'
return self.transition == "D"
@property
def wipe(self) -> bool:
"`True` if this transition is a wipe."
return self.transition.startswith('W')
return self.transition.startswith("W")
@property
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.
"""
return int(self.operand)
@property
def wipe_number(self) -> Optional[int]:
def wipe_number(self) -> int | None:
"Wipes are identified by a particular number."
if self.wipe:
return int(self.transition[1:])
+4 -1
View File
@@ -3,7 +3,10 @@
# Utility functions
def collimate(a_string, column_widths):
from __future__ import annotations
def collimate(a_string, column_widths) -> list[str]:
"""
Split a list-type thing, like a string, into slices that are column_widths
length.
-1
View File
@@ -1 +0,0 @@
from . import test_parse
+39
View 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
+26
View File
@@ -0,0 +1,26 @@
# ruff: noqa: SIM115
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()
+44 -47
View File
@@ -5,64 +5,65 @@ import pycmx
class TestParse(TestCase):
files = ["INS4_R1_010417.edl",
"INS4_R1_DX_092117.edl",
"STP R1 v082517.edl",
"ToD_R4_LOCK3.1_030618_Video.edl",
"TEST.edl",
"test_edl_cdl.edl",
"INS4_R1_DX_092117.edl"
]
def setUp(self) -> None:
self.files = [
"INS4_R1_010417.edl",
"INS4_R1_DX_092117.edl",
"STP R1 v082517.edl",
"ToD_R4_LOCK3.1_030618_Video.edl",
"TEST.edl",
"test_edl_cdl.edl",
"INS4_R1_DX_092117.edl",
]
return super().setUp()
def test_event_counts(self):
counts = [287, 466, 250, 376, 120, 3, 466]
for fn, count in zip(type(self).files, counts):
with open("tests/edls/" + fn, 'r') as f:
for fn, count in zip(self.files, counts):
with open("tests/edls/" + fn, "r") as f:
edl = pycmx.parse_cmx3600(f)
actual = len(list(edl.events))
self.assertTrue(actual == count,
"expected %i in file %s but found %i"
% (count, fn, actual))
self.assertTrue(
actual == count,
f"expected {count} in file {fn} but found {actual}"
)
def test_list_sanity(self):
for fn in type(self).files:
with open("tests/edls/" + fn, 'r') as f:
for fn in self.files:
with open("tests/edls/" + fn, "r") as f:
edl = pycmx.parse_cmx3600(f)
self.assertTrue(type(edl.title) is str)
self.assertTrue(len(edl.title) > 0)
def test_event_sanity(self):
for fn in type(self).files:
for fn in self.files:
path = "tests/edls/" + fn
with open(path, 'r') as f:
with open(path, "r") as f:
edl = pycmx.parse_cmx3600(f)
for index, event in enumerate(edl.events):
self.assertTrue(len(event.edits) > 0,
f"Failed for {path}")
self.assertEqual(event.number, index + 1,
f"Failed for {path}")
self.assertTrue(len(event.edits) > 0, f"Failed for {path}")
self.assertEqual(event.number, index + 1, f"Failed for {path}")
def test_events(self):
with open("tests/edls/TEST.edl", 'r') as f:
with open("tests/edls/TEST.edl", "r") as f:
edl = pycmx.parse_cmx3600(f)
events = list(edl.events)
self.assertEqual(events[0].number, 1)
self.assertEqual(events[0].edits[0].source, "OY_HEAD_")
self.assertEqual(events[0].edits[0].clip_name, "HEAD LEADER MONO")
self.assertEqual(
events[0].edits[0].source_file, "OY_HEAD_LEADER.MOV")
self.assertEqual(events[0].edits[0].source_file, "OY_HEAD_LEADER.MOV")
self.assertEqual(events[0].edits[0].source_in, "00:00:00:00")
self.assertEqual(events[0].edits[0].source_out, "00:00:00:00")
self.assertEqual(events[0].edits[0].record_in, "01:00:00:00")
self.assertEqual(events[0].edits[0].record_out, "01:00:08:00")
self.assertTrue(
events[0].edits[0].transition.kind == pycmx.Transition.Cut)
self.assertTrue(events[0].edits[0].transition.kind == pycmx.Transition.Cut)
def test_channel_map(self):
with open("tests/edls/TEST.edl", 'r') as f:
with open("tests/edls/TEST.edl", "r") as f:
edl = pycmx.parse_cmx3600(f)
events = list(edl.events)
self.assertFalse(events[0].edits[0].channels.video)
@@ -72,7 +73,7 @@ class TestParse(TestCase):
self.assertTrue(events[2].edits[0].channels.audio)
def test_multi_edit_events(self):
with open("tests/edls/TEST.edl", 'r') as f:
with open("tests/edls/TEST.edl", "r") as f:
edl = pycmx.parse_cmx3600(f)
events = list(edl.events)
@@ -80,25 +81,24 @@ class TestParse(TestCase):
self.assertEqual(len(events[42].edits), 2)
self.assertEqual(events[42].edits[0].source, "TC_R1_V1")
self.assertEqual(events[42].edits[0].clip_name,
"TC R1 V1.2 TEMP1 FX ST.WAV")
self.assertEqual(
events[42].edits[0].clip_name, "TC R1 V1.2 TEMP1 FX ST.WAV"
)
self.assertEqual(events[42].edits[0].source_in, "00:00:00:00")
self.assertEqual(events[42].edits[0].source_out, "00:00:00:00")
self.assertEqual(events[42].edits[0].record_in, "01:08:56:09")
self.assertEqual(events[42].edits[0].record_out, "01:08:56:09")
self.assertTrue(
events[42].edits[0].transition.kind == pycmx.Transition.Cut)
self.assertTrue(events[42].edits[0].transition.kind == pycmx.Transition.Cut)
self.assertEqual(events[42].edits[1].source, "TC_R1_V6")
self.assertEqual(events[42].edits[1].clip_name,
"TC R1 V6 TEMP2 ST FX.WAV")
self.assertEqual(events[42].edits[1].clip_name, "TC R1 V6 TEMP2 ST FX.WAV")
self.assertEqual(events[42].edits[1].source_in, "00:00:00:00")
self.assertEqual(events[42].edits[1].source_out, "00:00:00:00")
self.assertEqual(events[42].edits[1].record_in, "01:08:56:09")
self.assertEqual(events[42].edits[1].record_out, "01:08:56:11")
self.assertTrue(
events[42].edits[1].transition.kind ==
pycmx.Transition.Dissolve)
events[42].edits[1].transition.kind == pycmx.Transition.Dissolve
)
def test_line_numbers(self):
with open("tests/edls/ToD_R4_LOCK3.1_030618_Video.edl") as f:
@@ -113,12 +113,10 @@ class TestParse(TestCase):
with open("tests/edls/test_25.edl", "r") as f:
edl = pycmx.parse_cmx3600(f)
events = list(edl.events)
self.assertEqual(
events[4].edits[1].transition.name, "CROSS DISSOLVE")
self.assertEqual(events[4].edits[1].transition.name, "CROSS DISSOLVE")
def test_adobe_wide(self):
with open("tests/edls/adobe_dai109_test.txt", 'r',
encoding='ISO-8859-1') as f:
with open("tests/edls/adobe_dai109_test.txt", "r", encoding="ISO-8859-1") as f:
edl = pycmx.parse_cmx3600(f)
events = list(edl.events)
@@ -131,12 +129,11 @@ class TestParse(TestCase):
for event in edl.events:
if event.number == 42:
self.assertEqual(len(event.edits), 1)
self.assertEqual(event.edits[0].source,
"M018C0005_240925_1F4L13")
self.assertEqual(event.edits[0].transition.kind,
pycmx.Transition.Cut)
self.assertEqual(event.edits[0].source_in,
"18:44:20:12")
self.assertEqual(event.edits[0].source, "M018C0005_240925_1F4L13")
self.assertEqual(
event.edits[0].transition.kind, pycmx.Transition.Cut
)
self.assertEqual(event.edits[0].source_in, "18:44:20:12")
def test_cdl(self):
with open("tests/edls/cdl_example01.edl", "r") as f:
@@ -152,7 +149,7 @@ class TestParse(TestCase):
sat = event.edits[0].asc_sat
self.assertIsNotNone(sat)
assert sat
self.assertEqual(sat, float('0.9640'))
self.assertEqual(sat, float("0.9640"))
def test_frmc(self):
with open("tests/edls/cdl_frmc_example01.edl", "r") as f: