diff --git a/bin/edl2scenelist.py b/bin/edl2scenelist.py index 54cbbac..d7bf3de 100644 --- a/bin/edl2scenelist.py +++ b/bin/edl2scenelist.py @@ -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() diff --git a/docs/source/conf.py b/docs/source/conf.py index ac8fd02..4205076 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -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 ------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 256057a..92ba6a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/pycmx/__init__.py b/src/pycmx/__init__.py index 5fdcbd1..7bf6399 100644 --- a/src/pycmx/__init__.py +++ b/src/pycmx/__init__.py @@ -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") diff --git a/src/pycmx/cdl.py b/src/pycmx/cdl.py index 0478220..5eceaa3 100644 --- a/src/pycmx/cdl.py +++ b/src/pycmx/cdl.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Generic, NamedTuple, TypeVar -T = TypeVar('T') +T = TypeVar("T") @dataclass diff --git a/src/pycmx/channel_map.py b/src/pycmx/channel_map.py index 3783539..d6ad19e 100644 --- a/src/pycmx/channel_map.py +++ b/src/pycmx/channel_map.py @@ -1,8 +1,10 @@ # pycmx # (c) 2018-2025 Jamie Hardt -from re import (compile, match) -from typing import Dict, Tuple, Generator +from __future__ import annotations + +from re import compile, match +from typing import Generator class ChannelMap: @@ -10,35 +12,33 @@ class ChannelMap: Represents a set of all the channels to which an event applies. """ - _chan_map: Dict[str, Tuple] = { - "V": (True, False, False), - "A": (False, True, False), - "A2": (False, False, True), - "AA": (False, True, True), - "B": (True, True, False), - "AA/V": (True, True, True), - "A2/V": (True, False, True) - } - - def __init__(self, v=False, audio_channels=set()): - self._audio_channel_set = audio_channels + def __init__(self, v=False, audio_channels=None): + self._audio_channel_set = audio_channels or set() self.v = v + self._chan_map: dict[str, tuple] = { + "V": (True, False, False), + "A": (False, True, False), + "A2": (False, False, True), + "AA": (False, True, True), + "B": (True, True, False), + "AA/V": (True, True, True), + "A2/V": (True, False, True), + } @property def video(self) -> bool: - 'True if video is included' + "True if video is included" return self.v @property def audio(self) -> bool: - 'True if an audio channel is included' + "True if an audio channel is included" return len(self._audio_channel_set) > 0 @property def channels(self) -> Generator[int, None, None]: - 'A generator for each audio channel' - for c in self._audio_channel_set: - yield c + "A generator for each audio channel" + yield from self._audio_channel_set @property def a1(self) -> bool: @@ -78,7 +78,7 @@ class ChannelMap: def get_audio_channel(self, chan_num) -> bool: """True if chan_num is included""" - return (chan_num in self._audio_channel_set) + return chan_num in self._audio_channel_set def set_audio_channel(self, chan_num, enabled: bool): """If enabled is true, chan_num will be included""" @@ -88,7 +88,7 @@ class ChannelMap: self._audio_channel_set.remove(chan_num) def _append_event(self, event_str): - alt_channel_re = compile(r'^A(\d+)') + alt_channel_re = compile(r"^A(\d+)") if event_str in self._chan_map: channels = self._chan_map[event_str] self.v = channels[0] diff --git a/src/pycmx/edit.py b/src/pycmx/edit.py index ca6edfa..ed7de69 100644 --- a/src/pycmx/edit.py +++ b/src/pycmx/edit.py @@ -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 diff --git a/src/pycmx/edit_list.py b/src/pycmx/edit_list.py index d5ec58d..f614303 100644 --- a/src/pycmx/edit_list.py +++ b/src/pycmx/edit_list.py @@ -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: diff --git a/src/pycmx/event.py b/src/pycmx/event.py index 40a93ca..27650ed 100644 --- a/src/pycmx/event.py +++ b/src/pycmx/event.py @@ -1,18 +1,27 @@ # 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: """ Represents a collection of :class:`~pycmx.edit.Edit` s, all with the same - event number. """ + event number.""" def __init__(self, statements): self.statements = statements @@ -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], - 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)] + return [ + Edit( + edit_statement=e1[0], + audio_ext_statement=e1[1], + clip_name_statement=n1, + source_file_statement=s1, + trans_name_statement=u1, + asc_sop_statement=self._asc_sop_statement(), + asc_sat_statement=self._asc_sat_statement(), + frmc_statement=self._frmc_statement(), + ) + for (e1, n1, s1, u1) in zip(*the_zip) + ] @property - def unrecognized_statements(self) -> Generator[StmtUnrecognized, None, - None]: + 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) diff --git a/src/pycmx/parse_cmx_events.py b/src/pycmx/parse_cmx_events.py index 10bd142..e8f38ae 100644 --- a/src/pycmx/parse_cmx_events.py +++ b/src/pycmx/parse_cmx_events.py @@ -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: diff --git a/src/pycmx/parse_cmx_statements.py b/src/pycmx/parse_cmx_statements.py index 7408370..c7839a2 100644 --- a/src/pycmx/parse_cmx_statements.py +++ b/src/pycmx/parse_cmx_statements.py @@ -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, - 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) + 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) + 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)), - end=int(match.group(2)), - duration=int(match.group(3)), - line_number=line_number) + return StmtFrmc( + start=int(match.group(1)), + end=int(match.group(2)), + duration=int(match.group(3)), + line_number=line_number, + ) except ValueError as e: - return StmtCorruptRemark('FRMC', e, line_number) + 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,35 +268,45 @@ 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], - 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) + 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)' - ) + 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) diff --git a/src/pycmx/transition.py b/src/pycmx/transition.py index 8913c83..2c2ff1a 100644 --- a/src/pycmx/transition.py +++ b/src/pycmx/transition.py @@ -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:]) diff --git a/src/pycmx/util.py b/src/pycmx/util.py index 3bd0fe0..7068d5e 100644 --- a/src/pycmx/util.py +++ b/src/pycmx/util.py @@ -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. diff --git a/tests/test_issue_19.py b/tests/test_issue_19.py index ff1aa25..bd63462 100644 --- a/tests/test_issue_19.py +++ b/tests/test_issue_19.py @@ -1,3 +1,4 @@ +# ruff: noqa: SIM115 from unittest import TestCase from pycmx import parse_cmx3600 diff --git a/tests/test_parse.py b/tests/test_parse.py index 967d7a8..b7d007b 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -5,64 +5,65 @@ import pycmx class TestParse(TestCase): - files = ["INS4_R1_010417.edl", - "INS4_R1_DX_092117.edl", - "STP R1 v082517.edl", - "ToD_R4_LOCK3.1_030618_Video.edl", - "TEST.edl", - "test_edl_cdl.edl", - "INS4_R1_DX_092117.edl" - ] + def setUp(self) -> None: + self.files = [ + "INS4_R1_010417.edl", + "INS4_R1_DX_092117.edl", + "STP R1 v082517.edl", + "ToD_R4_LOCK3.1_030618_Video.edl", + "TEST.edl", + "test_edl_cdl.edl", + "INS4_R1_DX_092117.edl", + ] + + return super().setUp() def test_event_counts(self): counts = [287, 466, 250, 376, 120, 3, 466] - for fn, count in zip(type(self).files, counts): - with open("tests/edls/" + fn, 'r') as f: + for fn, count in zip(self.files, counts): + with open("tests/edls/" + fn, "r") as f: edl = pycmx.parse_cmx3600(f) actual = len(list(edl.events)) - self.assertTrue(actual == count, - "expected %i in file %s but found %i" - % (count, fn, actual)) + self.assertTrue( + actual == count, + f"expected {count} in file {fn} but found {actual}" + ) def test_list_sanity(self): - for fn in type(self).files: - with open("tests/edls/" + fn, 'r') as f: + for fn in self.files: + with open("tests/edls/" + fn, "r") as f: edl = pycmx.parse_cmx3600(f) self.assertTrue(type(edl.title) is str) self.assertTrue(len(edl.title) > 0) def test_event_sanity(self): - for fn in type(self).files: + for fn in self.files: path = "tests/edls/" + fn - with open(path, 'r') as f: + with open(path, "r") as f: edl = pycmx.parse_cmx3600(f) for index, event in enumerate(edl.events): - self.assertTrue(len(event.edits) > 0, - f"Failed for {path}") - self.assertEqual(event.number, index + 1, - f"Failed for {path}") + self.assertTrue(len(event.edits) > 0, f"Failed for {path}") + self.assertEqual(event.number, index + 1, f"Failed for {path}") def test_events(self): - with open("tests/edls/TEST.edl", 'r') as f: + with open("tests/edls/TEST.edl", "r") as f: edl = pycmx.parse_cmx3600(f) events = list(edl.events) self.assertEqual(events[0].number, 1) self.assertEqual(events[0].edits[0].source, "OY_HEAD_") self.assertEqual(events[0].edits[0].clip_name, "HEAD LEADER MONO") - self.assertEqual( - events[0].edits[0].source_file, "OY_HEAD_LEADER.MOV") + self.assertEqual(events[0].edits[0].source_file, "OY_HEAD_LEADER.MOV") self.assertEqual(events[0].edits[0].source_in, "00:00:00:00") self.assertEqual(events[0].edits[0].source_out, "00:00:00:00") self.assertEqual(events[0].edits[0].record_in, "01:00:00:00") self.assertEqual(events[0].edits[0].record_out, "01:00:08:00") - self.assertTrue( - events[0].edits[0].transition.kind == pycmx.Transition.Cut) + self.assertTrue(events[0].edits[0].transition.kind == pycmx.Transition.Cut) def test_channel_map(self): - with open("tests/edls/TEST.edl", 'r') as f: + with open("tests/edls/TEST.edl", "r") as f: edl = pycmx.parse_cmx3600(f) events = list(edl.events) self.assertFalse(events[0].edits[0].channels.video) @@ -72,7 +73,7 @@ class TestParse(TestCase): self.assertTrue(events[2].edits[0].channels.audio) def test_multi_edit_events(self): - with open("tests/edls/TEST.edl", 'r') as f: + with open("tests/edls/TEST.edl", "r") as f: edl = pycmx.parse_cmx3600(f) events = list(edl.events) @@ -80,25 +81,24 @@ class TestParse(TestCase): self.assertEqual(len(events[42].edits), 2) self.assertEqual(events[42].edits[0].source, "TC_R1_V1") - self.assertEqual(events[42].edits[0].clip_name, - "TC R1 V1.2 TEMP1 FX ST.WAV") + self.assertEqual( + events[42].edits[0].clip_name, "TC R1 V1.2 TEMP1 FX ST.WAV" + ) self.assertEqual(events[42].edits[0].source_in, "00:00:00:00") self.assertEqual(events[42].edits[0].source_out, "00:00:00:00") self.assertEqual(events[42].edits[0].record_in, "01:08:56:09") self.assertEqual(events[42].edits[0].record_out, "01:08:56:09") - self.assertTrue( - events[42].edits[0].transition.kind == pycmx.Transition.Cut) + self.assertTrue(events[42].edits[0].transition.kind == pycmx.Transition.Cut) self.assertEqual(events[42].edits[1].source, "TC_R1_V6") - self.assertEqual(events[42].edits[1].clip_name, - "TC R1 V6 TEMP2 ST FX.WAV") + self.assertEqual(events[42].edits[1].clip_name, "TC R1 V6 TEMP2 ST FX.WAV") self.assertEqual(events[42].edits[1].source_in, "00:00:00:00") self.assertEqual(events[42].edits[1].source_out, "00:00:00:00") self.assertEqual(events[42].edits[1].record_in, "01:08:56:09") self.assertEqual(events[42].edits[1].record_out, "01:08:56:11") self.assertTrue( - events[42].edits[1].transition.kind == - pycmx.Transition.Dissolve) + events[42].edits[1].transition.kind == pycmx.Transition.Dissolve + ) def test_line_numbers(self): with open("tests/edls/ToD_R4_LOCK3.1_030618_Video.edl") as f: @@ -113,12 +113,10 @@ class TestParse(TestCase): with open("tests/edls/test_25.edl", "r") as f: edl = pycmx.parse_cmx3600(f) events = list(edl.events) - self.assertEqual( - events[4].edits[1].transition.name, "CROSS DISSOLVE") + self.assertEqual(events[4].edits[1].transition.name, "CROSS DISSOLVE") def test_adobe_wide(self): - with open("tests/edls/adobe_dai109_test.txt", 'r', - encoding='ISO-8859-1') as f: + with open("tests/edls/adobe_dai109_test.txt", "r", encoding="ISO-8859-1") as f: edl = pycmx.parse_cmx3600(f) events = list(edl.events) @@ -131,12 +129,11 @@ class TestParse(TestCase): for event in edl.events: if event.number == 42: self.assertEqual(len(event.edits), 1) - self.assertEqual(event.edits[0].source, - "M018C0005_240925_1F4L13") - self.assertEqual(event.edits[0].transition.kind, - pycmx.Transition.Cut) - self.assertEqual(event.edits[0].source_in, - "18:44:20:12") + self.assertEqual(event.edits[0].source, "M018C0005_240925_1F4L13") + self.assertEqual( + event.edits[0].transition.kind, pycmx.Transition.Cut + ) + self.assertEqual(event.edits[0].source_in, "18:44:20:12") def test_cdl(self): with open("tests/edls/cdl_example01.edl", "r") as f: @@ -152,7 +149,7 @@ class TestParse(TestCase): sat = event.edits[0].asc_sat self.assertIsNotNone(sat) assert sat - self.assertEqual(sat, float('0.9640')) + self.assertEqual(sat, float("0.9640")) def test_frmc(self): with open("tests/edls/cdl_frmc_example01.edl", "r") as f: