-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Thanks to doop for brining up the ability to use None for empty slices.
- Loading branch information
1 parent
af4bbbb
commit b4ccaef
Showing
19 changed files
with
943 additions
and
219 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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]]]]: | ||
|
@@ -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 = [] | ||
|
||
|
@@ -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 | ||
|
@@ -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: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 --------------------------------------------------- | ||
|
@@ -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. | ||
|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.
Oops, something went wrong.