Merge pull request #22 from iluvcapra/bug-ruff-2026

Bug ruff 2026
This commit is contained in:
Jamie Hardt
2026-09-17 15:13:49 -07:00
committed by GitHub
15 changed files with 402 additions and 328 deletions
+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 -------------------------------------------------
+2 -16
View File
@@ -1,6 +1,6 @@
[project]
name = "pycmx"
version = "1.5.0"
version = "1.5.1"
description = "Python CMX 3600 Edit Decision List Parser"
authors = [{name = "Jamie Hardt", email= "jamiehardt@me.com"}]
license-files = ["LICENSE"]
@@ -13,7 +13,6 @@ keywords = [
requires-python = '>3.8'
classifiers = [
'Development Status :: 5 - Production/Stable',
'License :: OSI Approved :: MIT License',
'Topic :: Multimedia',
'Topic :: Multimedia :: Video',
'Topic :: Text Processing',
@@ -55,24 +54,11 @@ line-length = 88
indent-width = 4
[tool.ruff.lint]
select = ["E", "F", "W"]
# select = ["E", "F", "W"]
[tool.ruff.format]
docstring-code-line-length = 88
# [tool.pylint]
# max-line-length = 88
# disable = [
# "C0103", # (invalid-name)
# "C0114", # (missing-module-docstring)
# "C0115", # (missing-class-docstring)
# "C0116", # (missing-function-docstring)
# "R0903", # (too-few-public-methods)
# "R0913", # (too-many-arguments)
# "W0105", # (pointless-string-statement)
# ]
#
[build-system]
requires = ["uv_build>=0.9.18,<0.12"]
build-backend = "uv_build"
+3 -4
View File
@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
pycmx is a parser for CMX 3600-style EDLs.
@@ -7,9 +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__ = ("parse_cmx3600", "Transition", "Event", "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
+15 -15
View File
@@ -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] = {
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)
"A2/V": (True, False, True),
}
def __init__(self, v=False, audio_channels=set()):
self._audio_channel_set = audio_channels
self.v = v
@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]
+27 -29
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,24 +27,22 @@ 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:
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:
@@ -138,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.
@@ -149,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
@@ -161,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:
@@ -177,7 +175,7 @@ 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.
"""
@@ -187,7 +185,7 @@ 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.
"""
@@ -197,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
+20 -12
View File
@@ -1,13 +1,20 @@
# 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:
"""
@@ -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:
+42 -31
View File
@@ -1,12 +1,21 @@
# pycmx
# (c) 2023-2025 Jamie Hardt
from __future__ import annotations
from typing import Any, Generator
from .statements import (StmtFrmc, StmtEvent, StmtClipName, StmtSourceFile,
StmtAudioExt, StmtUnrecognized, StmtEffectsName,
StmtCdlSop, StmtCdlSat)
from .edit import Edit
from typing import List, Generator, Optional, Tuple, Any
from .statements import (
StmtAudioExt,
StmtCdlSat,
StmtCdlSop,
StmtClipName,
StmtEffectsName,
StmtEvent,
StmtFrmc,
StmtSourceFile,
StmtUnrecognized,
)
class Event:
@@ -25,7 +34,7 @@ class Event:
return int(self._edit_statements()[0].event)
@property
def edits(self) -> List[Edit]:
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
@@ -47,19 +56,19 @@ class Event:
# The list the_zip contains one element for each initialization
# parameter in Edit()
the_zip: List[List[Any]] = [edits_audio]
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: Optional[StmtClipName] = None
end_name: Optional[StmtClipName] = None
start_name: StmtClipName | None = None
end_name: StmtClipName | None = None
for clip_name in clip_names:
if clip_name.affect == 'from':
if clip_name.affect == "from":
start_name = clip_name
elif clip_name.affect == 'to':
elif clip_name.affect == "to":
end_name = clip_name
the_zip.append([start_name, end_name])
@@ -83,25 +92,28 @@ class Event:
# attach effects name to last event
try:
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)
the_zip.append(trans_names)
except IndexError:
the_zip.append([None] * len(edits_audio))
return [Edit(edit_statement=e1[0],
return [
Edit(
edit_statement=e1[0],
audio_ext_statement=e1[1],
clip_name_statement=n1,
source_file_statement=s1,
trans_name_statement=u1,
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)]
frmc_statement=self._frmc_statement(),
)
for (e1, n1, s1, u1) in zip(*the_zip)
]
@property
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None,
None]:
def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, None]:
"""
A generator for all the unrecognized statements in the event.
"""
@@ -109,38 +121,37 @@ class Event:
if type(s) is StmtUnrecognized:
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]
def _edit_statements(self) -> List[StmtEvent]:
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]:
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]:
def _source_file_statements(self) -> list[StmtSourceFile]:
return [s for s in self.statements if type(s) is StmtSourceFile]
def _statements_with_audio_ext(self) -> Generator[
Tuple[StmtEvent, Optional[StmtAudioExt]], None, None]:
def _statements_with_audio_ext(
self,
) -> Generator[tuple[StmtEvent, 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:]):
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_sop_statement(self) -> StmtCdlSop | None:
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 _asc_sat_statement(self) -> StmtCdlSat | None:
return next((s for s in self.statements if type(s) is StmtCdlSat), 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)
+1 -1
View File
@@ -3,8 +3,8 @@
from typing import TextIO
from .parse_cmx_statements import (parse_cmx3600_statements)
from .edit_list import EditList
from .parse_cmx_statements import parse_cmx3600_statements
def parse_cmx3600(f: TextIO, tolerant: bool = False) -> EditList:
+122 -71
View File
@@ -1,37 +1,63 @@
# pycmx
# (c) 2018-2025 Jamie Hardt
from __future__ import annotations
import re
from typing import TextIO, List
from typing import TextIO
from .cdl import AscSopComponents, Rgb
from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc,
StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM,
StmtAudioExt, StmtClipName, StmtEffectsName,
StmtEvent, StmtSourceFile, StmtSplitEdit)
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]:
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)]
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_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"
@@ -39,8 +65,7 @@ def _edl_column_widths(event_field_length, source_field_length) -> List[int]:
# 8,8,1,4,2,1,4,13,3,1,1]
def _parse_cmx3600_line(line: str, line_number: int,
tolerant: bool = False) -> object:
def _parse_cmx3600_line(line: str, line_number: int, tolerant: bool = False) -> object:
"""
Parses a single CMX EDL line.
@@ -61,7 +86,8 @@ def _parse_cmx3600_line(line: str, line_number: int,
try:
return _parse_columns_for_standard_form(
line, event_field_len, source_field_len, line_number)
line, event_field_len, source_field_len, line_number
)
except EventFormError:
if tolerant:
@@ -112,73 +138,88 @@ def _parse_extended_audio_channels(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)
return StmtClipName(
name=line[15:].strip(), affect="from", line_number=line_number
)
elif line.startswith("TO CLIP NAME:"):
return StmtClipName(name=line[13:].strip(), affect="to",
line_number=line_number)
return StmtClipName(
name=line[13:].strip(), affect="to", line_number=line_number
)
elif line.startswith("SOURCE FILE:"):
return StmtSourceFile(filename=line[12:].strip(),
line_number=line_number)
return StmtSourceFile(filename=line[12:].strip(), line_number=line_number)
elif line.startswith("ASC_SOP"):
group_patterns: list[str] = re.findall(r'\((.*?)\)', line)
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]
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]):
if len(v) != 3 or any(len(a) != 3 for a in v):
return StmtRemark(line, line_number)
else:
try:
return StmtCdlSop(line=line,
return StmtCdlSop(
line=line,
cdl_sop=AscSopComponents(
slope=Rgb(red=float(v[0][0]),
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]))
blue=float(v[0][2]),
),
line_number=line_number)
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)
return StmtCorruptRemark("ASC_SOP", e, line_number)
elif line.startswith("ASC_SAT"):
value = re.findall(r'(-?\d+(\.\d+)?)', line)
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)
return StmtCdlSat(value=float(value[0][0]), line_number=line_number)
except ValueError as e:
return StmtCorruptRemark('ASC_SAT', e, line_number)
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)
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)
return StmtCorruptRemark("FRMC", None, line_number)
else:
try:
return StmtFrmc(start=int(match.group(1)),
return StmtFrmc(
start=int(match.group(1)),
end=int(match.group(2)),
duration=int(match.group(3)),
line_number=line_number)
line_number=line_number,
)
except ValueError as e:
return StmtCorruptRemark('FRMC', e, line_number)
return StmtCorruptRemark("FRMC", e, line_number)
else:
return StmtRemark(text=line, line_number=line_number)
@@ -194,14 +235,14 @@ def _parse_split(line: str, line_number):
is_video = split_type.startswith("VIDEO")
split_delay = line[24:35]
return StmtSplitEdit(video=is_video, delay=split_delay,
line_number=line_number)
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
@@ -210,9 +251,9 @@ 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):
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)
@@ -227,7 +268,8 @@ def _parse_columns_for_standard_form(line: str, event_field_length: int,
if len(channels) == 0 or len(trans) == 0:
raise EventFormError()
return StmtEvent(event=column_strings[0],
return StmtEvent(
event=int(column_strings[0]),
source=column_strings[2].strip(),
channels=channels,
trans=trans,
@@ -237,25 +279,34 @@ def _parse_columns_for_standard_form(line: str, event_field_length: int,
record_in=column_strings[14].strip(),
record_out=column_strings[16].strip(),
line_number=line_number,
source_field_size=source_field_length)
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)'
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)))
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)
+6 -6
View File
@@ -1,7 +1,7 @@
# pycmx
# (c) 2023 Jamie Hardt
from typing import Optional
from __future__ import annotations
class Transition:
@@ -22,7 +22,7 @@ class Transition:
self.name = name
@property
def kind(self) -> Optional[str]:
def kind(self) -> str | None:
"""
Return the kind of transition: Cut, Wipe, etc.
"""
@@ -42,17 +42,17 @@ 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:
@@ -64,7 +64,7 @@ class Transition:
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,3 +1,4 @@
# ruff: noqa: SIM115
from unittest import TestCase
from pycmx import parse_cmx3600
+38 -41
View File
@@ -5,64 +5,65 @@ import pycmx
class TestParse(TestCase):
files = ["INS4_R1_010417.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"
"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: