33 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 610d406e97 flake8 2025-12-17 15:13:49 -08:00
jamie 28307608fc Added raw ASC_SOP line method 2025-12-17 15:12:34 -08:00
jamie 9ab3804c89 Removed explicit imports of pycmx 2025-12-17 15:09:05 -08:00
25 changed files with 704 additions and 555 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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }} - name: Set up Python ${{ matrix.python-version }}
@@ -26,13 +26,10 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
python -m pip install flake8 pytest python -m pip install -e .[dev]
python -m pip install -e . - name: Lint with ruff
- name: Lint with flake8
run: | run: |
# stop the build if there are Python syntax errors or undefined names ruff check src/
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
flake8 . --count --max-line-length=79 --statistics
- name: Test with pytest - name: Test with pytest
run: | run: |
pytest pytest
+1
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 # Set the version of Python and other tools you might need
build: build:
os: ubuntu-20.04 os: ubuntu-lts-latest
tools: tools:
python: "3.10" python: "3.13"
# You can also specify other tool versions: jobs:
# nodejs: "16" install:
# rust: "1.55" - pip install --upgrade pip
# golang: "1.17" - pip install --group 'doc' -e .
# Build documentation in the docs/ directory with Sphinx # Build documentation in the docs/ directory with Sphinx
sphinx: sphinx:
@@ -28,5 +28,3 @@ python:
install: install:
- method: pip - method: pip
path: . path: .
extra_requirements:
- doc
+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 If you have EDLs you are having trouble working with becuase of unusual formatting, please send me a copy! Contact us
through [Github]. through [Github].
[github]: https://github.com/iluvcapra/pycmx [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.
+2 -2
View File
@@ -14,7 +14,7 @@ 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 read. Event number field and source name field sizes are determined
dynamically for each statement for a high level of compliance at the expense dynamically for each statement for a high level of compliance at the expense
of strictness. of strictness.
* An more relaxed "tolerant" mode allows parsing of an EDL file where columns * A more relaxed "tolerant" mode allows parsing of an EDL file where columns
use non-standard widths. use non-standard widths.
* Preserves relationship between events and individual edits/clips. * Preserves relationship between events and individual edits/clips.
* Remark or comment fields with common recognized forms are read and * Remark or comment fields with common recognized forms are read and
@@ -37,7 +37,7 @@ The `pycmx` package parses a CMX 3600 EDL and its most most common variations.
### Opening and Parsing EDL Files ### Opening and Parsing EDL Files
``` ```
>>> import pycmx >>> import pycmx
>>> with open("tests/edls/TEST.edl") as f >>> with open("tests/edls/TEST.edl") as f:
... edl = pycmx.parse_cmx3600(f) ... edl = pycmx.parse_cmx3600(f)
... ...
>>> edl.title >>> edl.title
+56 -35
View File
@@ -1,12 +1,11 @@
import pycmx
import re
import argparse import argparse
import logging
import re
import sys import sys
import logging import pycmx
FORMAT = "%(asctime)-15s %(message)s"
FORMAT = '%(asctime)-15s %(message)s'
logging.basicConfig(format=FORMAT) logging.basicConfig(format=FORMAT)
@@ -21,7 +20,7 @@ def all_video_edits(edl):
def get_scene_name(edit, pattern): 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: if edit.clip_name is None:
return None return None
else: else:
@@ -37,16 +36,15 @@ def output_cmx(outfile, out_list):
outfile.write("FCM: NON-DROP FRAME\r\n") outfile.write("FCM: NON-DROP FRAME\r\n")
for i, o in enumerate(out_list): for i, o in enumerate(out_list):
line = '%03i AX V C ' % (i) line = "{i: 03d} AX V C "
line += '00:00:00:00 00:00:00:00 %s %s\r\n' % (o['start'], o['end']) line += f"00:00:00:00 00:00:00:00 {o['start']} {o['end']}\r\n"
outfile.write(line) 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): def output_cols(outfile, out_list):
for o in out_list: for o in out_list:
outfile.write("%-12s\t%-12s\t%s\n" % outfile.write(f"{o['start']:>12}\t{o['end']:>12}\t{o['scene']}")
(o['start'], o['end'], o['scene']))
def scene_list(infile, outfile, out_format, pattern): def scene_list(infile, outfile, out_format, pattern):
@@ -68,15 +66,17 @@ def scene_list(infile, outfile, out_format, pattern):
out_list = [] out_list = []
for group in grouped_edits: for group in grouped_edits:
out_list.append({ out_list.append(
'start': group[0].record_in, {
'end': group[-1].record_out, "start": group[0].record_in,
'scene': get_scene_name(group[0], pattern)} "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) output_cmx(outfile, out_list)
if out_format == 'cols': if out_format == "cols":
output_cols(outfile, out_list) output_cols(outfile, out_list)
else: else:
log.warning(f"Format {out_format} unrecognized. Will use cmx.\n") 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(): def scene_list_cli():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Read video events from an input CMX EDL and output ' description="Read video events from an input CMX EDL and output "
'events merged into scenes.') "events merged into scenes."
parser.add_argument('-o', '--outfile', default=sys.stdout, )
type=argparse.FileType('w'), parser.add_argument(
help='Output file. Default is stdout.') "-o",
parser.add_argument('-f', '--format', default='cmx', type=str, "--outfile",
help='Output format. Options are cols and cmx, cmx ' default=sys.stdout,
'is the default.') type=argparse.FileType("w"),
parser.add_argument('-p', '--pattern', default='V?([A-Z]*[0-9]+)', help="Output file. Default is stdout.",
help='RE pattern for extracting scene name from clip ' )
'name. The default is "V?([A-Z]*[0-9]+)". ' + parser.add_argument(
'This pattern will be matched case-insensitively.') "-f",
parser.add_argument('input_edl', default=sys.stdin, "--format",
type=argparse.FileType('r'), nargs='?', default="cmx",
help='Input file. Default is stdin.') 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() args = parser.parse_args()
infile = args.input_edl infile = args.input_edl
scene_list(infile=infile, outfile=args.outfile, scene_list(
out_format=args.format, pattern=args.pattern) infile=infile,
outfile=args.outfile,
out_format=args.format,
pattern=args.pattern,
)
if __name__ == '__main__': if __name__ == "__main__":
scene_list_cli() scene_list_cli()
+30 -31
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# #
# Configuration file for the Sphinx documentation builder. # Configuration file for the Sphinx documentation builder.
# #
@@ -15,14 +14,15 @@
import importlib import importlib
import os import os
import sys import sys
sys.path.insert(0, os.path.abspath('../..'))
sys.path.insert(0, os.path.abspath("../.."))
# -- Project information ----------------------------------------------------- # -- Project information -----------------------------------------------------
project = u'pycmx' project = "pycmx"
copyright = u'(c) 2018-2025, Jamie Hardt' copyright = "(c) 2018-2025, Jamie Hardt"
author = u'Jamie Hardt' author = "Jamie Hardt"
release = importlib.metadata.version("pycmx") release = importlib.metadata.version("pycmx")
version = release version = release
@@ -40,31 +40,31 @@ version = release
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones. # ones.
extensions = [ extensions = [
'sphinx.ext.autodoc', "sphinx.ext.autodoc",
'sphinx.ext.todo', "sphinx.ext.todo",
'sphinx.ext.coverage', "sphinx.ext.coverage",
'sphinx.ext.viewcode', "sphinx.ext.viewcode",
'sphinx.ext.githubpages', "sphinx.ext.githubpages",
] ]
# Add any paths that contain templates here, relative to this directory. # Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates'] templates_path = ["_templates"]
# The suffix(es) of source filenames. # The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string: # You can specify multiple suffix as a list of string:
# #
# source_suffix = ['.rst', '.md'] # source_suffix = ['.rst', '.md']
source_suffix = '.rst' source_suffix = ".rst"
# The master toctree document. # The master toctree document.
master_doc = 'index' master_doc = "index"
# The language for content autogenerated by Sphinx. Refer to documentation # The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages. # for a list of supported languages.
# #
# This is also used if you do content translation via gettext catalogs. # This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases. # 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 # List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files. # 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 # The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes. # 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 # 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 # further. For a list of options available for each theme, see the
# documentation. # 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, # 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, # relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css". # 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 # Custom sidebar templates, must be a dictionary that maps document names
# to template names. # to template names.
@@ -107,7 +107,7 @@ html_static_path = ['_static']
# -- Options for HTMLHelp output --------------------------------------------- # -- Options for HTMLHelp output ---------------------------------------------
# Output file base name for HTML help builder. # Output file base name for HTML help builder.
htmlhelp_basename = 'pycmxdoc' htmlhelp_basename = "pycmxdoc"
# -- Options for LaTeX output ------------------------------------------------ # -- Options for LaTeX output ------------------------------------------------
@@ -116,15 +116,12 @@ latex_elements = {
# The paper size ('letterpaper' or 'a4paper'). # The paper size ('letterpaper' or 'a4paper').
# #
# 'papersize': 'letterpaper', # 'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt'). # The font size ('10pt', '11pt' or '12pt').
# #
# 'pointsize': '10pt', # 'pointsize': '10pt',
# Additional stuff for the LaTeX preamble. # Additional stuff for the LaTeX preamble.
# #
# 'preamble': '', # 'preamble': '',
# Latex figure (float) alignment # Latex figure (float) alignment
# #
# 'figure_align': 'htbp', # 'figure_align': 'htbp',
@@ -134,8 +131,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
(master_doc, 'pycmx.tex', u'pycmx Documentation', (master_doc, "pycmx.tex", "pycmx Documentation", "Jamie Hardt", "manual"),
u'Jamie Hardt', 'manual'),
] ]
@@ -143,10 +139,7 @@ latex_documents = [
# One entry per manual page. List of tuples # One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section). # (source start file, name, description, authors, manual section).
man_pages = [ man_pages = [(master_doc, "pycmx", "pycmx Documentation", [author], "3p")]
(master_doc, 'pycmx', u'pycmx Documentation',
[author], "3p")
]
# -- Options for Texinfo output ---------------------------------------------- # -- Options for Texinfo output ----------------------------------------------
@@ -155,9 +148,15 @@ man_pages = [
# (source start file, target name, title, author, # (source start file, target name, title, author,
# dir menu entry, description, category) # dir menu entry, description, category)
texinfo_documents = [ texinfo_documents = [
(master_doc, 'pycmx', u'pycmx Documentation', (
author, 'pycmx', 'One line description of project.', master_doc,
'Miscellaneous'), "pycmx",
"pycmx Documentation",
author,
"pycmx",
"One line description of project.",
"Miscellaneous",
),
] ]
@@ -176,7 +175,7 @@ epub_title = project
# epub_uid = '' # epub_uid = ''
# A list of files that should not be packed into the epub file. # 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 ------------------------------------------------- # -- Extension configuration -------------------------------------------------
+1 -1
View File
@@ -16,7 +16,7 @@ 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 read. Event number field and source name field sizes are determined
dynamically for each statement for a high level of compliance at the expense dynamically for each statement for a high level of compliance at the expense
of strictness. of strictness.
* An more relaxed "tolerant" mode allows parsing of an EDL file where columns * A more relaxed "tolerant" mode allows parsing of an EDL file where columns
use non-standard widths. use non-standard widths.
* Preserves relationship between events and individual edits/clips. * Preserves relationship between events and individual edits/clips.
* Remark or comment fields with common recognized forms are read and * Remark or comment fields with common recognized forms are read and
-263
View File
@@ -1,263 +0,0 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
import re
from typing import TextIO, List
from pycmx.cdl import AscSopComponents, Rgb
from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
StmtAudioExt, StmtClipName, StmtEffectsName,
StmtEvent, StmtSourceFile, StmtSplitEdit)
from .util import collimate
def parse_cmx3600_statements(file: TextIO,
tolerant: bool = False) -> List[object]:
"""
Return a list of every statement in the file argument.
"""
lines = file.readlines()
return [_parse_cmx3600_line(line.strip(), line_number, tolerant)
for (line_number, line) in enumerate(lines)]
def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
return [event_field_length, 2, source_field_length, 1,
4, 2, # chans
4, 1, # trans
3, 1, # trans op
11, 1,
11, 1,
11, 1,
11]
# def _edl_m2_column_widths():
# return [2, # "M2"
# 3,3, #
# 8,8,1,4,2,1,4,13,3,1,1]
def _parse_cmx3600_line(line: str, line_number: int,
tolerant: bool = False) -> object:
"""
Parses a single CMX EDL line.
:param line: A single EDL line.
:param line_number: The index of this line in the file.
"""
event_num_p = re.compile(r"^(\d+) ")
line_matcher = event_num_p.match(line)
if line.startswith("TITLE:"):
return _parse_title(line, line_number)
if line.startswith("FCM:"):
return _parse_fcm(line, line_number)
if line_matcher is not None:
event_field_len = len(line_matcher.group(1))
source_field_len = len(line) - (event_field_len + 65)
try:
return _parse_columns_for_standard_form(line, event_field_len,
source_field_len,
line_number)
except EventFormError:
if tolerant:
return _parse_columns_tolerant(line, line_number)
else:
return StmtUnrecognized(line, line_number)
if line.startswith("AUD"):
return _parse_extended_audio_channels(line, line_number)
if line.startswith("*"):
return _parse_remark(line[1:].strip(), line_number)
if line.startswith(">>> SOURCE"):
return _parse_source_umid_statement(line, line_number)
if line.startswith("EFFECTS NAME IS"):
return _parse_effects_name(line, line_number)
if line.startswith("SPLIT:"):
return _parse_split(line, line_number)
if line.startswith("M2"):
pass
# return _parse_motion_memory(line, line_number)
return _parse_unrecognized(line, line_number)
def _parse_title(line, line_num) -> StmtTitle:
title = line[6:].strip()
return StmtTitle(title=title, line_number=line_num)
def _parse_fcm(line, line_num) -> StmtFCM:
val = line[4:].strip()
if val == "DROP FRAME":
return StmtFCM(drop=True, line_number=line_num)
return StmtFCM(drop=False, line_number=line_num)
def _parse_extended_audio_channels(line, line_number):
content = line.strip()
audio3 = "3" in content
audio4 = "4" in content
if audio3 or audio4:
return StmtAudioExt(audio3, audio4, line_number)
else:
return StmtUnrecognized(line, line_number)
def _parse_remark(line, line_number) -> object:
if line.startswith("FROM CLIP NAME:"):
return StmtClipName(name=line[15:].strip(), affect="from",
line_number=line_number)
elif line.startswith("TO CLIP NAME:"):
return StmtClipName(name=line[13:].strip(), affect="to",
line_number=line_number)
elif line.startswith("SOURCE FILE:"):
return StmtSourceFile(filename=line[12:].strip(),
line_number=line_number)
elif line.startswith("ASC_SOP"):
group_patterns: list[str] = re.findall(r'\((.*?)\)', line)
v1: list[list[tuple[str, str]]] = \
[re.findall(r'(-?\d+(\.\d+)?)', a) for a in group_patterns]
v: list[list[str]] = [[a[0] for a in b] for b in v1]
if len(v) != 3 or any([len(a) != 3 for a in v]):
return StmtRemark(line, line_number)
else:
try:
return StmtCdlSop(cdl_sop=AscSopComponents(
slope=Rgb(red=float(v[0][0]), green=float(v[0][1]),
blue=float(v[0][2])),
offset=Rgb(red=float(v[1][0]), green=float(v[1][1]),
blue=float(v[1][2])),
power=Rgb(red=float(v[2][0]), green=float(v[2][1]),
blue=float(v[2][2]))
),
line_number=line_number)
except ValueError as e:
return StmtCorruptRemark('ASC_SOP', e, line_number)
elif line.startswith("ASC_SAT"):
value = re.findall(r'(-?\d+(\.\d+)?)', line)
if len(value) != 1:
return StmtRemark(line, line_number)
else:
try:
return StmtCdlSat(value=float(value[0][0]),
line_number=line_number)
except ValueError as e:
return StmtCorruptRemark('ASC_SAT', e, line_number)
elif line.startswith("FRMC"):
match = re.match(r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)'
r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE)
if match is None:
return StmtCorruptRemark('FRMC', None, line_number)
else:
try:
return StmtFrmc(start=int(match.group(1)),
end=int(match.group(2)),
duration=int(match.group(3)),
line_number=line_number)
except ValueError as e:
return StmtCorruptRemark('FRMC', e, line_number)
else:
return StmtRemark(text=line, line_number=line_number)
def _parse_effects_name(line, line_number) -> StmtEffectsName:
name = line[16:].strip()
return StmtEffectsName(name=name, line_number=line_number)
def _parse_split(line: str, line_number):
split_type = line[10:21]
is_video = split_type.startswith("VIDEO")
split_delay = line[24:35]
return StmtSplitEdit(video=is_video, delay=split_delay,
line_number=line_number)
# def _parse_motion_memory(line, line_number):
# return StmtMotionMemory(source="", fps="")
#
class EventFormError(RuntimeError):
pass
def _parse_unrecognized(line, line_number):
return StmtUnrecognized(content=line, line_number=line_number)
def _parse_columns_for_standard_form(line: str, event_field_length: int,
source_field_length: int,
line_number: int):
# breakpoint()
col_widths = _edl_column_widths(event_field_length, source_field_length)
if sum(col_widths) > len(line):
raise EventFormError()
column_strings = collimate(line, col_widths)
channels = column_strings[4].strip()
trans = column_strings[6].strip()
if len(channels) == 0 or len(trans) == 0:
raise EventFormError()
return StmtEvent(event=column_strings[0],
source=column_strings[2].strip(),
channels=channels,
trans=trans,
trans_op=column_strings[8].strip(),
source_in=column_strings[10].strip(),
source_out=column_strings[12].strip(),
record_in=column_strings[14].strip(),
record_out=column_strings[16].strip(),
line_number=line_number,
source_field_size=source_field_length)
def _parse_columns_tolerant(line: str, line_number: int):
pattern = re.compile(r'^\s*(\d+)\s+(.{8,128}?)\s+'
r'(V|A|A2|AA|NONE|AA/V|A2/V|B)\s+'
r'(C|D|W|KB|K|KO)\s+(\d*)\s+(\d\d.\d\d.\d\d.\d\d)\s'
r'(\d\d.\d\d.\d\d.\d\d)\s(\d\d.\d\d.\d\d.\d\d)\s'
r'(\d\d.\d\d.\d\d.\d\d)'
)
match = pattern.match(line)
if match:
return StmtEvent(event=int(match.group(1)), source=match.group(2),
channels=match.group(3), trans=match.group(4),
trans_op=match.group(5), source_in=match.group(6),
source_out=match.group(7), record_in=match.group(8),
record_out=match.group(9), line_number=line_number,
source_field_size=len(match.group(2)))
else:
return StmtUnrecognized(line, line_number)
def _parse_source_umid_statement(line, line_number):
# trimmed = line[3:].strip()
# return StmtSourceUMID(name=None, umid=None, line_number=line_number)
...
+36 -28
View File
@@ -1,18 +1,18 @@
[tool.poetry] [project]
name = "pycmx" name = "pycmx"
version = "1.4.0" version = "1.5.1"
description = "Python CMX 3600 Edit Decision List Parser" description = "Python CMX 3600 Edit Decision List Parser"
authors = ["Jamie Hardt <jamiehardt@me.com>"] authors = [{name = "Jamie Hardt", email= "jamiehardt@me.com"}]
license = "MIT" license-files = ["LICENSE"]
readme = "README.md" readme = "README.md"
keywords = [ keywords = [
'parser', 'parser',
'film', 'film',
'broadcast' 'broadcast'
] ]
requires-python = '>3.8'
classifiers = [ classifiers = [
'Development Status :: 5 - Production/Stable', 'Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: MIT License',
'Topic :: Multimedia', 'Topic :: Multimedia',
'Topic :: Multimedia :: Video', 'Topic :: Multimedia :: Video',
'Topic :: Text Processing', 'Topic :: Text Processing',
@@ -21,36 +21,44 @@ classifiers = [
'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: 3.12',
'Programming Language :: Python :: 3.13' 'Programming Language :: Python :: 3.13',
'Programming Language :: Python :: 3.14'
] ]
homepage = "https://github.com/iluvcapra/pycmx"
documentation = "https://pycmx.readthedocs.io/"
repository = "https://github.com/iluvcapra/pycmx.git"
urls.Tracker = "https://github.com/iluvcapra/pycmx/issues"
[tool.poetry.extras] [project.optional-dependencies]
doc = [
'sphinx >= 5.3.0',
'sphinx_rtd_theme >= 1.1.1',
]
dev = [
'pytest',
'ruff>=0.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'] doc = ['sphinx', 'sphinx_rtd_theme']
[tool.poetry.dependencies]
python = "^3.8"
sphinx = { version='>= 5.3.0', optional=true}
sphinx_rtd_theme = {version ='>= 1.1.1', optional=true}
[tool.pyright] [tool.pyright]
typeCheckingMode = "basic" typeCheckingMode = "basic"
[tool.pylint] [tool.ruff]
max-line-length = 88 line-length = 88
disable = [ indent-width = 4
"C0103", # (invalid-name)
"C0114", # (missing-module-docstring) [tool.ruff.lint]
"C0115", # (missing-class-docstring) # select = ["E", "F", "W"]
"C0116", # (missing-function-docstring)
"R0903", # (too-few-public-methods) [tool.ruff.format]
"R0913", # (too-many-arguments) docstring-code-line-length = 88
"W0105", # (pointless-string-statement)
]
[build-system] [build-system]
requires = ["poetry-core"] requires = ["uv_build>=0.9.18,<0.12"]
build-backend = "poetry.core.masonry.api" build-backend = "uv_build"
+4 -3
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
""" """
pycmx is a parser for CMX 3600-style EDLs. 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. distribution.
""" """
from .edit import Edit
from .event import Event
from .parse_cmx_events import parse_cmx3600 from .parse_cmx_events import parse_cmx3600
from .transition import Transition 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 dataclasses import dataclass
from typing import Generic, NamedTuple, TypeVar from typing import Generic, NamedTuple, TypeVar
T = TypeVar('T') T = TypeVar("T")
@dataclass @dataclass
@@ -1,8 +1,10 @@
# pycmx # pycmx
# (c) 2018-2025 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from re import (compile, match) from __future__ import annotations
from typing import Dict, Tuple, Generator
from re import compile, match
from typing import Generator
class ChannelMap: class ChannelMap:
@@ -10,35 +12,33 @@ class ChannelMap:
Represents a set of all the channels to which an event applies. Represents a set of all the channels to which an event applies.
""" """
_chan_map: Dict[str, Tuple] = { 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), "V": (True, False, False),
"A": (False, True, False), "A": (False, True, False),
"A2": (False, False, True), "A2": (False, False, True),
"AA": (False, True, True), "AA": (False, True, True),
"B": (True, True, False), "B": (True, True, False),
"AA/V": (True, True, True), "AA/V": (True, True, True),
"A2/V": (True, False, True) "A2/V": (True, False, True),
} }
def __init__(self, v=False, audio_channels=set()):
self._audio_channel_set = audio_channels
self.v = v
@property @property
def video(self) -> bool: def video(self) -> bool:
'True if video is included' "True if video is included"
return self.v return self.v
@property @property
def audio(self) -> bool: 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 return len(self._audio_channel_set) > 0
@property @property
def channels(self) -> Generator[int, None, None]: def channels(self) -> Generator[int, None, None]:
'A generator for each audio channel' "A generator for each audio channel"
for c in self._audio_channel_set: yield from self._audio_channel_set
yield c
@property @property
def a1(self) -> bool: def a1(self) -> bool:
@@ -78,7 +78,7 @@ class ChannelMap:
def get_audio_channel(self, chan_num) -> bool: def get_audio_channel(self, chan_num) -> bool:
"""True if chan_num is included""" """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): def set_audio_channel(self, chan_num, enabled: bool):
"""If enabled is true, chan_num will be included""" """If enabled is true, chan_num will be included"""
@@ -88,7 +88,7 @@ class ChannelMap:
self._audio_channel_set.remove(chan_num) self._audio_channel_set.remove(chan_num)
def _append_event(self, event_str): 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: if event_str in self._chan_map:
channels = self._chan_map[event_str] channels = self._chan_map[event_str]
self.v = channels[0] self.v = channels[0]
+41 -34
View File
@@ -1,21 +1,21 @@
# pycmx # pycmx
# (c) 2018-2025 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from pycmx.cdl import AscSopComponents, FramecountTriple from __future__ import annotations
from pycmx.statements import (
from .cdl import AscSopComponents, FramecountTriple
from .channel_map import ChannelMap
from .statements import (
StmtAudioExt,
StmtCdlSat, StmtCdlSat,
StmtCdlSop, StmtCdlSop,
StmtFrmc,
StmtEvent,
StmtAudioExt,
StmtClipName, StmtClipName,
StmtSourceFile,
StmtEffectsName, StmtEffectsName,
StmtEvent,
StmtFrmc,
StmtSourceFile,
) )
from .transition import Transition from .transition import Transition
from .channel_map import ChannelMap
from typing import Optional
class Edit: class Edit:
@@ -27,32 +27,29 @@ class Edit:
def __init__( def __init__(
self, self,
edit_statement: StmtEvent, edit_statement: StmtEvent,
audio_ext_statement: Optional[StmtAudioExt], audio_ext_statement: StmtAudioExt | None,
clip_name_statement: Optional[StmtClipName], clip_name_statement: StmtClipName | None,
source_file_statement: Optional[StmtSourceFile], source_file_statement: StmtSourceFile | None,
trans_name_statement: Optional[StmtEffectsName] = None, trans_name_statement: StmtEffectsName | None = None,
asc_sop_statement: Optional[StmtCdlSop] = None, asc_sop_statement: StmtCdlSop | None = None,
asc_sat_statement: Optional[StmtCdlSat] = None, asc_sat_statement: StmtCdlSat | None = None,
frmc_statement: Optional[StmtFrmc] = None, frmc_statement: StmtFrmc | None = None,
) -> None: ) -> None:
# Assigning types for the attributes explicitly
self._edit_statement: StmtEvent = edit_statement self._edit_statement: StmtEvent = edit_statement
self._audio_ext: Optional[StmtAudioExt] = audio_ext_statement self._audio_ext: StmtAudioExt | None = audio_ext_statement
self._clip_name_statement: Optional[StmtClipName] = clip_name_statement self._clip_name_statement: StmtClipName | None = clip_name_statement
self._source_file_statement: Optional[StmtSourceFile] = \ self._source_file_statement: StmtSourceFile | None = source_file_statement
source_file_statement self._trans_name_statement: StmtEffectsName | None = trans_name_statement
self._trans_name_statement: Optional[StmtEffectsName] = \ self._asc_sop_statement: StmtCdlSop | None = asc_sop_statement
trans_name_statement self._asc_sat_statement: StmtCdlSat | None = asc_sat_statement
self._asc_sop_statement: Optional[StmtCdlSop] = asc_sop_statement self._frmc_statement: StmtFrmc | None = frmc_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:
""" """
Get the line number for the "standard form" statement associated with Get the line number for the "standard form" statement associated with
this edit. Line numbers a zero-indexed, such that the this edit. Line numbers a zero-indexed, such that the "TITLE:" record
"TITLE:" record is line zero. is line zero.
""" """
return self._edit_statement.line_number return self._edit_statement.line_number
@@ -139,7 +136,7 @@ class Edit:
return self.source == "AX" return self.source == "AX"
@property @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 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.
@@ -150,7 +147,7 @@ class Edit:
return self._source_file_statement.filename return self._source_file_statement.filename
@property @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 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 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 return self._clip_name_statement.name
@property @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, Get ASC CDL Slope-Offset-Power color transfer function for the edit,
if present. The ASC SOP is a transfer function of the form: if present. The ASC SOP is a transfer function of the form:
@@ -178,9 +175,19 @@ class Edit:
return self._asc_sop_statement.cdl_sop return self._asc_sop_statement.cdl_sop
@property @property
def asc_sat(self) -> Optional[float]: def asc_sop_raw(self) -> str | None:
""" """
Get ASC CDL saturation value for clip, if present 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) -> float | None:
"""
Get ASC CDL saturation value for clip, if present.
""" """
if self._asc_sat_statement is None: if self._asc_sat_statement is None:
return None return None
@@ -188,7 +195,7 @@ class Edit:
return self._asc_sat_statement.value return self._asc_sat_statement.value
@property @property
def framecounts(self) -> Optional[FramecountTriple]: def framecounts(self) -> FramecountTriple | None:
""" """
Get frame count offset data, if it exists. If an FRMC statement exists 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 in the EDL for the event it will give an integer frame count for the
+21 -13
View File
@@ -1,18 +1,25 @@
# pycmx # pycmx
# (c) 2018-2025 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from pycmx.statements import (StmtCorruptRemark, StmtTitle, StmtEvent, from __future__ import annotations
StmtUnrecognized, StmtSourceUMID)
from .event import Event
from .channel_map import ChannelMap
from typing import Any, Generator from typing import Any, Generator
from .channel_map import ChannelMap
from .event import Event
from .statements import (
StmtCorruptRemark,
StmtEvent,
StmtSourceUMID,
StmtTitle,
StmtUnrecognized,
)
class EditList: class EditList:
""" """
Represents an entire edit decision list as returned by Represents an entire edit decision list as returned by
:func:`~pycmx.parse_cmx3600()`. :func:`~pycmx.parse_cmx_events.parse_cmx3600()`.
""" """
def __init__(self, statements: list): def __init__(self, statements: list):
@@ -28,19 +35,20 @@ class EditList:
Adobe EDLs with more than 999 events will be reported as "3600". Adobe EDLs with more than 999 events will be reported as "3600".
""" """
first_event = next( 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:
if first_event.source_field_size == 8: if first_event.source_field_size == 8:
return '3600' return "3600"
elif first_event.source_field_size == 32: elif first_event.source_field_size == 32:
return 'File32' return "File32"
elif first_event.source_field_size == 128: elif first_event.source_field_size == 128:
return 'File128' return "File128"
else: else:
return 'unknown' return "unknown"
else: else:
return 'unknown' return "unknown"
@property @property
def channels(self) -> ChannelMap: def channels(self) -> ChannelMap:
@@ -72,12 +80,12 @@ class EditList:
:class:`StmtCorruptRemark` :class:`StmtCorruptRemark`
""" """
for s in self.event_statements: 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 yield s
@property @property
def events(self) -> Generator[Event, None, None]: 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 current_event_num = None
event_statements = [] event_statements = []
for stmt in self.event_statements: for stmt in self.event_statements:
+63 -33
View File
@@ -1,13 +1,21 @@
# pycmx # pycmx
# (c) 2023-2025 Jamie Hardt # (c) 2023-2025 Jamie Hardt
from __future__ import annotations
from typing import Any, Generator
from pycmx.statements import StmtFrmc
from .parse_cmx_statements import (
StmtEvent, StmtClipName, StmtSourceFile, StmtAudioExt, StmtUnrecognized,
StmtEffectsName, StmtCdlSop, StmtCdlSat)
from .edit import Edit from .edit import Edit
from .statements import (
from typing import List, Generator, Optional, Tuple, Any StmtAudioExt,
StmtCdlSat,
StmtCdlSop,
StmtClipName,
StmtEffectsName,
StmtEvent,
StmtFrmc,
StmtSourceFile,
StmtUnrecognized,
)
class Event: class Event:
@@ -26,26 +34,41 @@ class Event:
return int(self._edit_statements()[0].event) return int(self._edit_statements()[0].event)
@property @property
def edits(self) -> List[Edit]: def edits(self) -> list[Edit]:
""" """
Returns the edits. Most events will have a single edit, a single event 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 will have multiple edits when a dissolve, wipe or key transition needs
to be performed. 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()) edits_audio = list(self._statements_with_audio_ext())
clip_names = self._clip_name_statements() clip_names = self._clip_name_statements()
source_files = self._source_file_statements() source_files = self._source_file_statements()
the_zip: List[List[Any]] = [edits_audio] # 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: if len(edits_audio) == 2:
start_name: Optional[StmtClipName] = None start_name: StmtClipName | None = None
end_name: Optional[StmtClipName] = None end_name: StmtClipName | None = None
for clip_name in clip_names: for clip_name in clip_names:
if clip_name.affect == 'from': if clip_name.affect == "from":
start_name = clip_name start_name = clip_name
elif clip_name.affect == 'to': elif clip_name.affect == "to":
end_name = clip_name end_name = clip_name
the_zip.append([start_name, end_name]) the_zip.append([start_name, end_name])
@@ -55,6 +78,10 @@ class Event:
else: else:
the_zip.append([None] * len(edits_audio)) 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): if len(edits_audio) == len(source_files):
the_zip.append(source_files) the_zip.append(source_files)
elif len(source_files) == 1: elif len(source_files) == 1:
@@ -62,27 +89,31 @@ class Event:
else: else:
the_zip.append([None] * len(edits_audio)) the_zip.append([None] * len(edits_audio))
# attach trans name to last event # attach effects name to last event
try: try:
trans_statement = self._trans_name_statements()[0] trans_statement = self._trans_name_statements()[0]
trans_names: List[Optional[Any]] = [None] * (len(edits_audio) - 1) trans_names: list[Any] = [None] * (len(edits_audio) - 1)
trans_names.append(trans_statement) trans_names.append(trans_statement)
the_zip.append(trans_names) the_zip.append(trans_names)
except IndexError: except IndexError:
the_zip.append([None] * len(edits_audio)) the_zip.append([None] * len(edits_audio))
return [Edit(edit_statement=e1[0],
return [
Edit(
edit_statement=e1[0],
audio_ext_statement=e1[1], audio_ext_statement=e1[1],
clip_name_statement=n1, clip_name_statement=n1,
source_file_statement=s1, source_file_statement=s1,
trans_name_statement=u1, trans_name_statement=u1,
asc_sop_statement=self._asc_sop_statement(), asc_sop_statement=self._asc_sop_statement(),
asc_sat_statement=self._asc_sat_statement(), asc_sat_statement=self._asc_sat_statement(),
frmc_statement=self._frmc_statement()) frmc_statement=self._frmc_statement(),
for (e1, n1, s1, u1) in zip(*the_zip)] )
for (e1, n1, s1, u1) in zip(*the_zip)
]
@property @property
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, None]:
None]:
""" """
A generator for all the unrecognized statements in the event. A generator for all the unrecognized statements in the event.
""" """
@@ -90,38 +121,37 @@ class Event:
if type(s) is StmtUnrecognized: if type(s) is StmtUnrecognized:
yield s yield s
def _trans_name_statements(self) -> List[StmtEffectsName]: def _trans_name_statements(self) -> list[StmtEffectsName]:
return [s for s in self.statements if type(s) is StmtEffectsName] return [s for s in self.statements if type(s) is StmtEffectsName]
def _edit_statements(self) -> List[StmtEvent]: def _edit_statements(self) -> list[StmtEvent]:
return [s for s in self.statements if type(s) is StmtEvent] return [s for s in self.statements if type(s) is StmtEvent]
def _clip_name_statements(self) -> List[StmtClipName]: def _clip_name_statements(self) -> list[StmtClipName]:
return [s for s in self.statements if type(s) is StmtClipName] return [s for s in self.statements if type(s) is StmtClipName]
def _source_file_statements(self) -> List[StmtSourceFile]: def _source_file_statements(self) -> list[StmtSourceFile]:
return [s for s in self.statements if type(s) is StmtSourceFile] return [s for s in self.statements if type(s) is StmtSourceFile]
def _statements_with_audio_ext(self) -> Generator[ def _statements_with_audio_ext(
Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]: self,
) -> Generator[tuple[StmtEvent, StmtAudioExt | None], None, None]:
if len(self.statements) == 1 and type(self.statements[0]) is StmtEvent: if len(self.statements) == 1 and type(self.statements[0]) is StmtEvent:
yield (self.statements[0], None) yield (self.statements[0], None)
else: else:
for (s1, s2) in zip(self.statements, self.statements[1:]): for s1, s2 in zip(self.statements, self.statements[1:]):
if type(s1) is StmtEvent and type(s2) is StmtAudioExt: if type(s1) is StmtEvent and type(s2) is StmtAudioExt:
yield (s1, s2) yield (s1, s2)
elif type(s1) is StmtEvent: elif type(s1) is StmtEvent:
yield (s1, None) yield (s1, None)
def _asc_sop_statement(self) -> Optional[StmtCdlSop]: def _asc_sop_statement(self) -> StmtCdlSop | None:
return next((s for s in self.statements if type(s) is StmtCdlSop), return next((s for s in self.statements if type(s) is StmtCdlSop), None)
None)
def _asc_sat_statement(self) -> Optional[StmtCdlSat]: def _asc_sat_statement(self) -> StmtCdlSat | None:
return next((s for s in self.statements if type(s) is StmtCdlSat), return next((s for s in self.statements if type(s) is StmtCdlSat), None)
None)
def _frmc_statement(self) -> Optional[StmtFrmc]: def _frmc_statement(self) -> StmtFrmc | None:
return next((s for s in self.statements if type(s) is StmtFrmc), None) return next((s for s in self.statements if type(s) is StmtFrmc), None)
@@ -3,8 +3,8 @@
from typing import TextIO from typing import TextIO
from .parse_cmx_statements import (parse_cmx3600_statements)
from .edit_list import EditList from .edit_list import EditList
from .parse_cmx_statements import parse_cmx3600_statements
def parse_cmx3600(f: TextIO, tolerant: bool = False) -> EditList: def parse_cmx3600(f: TextIO, tolerant: bool = False) -> EditList:
+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)
...
@@ -3,9 +3,7 @@
from typing import Any, NamedTuple from typing import Any, NamedTuple
from pycmx.cdl import AscSopComponents from .cdl import AscSopComponents
# type str = str
class StmtTitle(NamedTuple): class StmtTitle(NamedTuple):
@@ -50,6 +48,7 @@ class StmtSourceFile(NamedTuple):
class StmtCdlSop(NamedTuple): class StmtCdlSop(NamedTuple):
line: str
cdl_sop: AscSopComponents[float] cdl_sop: AscSopComponents[float]
line_number: int line_number: int
@@ -1,7 +1,7 @@
# pycmx # pycmx
# (c) 2023 Jamie Hardt # (c) 2023 Jamie Hardt
from typing import Optional from __future__ import annotations
class Transition: class Transition:
@@ -22,9 +22,9 @@ class Transition:
self.name = name self.name = name
@property @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: if self.cut:
return Transition.Cut return Transition.Cut
@@ -42,28 +42,29 @@ class Transition:
@property @property
def cut(self) -> bool: def cut(self) -> bool:
"`True` if this transition is a cut." "`True` if this transition is a cut."
return self.transition == 'C' return self.transition == "C"
@property @property
def dissolve(self) -> bool: def dissolve(self) -> bool:
"`True` if this traansition is a dissolve." "`True` if this traansition is a dissolve."
return self.transition == 'D' return self.transition == "D"
@property @property
def wipe(self) -> bool: def wipe(self) -> bool:
"`True` if this transition is a wipe." "`True` if this transition is a wipe."
return self.transition.startswith('W') return self.transition.startswith("W")
@property @property
def effect_duration(self) -> int: def effect_duration(self) -> int:
"""The duration of this transition, in frames of the record target. """
The duration of this transition, in frames of the record target.
In the event of a key event, this is the duration of the fade in. In the event of a key event, this is the duration of the fade in.
""" """
return int(self.operand) return int(self.operand)
@property @property
def wipe_number(self) -> Optional[int]: def wipe_number(self) -> int | None:
"Wipes are identified by a particular number." "Wipes are identified by a particular number."
if self.wipe: if self.wipe:
return int(self.transition[1:]) return int(self.transition[1:])
+4 -1
View File
@@ -3,7 +3,10 @@
# Utility functions # 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 Split a list-type thing, like a string, into slices that are column_widths
length. length.
-2
View File
@@ -1,2 +0,0 @@
from . import test_parse
from . import test_issue_19
+1
View File
@@ -1,3 +1,4 @@
# ruff: noqa: SIM115
from unittest import TestCase from unittest import TestCase
from pycmx import parse_cmx3600 from pycmx import parse_cmx3600
+38 -41
View File
@@ -5,64 +5,65 @@ import pycmx
class TestParse(TestCase): class TestParse(TestCase):
files = ["INS4_R1_010417.edl", def setUp(self) -> None:
self.files = [
"INS4_R1_010417.edl",
"INS4_R1_DX_092117.edl", "INS4_R1_DX_092117.edl",
"STP R1 v082517.edl", "STP R1 v082517.edl",
"ToD_R4_LOCK3.1_030618_Video.edl", "ToD_R4_LOCK3.1_030618_Video.edl",
"TEST.edl", "TEST.edl",
"test_edl_cdl.edl", "test_edl_cdl.edl",
"INS4_R1_DX_092117.edl" "INS4_R1_DX_092117.edl",
] ]
return super().setUp()
def test_event_counts(self): def test_event_counts(self):
counts = [287, 466, 250, 376, 120, 3, 466] counts = [287, 466, 250, 376, 120, 3, 466]
for fn, count in zip(type(self).files, counts): for fn, count in zip(self.files, counts):
with open("tests/edls/" + fn, 'r') as f: with open("tests/edls/" + fn, "r") as f:
edl = pycmx.parse_cmx3600(f) edl = pycmx.parse_cmx3600(f)
actual = len(list(edl.events)) actual = len(list(edl.events))
self.assertTrue(actual == count, self.assertTrue(
"expected %i in file %s but found %i" actual == count,
% (count, fn, actual)) f"expected {count} in file {fn} but found {actual}"
)
def test_list_sanity(self): def test_list_sanity(self):
for fn in type(self).files: for fn in self.files:
with open("tests/edls/" + fn, 'r') as f: with open("tests/edls/" + fn, "r") as f:
edl = pycmx.parse_cmx3600(f) edl = pycmx.parse_cmx3600(f)
self.assertTrue(type(edl.title) is str) self.assertTrue(type(edl.title) is str)
self.assertTrue(len(edl.title) > 0) self.assertTrue(len(edl.title) > 0)
def test_event_sanity(self): def test_event_sanity(self):
for fn in type(self).files: for fn in self.files:
path = "tests/edls/" + fn path = "tests/edls/" + fn
with open(path, 'r') as f: with open(path, "r") as f:
edl = pycmx.parse_cmx3600(f) edl = pycmx.parse_cmx3600(f)
for index, event in enumerate(edl.events): for index, event in enumerate(edl.events):
self.assertTrue(len(event.edits) > 0, self.assertTrue(len(event.edits) > 0, f"Failed for {path}")
f"Failed for {path}") self.assertEqual(event.number, index + 1, f"Failed for {path}")
self.assertEqual(event.number, index + 1,
f"Failed for {path}")
def test_events(self): 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) edl = pycmx.parse_cmx3600(f)
events = list(edl.events) events = list(edl.events)
self.assertEqual(events[0].number, 1) self.assertEqual(events[0].number, 1)
self.assertEqual(events[0].edits[0].source, "OY_HEAD_") 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].clip_name, "HEAD LEADER MONO")
self.assertEqual( self.assertEqual(events[0].edits[0].source_file, "OY_HEAD_LEADER.MOV")
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_in, "00:00:00:00")
self.assertEqual(events[0].edits[0].source_out, "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_in, "01:00:00:00")
self.assertEqual(events[0].edits[0].record_out, "01:00:08:00") self.assertEqual(events[0].edits[0].record_out, "01:00:08:00")
self.assertTrue( self.assertTrue(events[0].edits[0].transition.kind == pycmx.Transition.Cut)
events[0].edits[0].transition.kind == pycmx.Transition.Cut)
def test_channel_map(self): 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) edl = pycmx.parse_cmx3600(f)
events = list(edl.events) events = list(edl.events)
self.assertFalse(events[0].edits[0].channels.video) self.assertFalse(events[0].edits[0].channels.video)
@@ -72,7 +73,7 @@ class TestParse(TestCase):
self.assertTrue(events[2].edits[0].channels.audio) self.assertTrue(events[2].edits[0].channels.audio)
def test_multi_edit_events(self): 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) edl = pycmx.parse_cmx3600(f)
events = list(edl.events) events = list(edl.events)
@@ -80,25 +81,24 @@ class TestParse(TestCase):
self.assertEqual(len(events[42].edits), 2) self.assertEqual(len(events[42].edits), 2)
self.assertEqual(events[42].edits[0].source, "TC_R1_V1") self.assertEqual(events[42].edits[0].source, "TC_R1_V1")
self.assertEqual(events[42].edits[0].clip_name, self.assertEqual(
"TC R1 V1.2 TEMP1 FX ST.WAV") 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_in, "00:00:00:00")
self.assertEqual(events[42].edits[0].source_out, "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_in, "01:08:56:09")
self.assertEqual(events[42].edits[0].record_out, "01:08:56:09") self.assertEqual(events[42].edits[0].record_out, "01:08:56:09")
self.assertTrue( self.assertTrue(events[42].edits[0].transition.kind == pycmx.Transition.Cut)
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].source, "TC_R1_V6")
self.assertEqual(events[42].edits[1].clip_name, self.assertEqual(events[42].edits[1].clip_name, "TC R1 V6 TEMP2 ST FX.WAV")
"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_in, "00:00:00:00")
self.assertEqual(events[42].edits[1].source_out, "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_in, "01:08:56:09")
self.assertEqual(events[42].edits[1].record_out, "01:08:56:11") self.assertEqual(events[42].edits[1].record_out, "01:08:56:11")
self.assertTrue( self.assertTrue(
events[42].edits[1].transition.kind == events[42].edits[1].transition.kind == pycmx.Transition.Dissolve
pycmx.Transition.Dissolve) )
def test_line_numbers(self): def test_line_numbers(self):
with open("tests/edls/ToD_R4_LOCK3.1_030618_Video.edl") as f: 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: with open("tests/edls/test_25.edl", "r") as f:
edl = pycmx.parse_cmx3600(f) edl = pycmx.parse_cmx3600(f)
events = list(edl.events) events = list(edl.events)
self.assertEqual( self.assertEqual(events[4].edits[1].transition.name, "CROSS DISSOLVE")
events[4].edits[1].transition.name, "CROSS DISSOLVE")
def test_adobe_wide(self): def test_adobe_wide(self):
with open("tests/edls/adobe_dai109_test.txt", 'r', with open("tests/edls/adobe_dai109_test.txt", "r", encoding="ISO-8859-1") as f:
encoding='ISO-8859-1') as f:
edl = pycmx.parse_cmx3600(f) edl = pycmx.parse_cmx3600(f)
events = list(edl.events) events = list(edl.events)
@@ -131,12 +129,11 @@ class TestParse(TestCase):
for event in edl.events: for event in edl.events:
if event.number == 42: if event.number == 42:
self.assertEqual(len(event.edits), 1) self.assertEqual(len(event.edits), 1)
self.assertEqual(event.edits[0].source, self.assertEqual(event.edits[0].source, "M018C0005_240925_1F4L13")
"M018C0005_240925_1F4L13") self.assertEqual(
self.assertEqual(event.edits[0].transition.kind, event.edits[0].transition.kind, pycmx.Transition.Cut
pycmx.Transition.Cut) )
self.assertEqual(event.edits[0].source_in, self.assertEqual(event.edits[0].source_in, "18:44:20:12")
"18:44:20:12")
def test_cdl(self): def test_cdl(self):
with open("tests/edls/cdl_example01.edl", "r") as f: with open("tests/edls/cdl_example01.edl", "r") as f:
@@ -152,7 +149,7 @@ class TestParse(TestCase):
sat = event.edits[0].asc_sat sat = event.edits[0].asc_sat
self.assertIsNotNone(sat) self.assertIsNotNone(sat)
assert sat assert sat
self.assertEqual(sat, float('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: