Skip to content

Commit

Permalink
Release version 4.2.0
Browse files Browse the repository at this point in the history
Thanks to doop for brining up the ability to use None for empty slices.
  • Loading branch information
OrangeChannel committed Jul 22, 2020
1 parent af4bbbb commit b4ccaef
Show file tree
Hide file tree
Showing 19 changed files with 943 additions and 219 deletions.
196 changes: 110 additions & 86 deletions acsuite/__init__.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,38 @@
"""Frame-based cutting/trimming/splicing of audio with VapourSynth."""
__all__ = ['eztrim']
__author__ = 'Dave <[email protected]>'
__date__ = '29 June 2020'
__date__ = '21 July 2020'
__credits__ = """AzraelNewtype, for the original audiocutter.py.
Ricardo Constantino (wiiaboo), for vfr.py from which this was inspired.
"""
__version__ = '4.2.0'

import fractions
import os
from pathlib import Path
import pathlib
from re import compile, IGNORECASE
from shutil import which
from subprocess import PIPE, run
from typing import cast, Dict, List, Optional, Tuple, Union
from typing import Dict, List, Optional, Tuple, Union
from warnings import simplefilter, warn

import vapoursynth as vs

simplefilter("always") # display warnings
simplefilter('always') # display warnings

Path = Union[bytes, os.PathLike, pathlib.Path, str]
Trim = Tuple[Optional[int], Optional[int]]


def eztrim(clip: vs.VideoNode,
/,
trims: Union[List[Tuple[int, int]], Tuple[int, int]],
audio_file: Union[Path, str],
outfile: Optional[Union[Path, str]] = None,
trims: Union[List[Trim], Trim],
audio_file: Path,
outfile: Optional[Path] = None,
*,
mkvmerge_path: Optional[Union[Path, str]] = None,
ffmpeg_path: Optional[Union[Path, str]] = None,
mkvmerge_path: Optional[Path] = None,
ffmpeg_path: Optional[Path] = None,
quiet: bool = False,
debug: bool = False
) -> Optional[Dict[str, Union[int, List[int], List[str]]]]:
Expand All @@ -37,101 +43,115 @@ def eztrim(clip: vs.VideoNode,
For a 100 frame long VapourSynth clip (``src[:100]``):
``src[3:22]+src[23:40]+src[48]+src[50:-20]+src[-10:-5]+src[97:]`` can be (almost) directly entered as
``trims=[(3,22),(23,40),(48,49),(50,-20),(-10,-5),(97,0)]``.
``src[3:-13]`` can be directly entered as ``trims=(3,-13)``.
>>> src = core.ffms2.Source('file.mkv')
>>> clip = src[3:22]+src[23:40]+src[48]+src[50:-20]+src[-10:-5]+src[97:]
>>> 'These trims can be almost directly entered as:'
>>> trims = [(3, 22), (23, 40), (48, 49), (50, -20), (-10, -5), (97, None)]
>>> eztrim(src, trims, 'audio_file.wav')
:param clip: Input clip needed to determine framerate for audio timecodes
and ``clip.num_frames`` for negative indexing.
:param trims: Either a list of 2-tuples, or one tuple of 2 ints.
>>> src = core.ffms2.Source('file.mkv')
>>> clip = src[3:-13]
>>> 'A single slice can be entered as a single tuple:'
>>> eztrim(src, (3, -13), 'audio_file.wav')
Empty slicing must represented with a ``0``.
``src[:10]+src[-5:]`` must be entered as ``trims=[(0,10), (-5,0)]``.
:param clip: Input clip needed to determine framerate for audio timecodes
and ``clip.num_frames`` for negative indexing
:param trims: Either a list of 2-tuples, or one tuple of 2 ints
Empty slicing must represented with a ``None``.
``src[:10]+src[-5:]`` must be entered as ``trims=[(None,10), (-5,None)]``.
For legacy reasons, ``0`` can be used in place of ``None`` but is not recommended.
Single frame slices must be represented as a normal slice.
``src[15]`` must be entered as ``trims=(15,16)``.
:param audio_file: A string or ``Path`` refering to the source audio file's location
:param audio_file: A string or path-like object refering to the source audio file's location
(i.e. '/path/to/audio_file.wav').
Can also be a container file that uses a slice-able audio codec
such as a remuxed BDMV source into a `.mkv` file with FLAC / PCM audio.
:param outfile: Either a filename 'out.mka' or a full path '/path/to/out.mka'
that will be used for the trimmed audio file.
If left empty, will default to ``audio_file`` + ``_cut.mka`` (Default: ``None``).
If left empty, will default to ``audio_file`` + ``_cut.mka``.
:param mkvmerge_path: Set this if ``mkvmerge`` is not in your `PATH`
or if you want to use a portable executable (Default: ``None``).
or if you want to use a portable executable
:param ffmpeg_path: Needed to output a `.wav` track instead of a one-track Mastroka Audio file (Default: ``None``).
:param ffmpeg_path: Needed to output a `.wav` track instead of a one-track Mastroka Audio file.
If ``ffmpeg`` exists in your `PATH`, it will automatically be detected and used.
If set to ``None`` or ``ffmpeg`` can't be found, will only output a `.mka` file.
If specified as a blank string ``''``, the script will skip attemping to re-write the file as a `.wav` track
and will simply output a `.mka` file.
:param quiet: Suppresses most console output from MKVToolNix and FFmpeg (Default: ``False``).
:param debug: Used for testing purposes (Default: ``False``).
:param quiet: Suppresses most console output from MKVToolNix and FFmpeg
:param debug: Used for testing purposes
:return: If ``debug`` is ``True``, returns a dictionary of values for testing, otherwise returns ``None``.
Outputs a cut/spliced audio file in either the script's directoy or the path specified with ``outfile``.
"""
if not mkvmerge_path:
if not (mkvmerge_path := which('mkvmerge')):
raise FileNotFoundError('eztrim: mkvmerge executable not found')

audio_file = Path(audio_file)
if not audio_file.exists():
raise FileNotFoundError('eztrim: {audio_file} not found')

if not outfile:
outfile = Path(os.path.splitext(audio_file)[0] + '_cut.mka')
if debug:
pass
else:
outfile = Path(outfile)
if not os.path.isfile(audio_file):
raise FileNotFoundError(f"eztrim: {audio_file} not found")

if outfile is None:
outfile = os.path.splitext(audio_file)[0] + '_cut.mka'
if os.path.splitext(outfile)[1] != '.mka':
warn('eztrim: outfile does not have a `.mka` extension, one will be added for you', SyntaxWarning)
outfile = Path(os.path.splitext(outfile)[0] + '.mka')
warn("eztrim: outfile does not have a .mka extension, one will be added for you", SyntaxWarning)
outfile = os.path.splitext(outfile)[0] + '.mka'
if os.path.isfile(outfile):
raise FileExistsError(f"eztrim: {outfile} already exists")

# error checking
if mkvmerge_path is None:
mkvmerge_path: str = which('mkvmerge') or ''
if not os.path.isfile(mkvmerge_path):
raise FileNotFoundError("eztrim: mkvmerge executable not found")

# error checking ------------------------------------------------------------------------
if not isinstance(trims, (list, tuple)):
raise TypeError('eztrim: trims must be a list of 2-tuples (or just one 2-tuple)')
raise TypeError("eztrim: trims must be a list of 2-tuples (or just one 2-tuple)")

if len(trims) == 1 and isinstance(trims, list):
warn('eztrim: using a list of one 2-tuple is not recommended; for a single trim, directly use a tuple: `trims=(5,-2)` instead of `trims=[(5,-2)]`', SyntaxWarning)
if isinstance(trims, list):
warn("eztrim: using a list of one 2-tuple is not recommended; "
"for a single trim, directly use a tuple: `trims=(5,-2)` instead of `trims=[(5,-2)]`", SyntaxWarning)
if isinstance(trims[0], tuple):
trims = trims[0]
else:
raise ValueError("eztrim: the inner trim must be a tuple")
elif isinstance(trims, list):
for trim in trims:
if not isinstance(trim, tuple):
raise TypeError('eztrim: the trim {trim} is not a tuple')
raise TypeError(f"eztrim: the trim {trim} is not a tuple")
if len(trim) != 2:
raise ValueError('eztrim: the trim {trim} needs 2 elements')
raise ValueError(f"eztrim: the trim {trim} needs 2 elements")
for i in trim:
if not isinstance(i, int):
raise ValueError('eztrim: the trim {trim} must have 2 ints')
if not isinstance(i, (int, type(None))):
raise ValueError(f"eztrim: the trim {trim} must have 2 ints or None's")

if isinstance(trims, tuple):
if len(trims) != 2:
raise ValueError('eztrim: the trim must have 2 elements')
start: int = trims[0]
end: int = trims[1] # directly un-pack values from the single trim
start, end = cast(Tuple, _negative_to_positive(clip, start, end))
raise ValueError("eztrim: a single tuple trim must have 2 elements")
# --------------------------------------------

num_frames, fps = clip.num_frames, clip.fps

if isinstance(trims, tuple):
start, end = _negative_to_positive(num_frames, *trims)
if end <= start:
raise ValueError('eztrim: the trim {trims} is not logical')
cut_ts_s: List[str] = [_f2ts(clip, start)]
cut_ts_e: List[str] = [_f2ts(clip, end)]
raise ValueError(f"eztrim: the trim {trims} is not logical")
cut_ts_s: List[str] = [_f2ts(fps, start)]
cut_ts_e: List[str] = [_f2ts(fps, end)]
if debug:
return {'s': start, 'e': end, 'cut_ts_s': cut_ts_s, 'cut_ts_e': cut_ts_e}
else:
starts: List[int] = [s for s, e in trims]
ends: List[int] = [e for s, e in trims]
starts, ends = cast(Tuple, _negative_to_positive(clip, starts, ends))
starts, ends = _negative_to_positive(clip.num_frames, [s for s, e in trims], [e for s, e in trims])
if _check_ordered(starts, ends):
cut_ts_s = [_f2ts(clip, f) for f in starts]
cut_ts_e = [_f2ts(clip, f) for f in ends]
cut_ts_s = [_f2ts(fps, f) for f in starts]
cut_ts_e = [_f2ts(fps, f) for f in ends]
else:
raise ValueError('eztrim: the trims are not logical')

if debug:
if isinstance(trims, list):
raise ValueError("eztrim: the trims are not logical")
if debug:
return {'s': starts, 'e': ends, 'cut_ts_s': cut_ts_s, 'cut_ts_e': cut_ts_e}
elif isinstance(trims, tuple):
return {'s': start, 'e': end, 'cut_ts_s': cut_ts_s, 'cut_ts_e': cut_ts_e}

delay_statement = []

Expand Down Expand Up @@ -160,15 +180,16 @@ def eztrim(clip: vs.VideoNode,

if ffmpeg_path:
ffmpeg_silence = ['-loglevel', '16'] if quiet else []
ffmpeg_outfile = os.path.splitext(outfile)[0] + '.wav'
if os.path.isfile(ffmpeg_outfile):
raise FileExistsError(f"eztrim: {ffmpeg_outfile} already exists, not re-encoding with FFmpeg")
run([str(ffmpeg_path), '-hide_banner'] + ffmpeg_silence + ['-i', str(outfile), os.path.splitext(outfile)[0] + '.wav'])
os.remove(outfile)

return None


def _f2ts(clip: vs.VideoNode, f: int) -> str:
"""Converts frame number to HH:mm:ss.nnnnnnnnn timestamp based on clip's framerate."""
t = round(10 ** 9 * f * clip.fps ** -1)
def _f2ts(fps: fractions.Fraction, f: int) -> str:
"""Converts frame number to HH:mm:ss.nnnnnnnnn timestamp based on framerate."""
t = round(10 ** 9 * f * fps ** -1)

s = t / 10 ** 9
m = s // 60
Expand All @@ -179,38 +200,41 @@ def _f2ts(clip: vs.VideoNode, f: int) -> str:
return f'{h:02.0f}:{m:02.0f}:{s:012.9f}'


def _negative_to_positive(clip: vs.VideoNode, a: Union[List[int], int], b: Union[List[int], int]) \
-> Union[Tuple[List[int], List[int]], Tuple[int, int]]:
"""Changes negative/zero index to positive based on clip.num_frames."""
num_frames = clip.num_frames
_Neg2pos_in = Union[List[Optional[int]], Optional[int]]
_Neg2pos_out = Union[Tuple[List[int], List[int]], Tuple[int, int]]


# speed-up analysis of a single trim
if isinstance(a, int) and isinstance(b, int):
def _negative_to_positive(num_frames: int, a: _Neg2pos_in, b: _Neg2pos_in) -> _Neg2pos_out:
"""Changes negative/zero index to positive based on num_frames."""
single_trim = (isinstance(a, (int, type(None))) and isinstance(b, (int, type(None))))

# simplify analysis of a single trim
if single_trim:
a, b = (a or 0), (b or 0)
if abs(a) > num_frames or abs(b) > num_frames:
raise ValueError('_negative_to_positive: {max(abs(a), abs(b))} is out of bounds')
raise ValueError(f"_negative_to_positive: {max(abs(a), abs(b))} is out of bounds")
return a if a >= 0 else num_frames + a, b if b > 0 else num_frames + b

else:
a = cast(List, a)
b = cast(List, b)
if len(a) != len(b):
raise ValueError('_negative_to_positive: lists must be same length')
raise ValueError("_negative_to_positive: lists must be same length")

real_a, real_b = [(i or 0) for i in a], [(i or 0) for i in b] # convert None to 0

for x, y in zip(a, b):
if abs(x) > num_frames or abs(y) > num_frames:
raise ValueError('_negative_to_positive: {max(abs(x), abs(y))} is out of bounds')
if not (all(abs(i) <= num_frames for i in real_a) and all(abs(i) <= num_frames for i in real_b)):
raise ValueError("_negative_to_positive: one or more trims are out of bounds")

if all(i >= 0 for i in a) and all(i > 0 for i in b): return a, b
if all(i >= 0 for i in real_a) and all(i > 0 for i in real_b):
return real_a, real_b

positive_a = [x if x >= 0 else num_frames + x for x in a]
positive_b = [y if y > 0 else num_frames + y for y in b]
positive_a = [x if x >= 0 else num_frames + x for x in real_a]
positive_b = [y if y > 0 else num_frames + y for y in real_b]

return positive_a, positive_b


# Static helper functions
def _check_ordered(a: List[int], b: List[int]) -> bool:
"""Checks if lists follow logical python slicing."""
"""Checks if lists follow logical Python slicing."""
if len(a) != len(b):
raise ValueError('_check_ordered: lists must be same length')
if len(a) == 1 and len(b) == 1:
Expand Down
17 changes: 17 additions & 0 deletions docs/_static/theme_overrides.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
@media screen and (min-width:1100px) {
.wy-nav-content {
max-width: 1000px
}
}

.rst-content code,.rst-content tt {
font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace
}

.rst-content code.literal {
color: #595959
}

.rst-content .viewcode-back,.rst-content .viewcode-link,.rst-content a code.xref {
color: #007020
}
23 changes: 16 additions & 7 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
author = 'Dave <[email protected]>'

# The full version, including alpha/beta/rc tags
release = '4.1.0'
version = release = '4.2.0'


# -- General configuration ---------------------------------------------------
Expand All @@ -34,7 +34,9 @@
'sphinx.ext.autodoc',
'sphinx.ext.autosummary',
'sphinx.ext.todo',
'sphinx_autodoc_typehints'
'sphinx.ext.viewcode',
'sphinx_autodoc_typehints',
'sphinx.ext.githubpages',
]

# Add any paths that contain templates here, relative to this directory.
Expand All @@ -57,20 +59,27 @@
# 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'

# 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_context = {
# https://rackerlabs.github.io/docs-rackspace/tools/rtd-tables.html
'css_files': ['_static/theme_overrides.css'],
}

autosummary_generate = True

autodoc_mock_imports = ["vapoursynth"]
autodoc_mock_imports = ['vapoursynth']
smartquotes = True
html_show_copyright = False
html_show_sphinx = False
# add_module_names = False
pygments_style = 'sphinx'

# -- Extension configuration -------------------------------------------------

# -- Options for todo extension ----------------------------------------------

# If true, `todo` and `todoList` produce output, else they produce nothing.
todo_include_todos = True
2 changes: 1 addition & 1 deletion docs/html/.buildinfo
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Sphinx build info version 1
# This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done.
config: ae8ed2fff03942fd3e6c1678b406b7d8
config: 40d3808df14f92f3f1d2e334bcf020d0
tags: 645f666f9bcd5a90fca523b33c5a78b7
Empty file added docs/html/.nojekyll
Empty file.
Loading

0 comments on commit b4ccaef

Please sign in to comment.