7 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
16 changed files with 404 additions and 334 deletions
+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 -------------------------------------------------
+4 -20
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pycmx" name = "pycmx"
version = "1.5.0" version = "1.5.1"
description = "Python CMX 3600 Edit Decision List Parser" description = "Python CMX 3600 Edit Decision List Parser"
authors = [{name = "Jamie Hardt", email= "jamiehardt@me.com"}] authors = [{name = "Jamie Hardt", email= "jamiehardt@me.com"}]
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -13,7 +13,6 @@ keywords = [
requires-python = '>3.8' 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',
@@ -33,7 +32,7 @@ doc = [
] ]
dev = [ dev = [
'pytest', 'pytest',
'ruff>=0.14.10' 'ruff>=0.16.8'
] ]
[project.urls] [project.urls]
@@ -55,26 +54,11 @@ line-length = 88
indent-width = 4 indent-width = 4
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "W"] # select = ["E", "F", "W"]
[tool.ruff.format] [tool.ruff.format]
docstring-code-line-length = 88 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] [build-system]
requires = ["uv_build>=0.9.18,<0.10.0"] requires = ["uv_build>=0.9.18,<0.12"]
build-backend = "uv_build" build-backend = "uv_build"
+3 -4
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,9 +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__ = ("parse_cmx3600", "Transition", "Event", "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
+21 -21
View File
@@ -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):
"V": (True, False, False), self._audio_channel_set = audio_channels or set()
"A": (False, True, False),
"A2": (False, False, True),
"AA": (False, True, True),
"B": (True, True, False),
"AA/V": (True, True, True),
"A2/V": (True, False, True)
}
def __init__(self, v=False, audio_channels=set()):
self._audio_channel_set = audio_channels
self.v = v self.v = v
self._chan_map: dict[str, tuple] = {
"V": (True, False, False),
"A": (False, True, False),
"A2": (False, False, True),
"AA": (False, True, True),
"B": (True, True, False),
"AA/V": (True, True, True),
"A2/V": (True, False, True),
}
@property @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]
+27 -29
View File
@@ -1,21 +1,21 @@
# pycmx # pycmx
# (c) 2018-2025 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from __future__ import annotations
from .cdl import AscSopComponents, FramecountTriple from .cdl import AscSopComponents, FramecountTriple
from .channel_map import ChannelMap
from .statements import ( 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,24 +27,22 @@ 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:
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:
@@ -138,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.
@@ -149,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
@@ -161,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:
@@ -177,7 +175,7 @@ class Edit:
return self._asc_sop_statement.cdl_sop return self._asc_sop_statement.cdl_sop
@property @property
def asc_sop_raw(self) -> Optional[str]: def asc_sop_raw(self) -> str | None:
""" """
ASC CDL Slope-Offset-Power statement raw line. ASC CDL Slope-Offset-Power statement raw line.
""" """
@@ -187,7 +185,7 @@ class Edit:
return self._asc_sop_statement.line return self._asc_sop_statement.line
@property @property
def asc_sat(self) -> Optional[float]: def asc_sat(self) -> float | None:
""" """
Get ASC CDL saturation value for clip, if present. Get ASC CDL saturation value for clip, if present.
""" """
@@ -197,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
+20 -12
View File
@@ -1,13 +1,20 @@
# pycmx # pycmx
# (c) 2018-2025 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from .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:
""" """
@@ -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:
+49 -38
View File
@@ -1,18 +1,27 @@
# pycmx # pycmx
# (c) 2023-2025 Jamie Hardt # (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 .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:
""" """
Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same
event number. """ event number."""
def __init__(self, statements): def __init__(self, statements):
self.statements = statements self.statements = statements
@@ -25,7 +34,7 @@ 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
@@ -47,19 +56,19 @@ class Event:
# The list the_zip contains one element for each initialization # The list the_zip contains one element for each initialization
# parameter in Edit() # 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 # 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 # "FROM" and "TO" clip name lines. Otherwise we just look for on
# each per edit. # 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])
@@ -83,25 +92,28 @@ class Event:
# attach effects 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 [
audio_ext_statement=e1[1], Edit(
clip_name_statement=n1, edit_statement=e1[0],
source_file_statement=s1, audio_ext_statement=e1[1],
trans_name_statement=u1, clip_name_statement=n1,
asc_sop_statement=self._asc_sop_statement(), source_file_statement=s1,
asc_sat_statement=self._asc_sat_statement(), trans_name_statement=u1,
frmc_statement=self._frmc_statement()) asc_sop_statement=self._asc_sop_statement(),
for (e1, n1, s1, u1) in zip(*the_zip)] asc_sat_statement=self._asc_sat_statement(),
frmc_statement=self._frmc_statement(),
)
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.
""" """
@@ -109,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)
+1 -1
View File
@@ -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:
+137 -86
View File
@@ -1,37 +1,63 @@
# pycmx # pycmx
# (c) 2018-2025 Jamie Hardt # (c) 2018-2025 Jamie Hardt
from __future__ import annotations
import re import re
from typing import TextIO, List from typing import TextIO
from .cdl import AscSopComponents, Rgb from .cdl import AscSopComponents, Rgb
from .statements import (
from .statements import (StmtCdlSat, StmtCdlSop, StmtCorruptRemark, StmtFrmc, StmtAudioExt,
StmtRemark, StmtTitle, StmtUnrecognized, StmtFCM, StmtCdlSat,
StmtAudioExt, StmtClipName, StmtEffectsName, StmtCdlSop,
StmtEvent, StmtSourceFile, StmtSplitEdit) StmtClipName,
StmtCorruptRemark,
StmtEffectsName,
StmtEvent,
StmtFCM,
StmtFrmc,
StmtRemark,
StmtSourceFile,
StmtSplitEdit,
StmtTitle,
StmtUnrecognized,
)
from .util import collimate from .util import collimate
def parse_cmx3600_statements(file: TextIO, def parse_cmx3600_statements(file: TextIO, tolerant: bool = False) -> list[object]:
tolerant: bool = False) -> List[object]:
""" """
Return a list of every statement in the file argument. Return a list of every statement in the file argument.
""" """
lines = file.readlines() lines = file.readlines()
return [_parse_cmx3600_line(line.strip(), line_number, tolerant) return [
for (line_number, line) in enumerate(lines)] _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]: def _edl_column_widths(event_field_length, source_field_length) -> list[int]:
return [event_field_length, 2, source_field_length, 1, return [
4, 2, # chans event_field_length,
4, 1, # trans 2,
3, 1, # trans op source_field_length,
11, 1, 1,
11, 1, 4,
11, 1, 2, # chans
11] 4,
1, # trans
3,
1, # trans op
11,
1,
11,
1,
11,
1,
11,
]
# def _edl_m2_column_widths(): # def _edl_m2_column_widths():
# return [2, # "M2" # 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] # 8,8,1,4,2,1,4,13,3,1,1]
def _parse_cmx3600_line(line: str, line_number: int, def _parse_cmx3600_line(line: str, line_number: int, tolerant: bool = False) -> object:
tolerant: bool = False) -> object:
""" """
Parses a single CMX EDL line. Parses a single CMX EDL line.
@@ -61,7 +86,8 @@ def _parse_cmx3600_line(line: str, line_number: int,
try: try:
return _parse_columns_for_standard_form( 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: except EventFormError:
if tolerant: if tolerant:
@@ -112,73 +138,88 @@ def _parse_extended_audio_channels(line, line_number):
def _parse_remark(line, line_number) -> object: def _parse_remark(line, line_number) -> object:
if line.startswith("FROM CLIP NAME:"): if line.startswith("FROM CLIP NAME:"):
return StmtClipName(name=line[15:].strip(), affect="from", return StmtClipName(
line_number=line_number) name=line[15:].strip(), affect="from", line_number=line_number
)
elif line.startswith("TO CLIP NAME:"): elif line.startswith("TO CLIP NAME:"):
return StmtClipName(name=line[13:].strip(), affect="to", return StmtClipName(
line_number=line_number) name=line[13:].strip(), affect="to", line_number=line_number
)
elif line.startswith("SOURCE FILE:"): elif line.startswith("SOURCE FILE:"):
return StmtSourceFile(filename=line[12:].strip(), return StmtSourceFile(filename=line[12:].strip(), line_number=line_number)
line_number=line_number)
elif line.startswith("ASC_SOP"): 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]]] = \ v1: list[list[tuple[str, str]]] = [
[re.findall(r'(-?\d+(\.\d+)?)', a) for a in group_patterns] 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] 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) return StmtRemark(line, line_number)
else: else:
try: try:
return StmtCdlSop(line=line, return StmtCdlSop(
cdl_sop=AscSopComponents( line=line,
slope=Rgb(red=float(v[0][0]), cdl_sop=AscSopComponents(
green=float(v[0][1]), slope=Rgb(
blue=float(v[0][2])), red=float(v[0][0]),
offset=Rgb(red=float(v[1][0]), green=float(v[0][1]),
green=float(v[1][1]), blue=float(v[0][2]),
blue=float(v[1][2])), ),
power=Rgb(red=float(v[2][0]), offset=Rgb(
green=float(v[2][1]), red=float(v[1][0]),
blue=float(v[2][2])) green=float(v[1][1]),
), blue=float(v[1][2]),
line_number=line_number) ),
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: except ValueError as e:
return StmtCorruptRemark('ASC_SOP', e, line_number) return StmtCorruptRemark("ASC_SOP", e, line_number)
elif line.startswith("ASC_SAT"): elif line.startswith("ASC_SAT"):
value = re.findall(r'(-?\d+(\.\d+)?)', line) value = re.findall(r"(-?\d+(\.\d+)?)", line)
if len(value) != 1: if len(value) != 1:
return StmtRemark(line, line_number) return StmtRemark(line, line_number)
else: else:
try: try:
return StmtCdlSat(value=float(value[0][0]), return StmtCdlSat(value=float(value[0][0]), line_number=line_number)
line_number=line_number)
except ValueError as e: except ValueError as e:
return StmtCorruptRemark('ASC_SAT', e, line_number) return StmtCorruptRemark("ASC_SAT", e, line_number)
elif line.startswith("FRMC"): elif line.startswith("FRMC"):
match = re.match(r'^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)' match = re.match(
r'\s+FRMC DURATION:\s*(\d+)', line, re.IGNORECASE) r"^FRMC START:\s*(\d+)\s+FRMC END:\s*(\d+)"
r"\s+FRMC DURATION:\s*(\d+)",
line,
re.IGNORECASE,
)
if match is None: if match is None:
return StmtCorruptRemark('FRMC', None, line_number) return StmtCorruptRemark("FRMC", None, line_number)
else: else:
try: try:
return StmtFrmc(start=int(match.group(1)), return StmtFrmc(
end=int(match.group(2)), start=int(match.group(1)),
duration=int(match.group(3)), end=int(match.group(2)),
line_number=line_number) duration=int(match.group(3)),
line_number=line_number,
)
except ValueError as e: except ValueError as e:
return StmtCorruptRemark('FRMC', e, line_number) return StmtCorruptRemark("FRMC", e, line_number)
else: else:
return StmtRemark(text=line, line_number=line_number) 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") is_video = split_type.startswith("VIDEO")
split_delay = line[24:35] split_delay = line[24:35]
return StmtSplitEdit(video=is_video, delay=split_delay, return StmtSplitEdit(video=is_video, delay=split_delay, line_number=line_number)
line_number=line_number)
# def _parse_motion_memory(line, line_number): # def _parse_motion_memory(line, line_number):
# return StmtMotionMemory(source="", fps="") # return StmtMotionMemory(source="", fps="")
# #
class EventFormError(RuntimeError): class EventFormError(RuntimeError):
pass pass
@@ -210,9 +251,9 @@ def _parse_unrecognized(line, line_number):
return StmtUnrecognized(content=line, line_number=line_number) return StmtUnrecognized(content=line, line_number=line_number)
def _parse_columns_for_standard_form(line: str, event_field_length: int, def _parse_columns_for_standard_form(
source_field_length: int, line: str, event_field_length: int, source_field_length: int, line_number: int
line_number: int): ):
# breakpoint() # breakpoint()
col_widths = _edl_column_widths(event_field_length, source_field_length) col_widths = _edl_column_widths(event_field_length, source_field_length)
@@ -227,35 +268,45 @@ def _parse_columns_for_standard_form(line: str, event_field_length: int,
if len(channels) == 0 or len(trans) == 0: if len(channels) == 0 or len(trans) == 0:
raise EventFormError() raise EventFormError()
return StmtEvent(event=column_strings[0], return StmtEvent(
source=column_strings[2].strip(), event=int(column_strings[0]),
channels=channels, source=column_strings[2].strip(),
trans=trans, channels=channels,
trans_op=column_strings[8].strip(), trans=trans,
source_in=column_strings[10].strip(), trans_op=column_strings[8].strip(),
source_out=column_strings[12].strip(), source_in=column_strings[10].strip(),
record_in=column_strings[14].strip(), source_out=column_strings[12].strip(),
record_out=column_strings[16].strip(), record_in=column_strings[14].strip(),
line_number=line_number, record_out=column_strings[16].strip(),
source_field_size=source_field_length) line_number=line_number,
source_field_size=source_field_length,
)
def _parse_columns_tolerant(line: str, line_number: int): def _parse_columns_tolerant(line: str, line_number: int):
pattern = re.compile(r'^\s*(\d+)\s+(.{8,128}?)\s+' pattern = re.compile(
r'(V|A|A2|AA|NONE|AA/V|A2/V|B)\s+' r"^\s*(\d+)\s+(.{8,128}?)\s+"
r'(C|D|W|KB|K|KO)\s+(\d*)\s+(\d\d.\d\d.\d\d.\d\d)\s' r"(V|A|A2|AA|NONE|AA/V|A2/V|B)\s+"
r'(\d\d.\d\d.\d\d.\d\d)\s(\d\d.\d\d.\d\d.\d\d)\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)' 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) match = pattern.match(line)
if match: if match:
return StmtEvent(event=int(match.group(1)), source=match.group(2), return StmtEvent(
channels=match.group(3), trans=match.group(4), event=int(match.group(1)),
trans_op=match.group(5), source_in=match.group(6), source=match.group(2),
source_out=match.group(7), record_in=match.group(8), channels=match.group(3),
record_out=match.group(9), line_number=line_number, trans=match.group(4),
source_field_size=len(match.group(2))) 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: else:
return StmtUnrecognized(line, line_number) return StmtUnrecognized(line, line_number)
+6 -6
View File
@@ -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,7 +22,7 @@ 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.
""" """
@@ -42,17 +42,17 @@ 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:
@@ -64,7 +64,7 @@ class Transition:
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
+44 -47
View File
@@ -5,64 +5,65 @@ import pycmx
class TestParse(TestCase): class TestParse(TestCase):
files = ["INS4_R1_010417.edl", def setUp(self) -> None:
"INS4_R1_DX_092117.edl", self.files = [
"STP R1 v082517.edl", "INS4_R1_010417.edl",
"ToD_R4_LOCK3.1_030618_Video.edl", "INS4_R1_DX_092117.edl",
"TEST.edl", "STP R1 v082517.edl",
"test_edl_cdl.edl", "ToD_R4_LOCK3.1_030618_Video.edl",
"INS4_R1_DX_092117.edl" "TEST.edl",
] "test_edl_cdl.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: