├── .coveragerc ├── .github └── workflows │ └── build.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── README.rst ├── docs ├── _static │ └── css │ │ └── theme_overrides.css ├── api.rst ├── conf.py ├── index.rst ├── license.rst ├── requirements.txt └── versions.rst ├── flake.lock ├── flake.nix ├── setup.py ├── tests └── test_vsutil.py └── vsutil ├── __init__.py ├── _metadata.py ├── clips.py ├── func.py ├── info.py ├── py.typed └── types.py /.coveragerc: -------------------------------------------------------------------------------- 1 | 2 | [run] 3 | include = 4 | vsutil.py 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: build 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | paths: 10 | - 'vsutil/**' 11 | - 'test/**' 12 | - '.github/workflows/**' 13 | pull_request: 14 | branches: [ master ] 15 | paths: 16 | - 'vsutil/**' 17 | - 'test/**' 18 | - '.github/workflows/**' 19 | 20 | jobs: 21 | windows: 22 | runs-on: windows-latest 23 | strategy: 24 | matrix: 25 | versions: 26 | - 59 27 | python-version: 28 | - "3.10" 29 | 30 | steps: 31 | - uses: actions/checkout@v2 32 | - name: Set up Python ${{ matrix.python-version }} 33 | uses: actions/setup-python@v2 34 | with: 35 | python-version: ${{ matrix.python-version }} 36 | - name: Install dependencies 37 | run: | 38 | python -m pip install --upgrade pip 39 | pip install vapoursynth-portable==${{ matrix.versions }} 40 | pip install . 41 | - name: Running tests 42 | run: | 43 | python -m unittest discover -s ./tests 44 | 45 | linux: 46 | runs-on: ubuntu-latest 47 | 48 | steps: 49 | - uses: actions/checkout@v2.4.0 50 | - uses: cachix/install-nix-action@v15 51 | with: 52 | nix_path: nixpkgs=channel:nixos-unstable 53 | - run: nix build .#packages.x86_64-linux.default -L 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/python 3 | # Edit at https://www.gitignore.io/?templates=python 4 | 5 | ### Python ### 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # celery beat schedule file 98 | celerybeat-schedule 99 | 100 | # SageMath parsed files 101 | *.sage.py 102 | 103 | # Environments 104 | .env 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | 130 | # End of https://www.gitignore.io/api/python 131 | # pycharm config 132 | .idea 133 | 134 | 135 | result 136 | result-* 137 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | python: 4 | version: 3.8 5 | install: 6 | - requirements: docs/requirements.txt 7 | 8 | sphinx: 9 | configuration: docs/conf.py 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Irrational Encoding Wizardry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | vsutil 2 | ------ 3 | 4 | |build| |discord| |docs| 5 | 6 | .. |build| image:: https://github.com/Irrational-Encoding-Wizardry/vsutil/workflows/build/badge.svg 7 | :target: https://github.com/Irrational-Encoding-Wizardry/vsutil/actions?query=workflow%3Abuild+ 8 | :alt: Build 9 | 10 | .. |discord| image:: https://img.shields.io/discord/221919789017202688.svg 11 | :target: https://discord.gg/ZB7ZXbN 12 | :alt: Discord 13 | 14 | .. |docs| image:: https://readthedocs.org/projects/vsutil/badge/?version=latest 15 | :target: http://vsutil.encode.moe/en/latest/?badge=latest 16 | :alt: Documentation Status 17 | 18 | A collection of general-purpose VapourSynth functions to be reused in modules and scripts. 19 | 20 | The goal for vsutil is to allow authors of various "func" scripts to make use of premade helper functions instead of having to write their own. 21 | 22 | There are various benefits to this. For starters, only one script will require updating if anything is changed in VapourSynth instead of every single func. This also helps unmaintained scripts from breaking and never being fixed. Additionally this will also be a good resource for new authors and makes functions easier to write and read. 23 | 24 | As this is a community-driven project, contributions are heavily encouraged. vsutil will be continually updated to ensure it is up-to-date with changes to VapourSynth and to include various pull requests sent in by contributors. 25 | -------------------------------------------------------------------------------- /docs/_static/css/theme_overrides.css: -------------------------------------------------------------------------------- 1 | @import 'theme.css'; 2 | 3 | @media screen and (min-width:1100px) { 4 | .wy-nav-content { 5 | max-width: 1000px 6 | } 7 | } 8 | 9 | .rst-content code.literal { 10 | color: #595959 11 | } 12 | 13 | .rst-content .viewcode-back,.rst-content .viewcode-link,.rst-content a code.xref { 14 | color: #007020 15 | } 16 | 17 | span.sig-prename.descclassname, span.sig-name.descname { 18 | color: black; 19 | font-family: SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",Courier,monospace; 20 | } 21 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | API Reference 3 | ============= 4 | 5 | .. automodule:: vsutil 6 | 7 | 8 | Functions that return a clip 9 | ============================ 10 | 11 | .. autofunction:: vsutil.depth 12 | .. autofunction:: vsutil.frame2clip 13 | .. autofunction:: vsutil.get_y 14 | .. autofunction:: vsutil.insert_clip 15 | .. autofunction:: vsutil.join 16 | .. autofunction:: vsutil.plane 17 | .. autofunction:: vsutil.split 18 | 19 | 20 | Miscellanious non-VapourSynth functions 21 | ======================================= 22 | 23 | .. autofunction:: vsutil.fallback 24 | .. autofunction:: vsutil.iterate 25 | .. autofunction:: vsutil.resolve_enum 26 | 27 | 28 | Decorators 29 | ========== 30 | 31 | .. autofunction:: vsutil.disallow_variable_format 32 | .. autofunction:: vsutil.disallow_variable_resolution 33 | 34 | 35 | Clip information and helper functions 36 | ===================================== 37 | 38 | **Helpers to inspect a clip/frame** 39 | 40 | .. autofunction:: vsutil.get_depth 41 | .. autofunction:: vsutil.get_lowest_value 42 | .. autofunction:: vsutil.get_neutral_value 43 | .. autofunction:: vsutil.get_peak_value 44 | .. autofunction:: vsutil.get_plane_size 45 | .. autofunction:: vsutil.get_subsampling 46 | .. autofunction:: vsutil.is_image 47 | 48 | ---- 49 | 50 | **Mathematical helpers** 51 | 52 | .. autofunction:: vsutil.get_w 53 | .. autofunction:: vsutil.scale_value 54 | 55 | 56 | Enums 57 | ===== 58 | 59 | .. autoclass:: vsutil.Dither 60 | :members: 61 | .. autoclass:: vsutil.Range 62 | :members: 63 | 64 | Other 65 | ===== 66 | .. py:data:: vsutil.EXPR_VARS 67 | :type: str 68 | :value: 'xyzabcdefghijklmnopqrstuvw' 69 | 70 | This constant contains a list of all variables that can appear inside an expr-string ordered 71 | by assignment. So the first clip will have the name *EXPR_VARS[0]*, the second one will 72 | have the name *EXPR_VARS[1]*, and so on. 73 | 74 | This can be used to automatically generate expr-strings. 75 | 76 | .. autoclass:: vsutil.function 77 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | 13 | import os 14 | import sys 15 | 16 | sys.path.insert(0, os.path.abspath('..')) 17 | 18 | meta = {} 19 | exec(open(os.path.abspath('../vsutil/_metadata.py')).read(), meta) 20 | 21 | 22 | # -- Project information ----------------------------------------------------- 23 | 24 | project = 'vsutil' 25 | copyright = '2020, Irrational Encoding Wizardry' 26 | author = 'Irrational Encoding Wizardry' 27 | 28 | # The full version, including alpha/beta/rc tags 29 | version = release = meta['__version__'] 30 | 31 | 32 | # -- General configuration --------------------------------------------------- 33 | 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.autosummary', 37 | 'sphinx.ext.todo', 38 | 'sphinx.ext.viewcode', 39 | 'sphinx_autodoc_typehints', 40 | ] 41 | 42 | # Add any paths that contain templates here, relative to this directory. 43 | templates_path = ['_templates'] 44 | 45 | # List of patterns, relative to source directory, that match files and 46 | # directories to ignore when looking for source files. 47 | # This pattern also affects html_static_path and html_extra_path. 48 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 49 | 50 | 51 | # -- Options for HTML output ------------------------------------------------- 52 | 53 | # The theme to use for HTML and HTML Help pages. See the documentation for 54 | # a list of builtin themes. 55 | # 56 | html_theme = 'sphinx_rtd_theme' 57 | 58 | # Add any paths that contain custom static files (such as style sheets) here, 59 | # relative to this directory. They are copied after the builtin static files, 60 | # so a file named "default.css" will overwrite the builtin "default.css". 61 | html_static_path = ['_static'] 62 | 63 | html_css_files = [ 64 | 'css/theme_overrides.css' 65 | ] 66 | 67 | html_style = 'css/theme_overrides.css' 68 | 69 | autosummary_generate = True 70 | autodoc_mock_imports = ['vapoursynth'] 71 | smartquotes = True 72 | html_show_sphinx = False 73 | # add_module_names = False 74 | pygments_style = 'sphinx' 75 | autodoc_preserve_defaults = True 76 | 77 | 78 | # -- Extension configuration ------------------------------------------------- 79 | 80 | todo_include_todos = True 81 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.rst 2 | 3 | Contents 4 | ======== 5 | 6 | .. toctree:: 7 | :maxdepth: 2 8 | 9 | api 10 | 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | license 15 | versions 16 | -------------------------------------------------------------------------------- /docs/license.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | License 3 | ======= 4 | 5 | vsutil is under the MIT License. See the LICENSE file. 6 | 7 | Conditions for Contributors 8 | =========================== 9 | 10 | By contributing to this software project, you are agreeing to the following 11 | terms and conditions for your contributions: First, you agree your 12 | contributions are submitted under the MIT license. Second, you represent you 13 | are authorized to make the contributions and grant the license. If your 14 | employer has rights to intellectual property that includes your contributions, 15 | you represent that you have received permission to make contributions and grant 16 | the required license on behalf of that employer. 17 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | docutils==0.16 2 | Pygments==2.6.1 3 | pyparsing==2.4.7 4 | Sphinx>=4.2.0,<5.0.0 5 | sphinx-autodoc-typehints>=1.10.3 6 | sphinx-rtd-theme==0.4.3 7 | -------------------------------------------------------------------------------- /docs/versions.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | Version History 3 | =============== 4 | 5 | .. automodule:: vsutil 6 | :noindex: 7 | 8 | 0.7.0 9 | ----- 10 | 11 | .. 12 | 13 | - New functions: 14 | 15 | * :func:`resolve_enum` added to public API (previously a private function used internally). 16 | * :func:`get_lowest_value`, :func:`get_neutral_value`, and :func:`get_peak_value` for relevant min/median/max float values based on sample-type and bit depth. 17 | 18 | - Changes to existing functions: 19 | 20 | * Deprecated ``enforce_cache`` parameter of :func:`frame2clip`. 21 | 22 | 0.6.0 23 | ----- 24 | 25 | .. 26 | 27 | - Add constant for std.Expr variables 28 | - Lots of documentation changes 29 | - Improve internal enum usage 30 | 31 | 0.5.0 32 | ----- 33 | 34 | .. 35 | 36 | - Split single ``__init__.py`` file into submodules. 37 | 38 | - New functions: 39 | 40 | * :func:`scale_value` added to scale values for bit depth, sample type, or range conversion. 41 | 42 | 0.4.0 43 | ----- 44 | 45 | .. 46 | 47 | - New functions: 48 | 49 | * :func:`disallow_variable_format` and :func:`disallow_variable_resolution` function decorators added that raise exceptions on variable-format or variable-res clips. Helpful for functions that assume the input clip's ``format`` is a valid ``vapoursynth.Format`` instance. 50 | 51 | - Changes to existing functions: 52 | 53 | * Removed ``functools.reduce`` usage in :func:`iterate`, fixing type-hinting and disallowing negative ``count``. 54 | * Dithering logic in :func:`depth` is now handled in a separate private function. 55 | 56 | - Bug fixes: 57 | 58 | * Will no longer dither for 8-bit full-range to 16-bit full-range conversions in :func:`depth`. 59 | 60 | 0.3.0 61 | ----- 62 | 63 | .. 64 | 65 | - Now uses Python 3.8 positional-only arguments (see :pep:`570` for more information): 66 | 67 | * :func:`get_subsampling` ``clip`` parameter 68 | * :func:`get_depth` ``clip`` parameter 69 | * :func:`get_plane_size` ``frame`` parameter 70 | * :func:`insert_clip` ``clip`` parameter 71 | * :func:`plane` ``clip`` and ``planeno`` parameters 72 | * :func:`get_y` ``clip`` parameter 73 | * :func:`split` ``clip`` parameter 74 | * :func:`frame2clip` ``frame`` parameter 75 | * :func:`is_image` ``filename`` parameter 76 | 77 | - New classes: 78 | 79 | * :class:`Dither` and :class:`Range` enumerations added to simpliy ``range``, ``range_in``, and ``dither_type`` arguments to ``vapoursynth.core.resize``. 80 | 81 | - New functions: 82 | 83 | * Added a bit depth converter, :func:`depth`, that automatically handles dithering and format changes. 84 | 85 | - Changes to existing functions: 86 | 87 | * :func:`get_subsampling` now returns ``None`` for formats without subsampling (i.e. RGB). 88 | * :func:`get_w` ``only_even`` parameter changed to keyword-only argument. 89 | 90 | 0.2.0 91 | ----- 92 | 93 | .. 94 | 95 | - Changes to existing functions: 96 | 97 | * Added ``enforce_cache`` parameter to :func:`frame2clip`. 98 | 99 | 0.1.0 100 | ----- 101 | 102 | - Initial package release. 103 | 104 | - Included functions: 105 | 106 | * :func:`fallback` 107 | * :func:`frame2clip` 108 | * :func:`get_depth` 109 | * :func:`get_plane_size` 110 | * :func:`get_subsampling` 111 | * :func:`get_w` 112 | * :func:`get_y` 113 | * :func:`insert_clip` 114 | * :func:`is_image` 115 | * :func:`iterate` 116 | * :func:`join` 117 | * :func:`plane` 118 | * :func:`split` 119 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "locked": { 5 | "lastModified": 1667395993, 6 | "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", 7 | "owner": "numtide", 8 | "repo": "flake-utils", 9 | "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "owner": "numtide", 14 | "repo": "flake-utils", 15 | "type": "github" 16 | } 17 | }, 18 | "nixpkgs": { 19 | "locked": { 20 | "lastModified": 1668671332, 21 | "narHash": "sha256-6QW9sTuLDcBLoR5EY+6LR7Y1k0dFVEMqcWY4jYOuiqM=", 22 | "owner": "nixos", 23 | "repo": "nixpkgs", 24 | "rev": "54be84c3ac0122c2b2272fc68a9015304bc0bb73", 25 | "type": "github" 26 | }, 27 | "original": { 28 | "owner": "nixos", 29 | "ref": "nixos-unstable", 30 | "repo": "nixpkgs", 31 | "type": "github" 32 | } 33 | }, 34 | "root": { 35 | "inputs": { 36 | "flake-utils": "flake-utils", 37 | "nixpkgs": "nixpkgs" 38 | } 39 | } 40 | }, 41 | "root": "root", 42 | "version": 7 43 | } 44 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "A collection of general-purpose VapourSynth functions to be reused in modules and scripts."; 3 | 4 | inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 5 | inputs.flake-utils.url = "github:numtide/flake-utils"; 6 | 7 | outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachSystem [ "x86_64-linux" ] (system: 8 | let 9 | pkgs = import nixpkgs { 10 | inherit system; 11 | }; 12 | 13 | ## Change this to update python versions. 14 | ## run "nix flake lock --update-input nixpkgs" after you did that. 15 | python = pkgs.python310; 16 | 17 | # On darwin it sadly needs to be monkey-patched still. 18 | vapoursynth_python = py: py.pkgs.vapoursynth; 19 | in 20 | { 21 | devShells.default = pkgs.mkShell { 22 | buildInputs = [ 23 | (python.withPackages (ps: [ 24 | (vapoursynth_python python) 25 | ])) 26 | ]; 27 | }; 28 | devShell = self.devShell.${system}.default; 29 | 30 | packages.default = python.pkgs.buildPythonPackage { 31 | pname = "vsutil"; 32 | version = 33 | let 34 | content = builtins.readFile ./vsutil/_metadata.py; 35 | version = builtins.match ".*__version__.*'(.*)'.*" content; 36 | in 37 | builtins.elemAt version 0; 38 | src = ./.; 39 | buildInputs = [ 40 | (vapoursynth_python python) 41 | ]; 42 | checkPhase = '' 43 | ${python}/bin/python -m unittest discover -s $src/tests 44 | ''; 45 | }; 46 | 47 | packages.dist = 48 | let 49 | build_python = python.withPackages (ps: [ 50 | ps.setuptools 51 | ps.wheel 52 | ]); 53 | in 54 | pkgs.runCommandNoCC "vsutil-dist" { src = ./.; } '' 55 | # Make sure the package test run. 56 | echo ${self.packages.${system}.default} >/dev/null 57 | 58 | cp -r $src/* . 59 | ${build_python}/bin/python setup.py bdist_wheel 60 | ${build_python}/bin/python setup.py sdist 61 | 62 | mkdir $out 63 | cp ./dist/* $out 64 | ''; 65 | defaultPackage = self.packages.${system}.default; 66 | } 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.test import test 3 | from distutils.util import convert_path 4 | 5 | # We can't import the submodule normally as that would "run" the main module 6 | # code while the setup script is meant to *build* the module. 7 | 8 | # Besides preventing a whole possible mess of issues with an un-built package, 9 | # this also prevents the vapoursynth import which breaks the docs on RTD. 10 | 11 | # convert_path is used here because according to the distutils docs: 12 | # '...filenames in the setup script are always supplied in Unix 13 | # style, and have to be converted to the local convention before we can 14 | # actually use them in the filesystem.' 15 | meta = {} 16 | exec(open(convert_path('vsutil/_metadata.py')).read(), meta) 17 | 18 | 19 | class DiscoverTest(test): 20 | 21 | def finalize_options(self): 22 | test.finalize_options(self) 23 | self.test_args = [] 24 | self.test_suite = True 25 | 26 | def run_tests(self): 27 | import os 28 | import unittest 29 | path = os.path.join(os.path.dirname(__file__), "tests") 30 | runner = unittest.TextTestRunner(verbosity=2) 31 | suite = unittest.TestLoader().discover(path, pattern="test_*.py") 32 | runner.run(suite) 33 | 34 | 35 | setup( 36 | name='vsutil', 37 | version=meta['__version__'], 38 | packages=find_packages(exclude=['tests']), 39 | package_data={ 40 | 'vsutil': ['py.typed'] 41 | }, 42 | url='https://encode.moe/vsutil', 43 | license='MIT', 44 | author=meta['__author__'].split()[0], 45 | author_email=meta['__author__'].split()[1][1:-1], 46 | description='A collection of general-purpose Vapoursynth functions to be reused in modules and scripts.', 47 | install_requires=[ 48 | "vapoursynth" 49 | ], 50 | cmdclass={ 51 | 'test': DiscoverTest 52 | }, 53 | python_requires='>=3.8', 54 | project_urls={ 55 | 'Documentation': 'http://vsutil.encode.moe/en/latest/', 56 | 'Source': 'https://github.com/Irrational-Encoding-Wizardry/vsutil', 57 | 'Tracker': 'https://github.com/Irrational-Encoding-Wizardry/vsutil/issues', 58 | }, 59 | keywords='encoding vapoursynth video', 60 | classifiers=[ 61 | "License :: OSI Approved :: MIT License", 62 | "Programming Language :: Python", 63 | "Topic :: Multimedia :: Video", 64 | "Typing :: Typed", 65 | ], 66 | ) 67 | -------------------------------------------------------------------------------- /tests/test_vsutil.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import vapoursynth as vs 4 | 5 | import vsutil 6 | 7 | 8 | MODULE_FUNCTION = vsutil.function("std", "BlankClip") 9 | 10 | class VsUtilTests(unittest.TestCase): 11 | CLASS_FUNCTION = vsutil.function("std", "BlankClip") 12 | 13 | def setUp(self): 14 | self.YUV420P8_CLIP = vs.core.std.BlankClip(format=vs.YUV420P8, width=160, height=120, color=[0, 128, 128], length=100) 15 | self.YUV420P10_CLIP = vs.core.std.BlankClip(format=vs.YUV420P10, width=160, height=120, color=[0, 128, 128], length=100) 16 | self.YUV444P8_CLIP = vs.core.std.BlankClip(format=vs.YUV444P8, width=160, height=120, color=[0, 128, 128], length=100) 17 | self.YUV422P8_CLIP = vs.core.std.BlankClip(format=vs.YUV422P8, width=160, height=120, color=[0, 128, 128], length=100) 18 | self.YUV410P8_CLIP = vs.core.std.BlankClip(format=vs.YUV410P8, width=160, height=120, color=[0, 128, 128], length=100) 19 | self.YUV411P8_CLIP = vs.core.std.BlankClip(format=vs.YUV411P8, width=160, height=120, color=[0, 128, 128], length=100) 20 | self.YUV440P8_CLIP = vs.core.std.BlankClip(format=vs.YUV440P8, width=160, height=120, color=[0, 128, 128], length=100) 21 | self.RGB24_CLIP = vs.core.std.BlankClip(format=vs.RGB24) 22 | 23 | self.SMALLER_SAMPLE_CLIP = vs.core.std.BlankClip(format=vs.YUV420P8, width=10, height=10) 24 | 25 | self.BLACK_SAMPLE_CLIP = vs.core.std.BlankClip(format=vs.YUV420P8, width=160, height=120, color=[0, 128, 128], 26 | length=100) 27 | self.WHITE_SAMPLE_CLIP = vs.core.std.BlankClip(format=vs.YUV420P8, width=160, height=120, color=[255, 128, 128], 28 | length=100) 29 | 30 | self.VARIABLE_FORMAT_CLIP = vs.core.std.Interleave([self.YUV420P8_CLIP, self.YUV444P8_CLIP], mismatch=True) 31 | 32 | def assert_same_dimensions(self, clip_a: vs.VideoNode, clip_b: vs.VideoNode): 33 | """ 34 | Assert that two clips have the same width and height. 35 | """ 36 | self.assertEqual(clip_a.height, clip_b.height, f'Same height expected, was {clip_a.height} and {clip_b.height}.') 37 | self.assertEqual(clip_a.width, clip_b.width, f'Same width expected, was {clip_a.width} and {clip_b.width}.') 38 | 39 | def assert_same_format(self, clip_a: vs.VideoNode, clip_b: vs.VideoNode): 40 | """ 41 | Assert that two clips have the same format (but not necessarily size). 42 | """ 43 | self.assertEqual(clip_a.format.id, clip_b.format.id, 'Same format expected.') 44 | 45 | def assert_same_bitdepth(self, clip_a: vs.VideoNode, clip_b: vs.VideoNode): 46 | """ 47 | Assert that two clips have the same number of bits per sample. 48 | """ 49 | self.assertEqual(clip_a.format.bits_per_sample, clip_b.format.bits_per_sample, 50 | f'Same depth expected, was {clip_a.format.bits_per_sample} and {clip_b.format.bits_per_sample}.') 51 | 52 | def assert_same_length(self, clip_a: vs.VideoNode, clip_b: vs.VideoNode): 53 | """ 54 | Assert that two clips have the same length 55 | """ 56 | self.assertEqual(len(clip_a), len(clip_b), 57 | f'Same number of frames expected, was {len(clip_a)} and {len(clip_b)}.') 58 | 59 | def assert_same_metadata(self, clip_a: vs.VideoNode, clip_b: vs.VideoNode): 60 | """ 61 | Assert that two clips have the same height and width, format, depth, and length. 62 | """ 63 | self.assert_same_format(clip_a, clip_b) 64 | self.assert_same_dimensions(clip_a, clip_b) 65 | self.assert_same_length(clip_a, clip_b) 66 | 67 | def assert_same_frame(self, clip_a: vs.VideoNode, clip_b: vs.VideoNode, frameno: int = 0): 68 | """ 69 | Assert that two frames are identical. Only the first frame of the arguments is used. 70 | """ 71 | diff = vs.core.std.PlaneStats(clip_a, clip_b) 72 | frame = diff.get_frame(frameno) 73 | self.assertEqual(frame.props.PlaneStatsDiff, 0) 74 | 75 | def test_subsampling(self): 76 | self.assertEqual('444', vsutil.get_subsampling(self.YUV444P8_CLIP)) 77 | self.assertEqual('440', vsutil.get_subsampling(self.YUV440P8_CLIP)) 78 | self.assertEqual('420', vsutil.get_subsampling(self.YUV420P8_CLIP)) 79 | self.assertEqual('422', vsutil.get_subsampling(self.YUV422P8_CLIP)) 80 | self.assertEqual('411', vsutil.get_subsampling(self.YUV411P8_CLIP)) 81 | self.assertEqual('410', vsutil.get_subsampling(self.YUV410P8_CLIP)) 82 | self.assertEqual(None, vsutil.get_subsampling(self.RGB24_CLIP)) 83 | # let’s create a custom format with higher subsampling than any of the legal ones to test that branch as well: 84 | with self.assertRaisesRegex(ValueError, 'Unknown subsampling.'): 85 | vsutil.get_subsampling( 86 | vs.core.std.BlankClip(format_=self.YUV444P8_CLIP.format.replace(subsampling_w=4)) 87 | ) 88 | 89 | def test_get_depth(self): 90 | self.assertEqual(8, vsutil.get_depth(self.YUV420P8_CLIP)) 91 | self.assertEqual(10, vsutil.get_depth(self.YUV420P10_CLIP)) 92 | 93 | def test_plane_size(self): 94 | self.assertEqual((160, 120), vsutil.get_plane_size(self.YUV420P8_CLIP, 0)) 95 | self.assertEqual((80, 60), vsutil.get_plane_size(self.YUV420P8_CLIP, 1)) 96 | # these should fail because they don’t have a constant format or size 97 | with self.assertRaises(ValueError): 98 | vsutil.get_plane_size( 99 | vs.core.std.Splice([self.BLACK_SAMPLE_CLIP, self.SMALLER_SAMPLE_CLIP], mismatch=True), 0) 100 | with self.assertRaises(ValueError): 101 | vsutil.get_plane_size( 102 | vs.core.std.Splice([self.YUV444P8_CLIP, self.YUV422P8_CLIP], mismatch=True), 0) 103 | 104 | def test_insert_clip(self): 105 | inserted_middle = vsutil.insert_clip(self.BLACK_SAMPLE_CLIP, self.WHITE_SAMPLE_CLIP[:10], 50) 106 | self.assert_same_frame(inserted_middle[0], self.BLACK_SAMPLE_CLIP[0]) 107 | self.assert_same_frame(inserted_middle[50], self.WHITE_SAMPLE_CLIP[0]) 108 | self.assert_same_frame(inserted_middle[60], self.BLACK_SAMPLE_CLIP[60]) 109 | 110 | inserted_start = vsutil.insert_clip(self.BLACK_SAMPLE_CLIP, self.WHITE_SAMPLE_CLIP[:10], 0) 111 | self.assert_same_frame(inserted_start[0], self.WHITE_SAMPLE_CLIP[0]) 112 | self.assert_same_frame(inserted_start[10], self.BLACK_SAMPLE_CLIP[10]) 113 | 114 | inserted_end = vsutil.insert_clip(self.BLACK_SAMPLE_CLIP, self.WHITE_SAMPLE_CLIP[:10], 90) 115 | self.assert_same_frame(inserted_end[-1], self.WHITE_SAMPLE_CLIP[9]) 116 | self.assert_same_frame(inserted_end[89], self.BLACK_SAMPLE_CLIP[89]) 117 | 118 | # make sure we didn’t lose or add any frames in the process 119 | self.assert_same_metadata(self.BLACK_SAMPLE_CLIP, inserted_start) 120 | self.assert_same_metadata(self.BLACK_SAMPLE_CLIP, inserted_middle) 121 | self.assert_same_metadata(self.BLACK_SAMPLE_CLIP, inserted_end) 122 | 123 | with self.assertRaises(ValueError): 124 | vsutil.insert_clip(self.BLACK_SAMPLE_CLIP, self.BLACK_SAMPLE_CLIP, 90) 125 | 126 | def test_fallback(self): 127 | self.assertEqual(vsutil.fallback(None, 'a value'), 'a value') 128 | self.assertEqual(vsutil.fallback('a value', 'another value'), 'a value') 129 | self.assertEqual(vsutil.fallback(None, sum(range(5))), 10) 130 | 131 | def test_get_y(self): 132 | y = vsutil.get_y(self.BLACK_SAMPLE_CLIP) 133 | self.assertEqual(y.format.num_planes, 1) 134 | self.assert_same_dimensions(self.BLACK_SAMPLE_CLIP, y) 135 | self.assert_same_bitdepth(self.BLACK_SAMPLE_CLIP, y) 136 | 137 | with self.assertRaisesRegex(ValueError, 'The clip must have a luma plane.'): 138 | vsutil.get_y(self.RGB24_CLIP) 139 | 140 | def test_plane(self): 141 | y = vs.core.std.BlankClip(format=vs.GRAY8) 142 | # This should be a no-op, and even the clip reference shouldn’t change 143 | self.assertEqual(y, vsutil.plane(y, 0)) 144 | 145 | def test_split_join(self): 146 | planes = vsutil.split(self.BLACK_SAMPLE_CLIP) 147 | self.assertEqual(len(planes), 3) 148 | self.assert_same_metadata(self.BLACK_SAMPLE_CLIP, vsutil.join(planes)) 149 | 150 | def test_frame2clip(self): 151 | frame = self.WHITE_SAMPLE_CLIP.get_frame(0) 152 | clip = vsutil.frame2clip(frame) 153 | self.assert_same_frame(self.WHITE_SAMPLE_CLIP, clip) 154 | 155 | def test_is_image(self): 156 | """These are basically tests for the mime types, but I want the coverage. rooDerp""" 157 | self.assertEqual(vsutil.is_image('something.png'), True) 158 | self.assertEqual(vsutil.is_image('something.m2ts'), False) 159 | 160 | def test_get_w(self): 161 | self.assertEqual(vsutil.get_w(480), 854) 162 | self.assertEqual(vsutil.get_w(480, only_even=False), 853) 163 | self.assertEqual(vsutil.get_w(1080, 4 / 3), 1440) 164 | self.assertEqual(vsutil.get_w(1080), 1920) 165 | self.assertEqual(vsutil.get_w(849, mod=4), 1508) 166 | 167 | def test_iterate(self): 168 | def double_number(x: int) -> int: 169 | return x * 2 170 | 171 | self.assertEqual(vsutil.iterate(2, double_number, 0), 2) 172 | self.assertEqual(vsutil.iterate(2, double_number, 1), double_number(2)) 173 | self.assertEqual(vsutil.iterate(2, double_number, 3), double_number(double_number(double_number(2)))) 174 | 175 | with self.assertRaisesRegex(ValueError, 'Count cannot be negative.'): 176 | vsutil.iterate(2, double_number, -1) 177 | 178 | def test_scale_value(self): 179 | # no change 180 | self.assertEqual(vsutil.scale_value(1, 8, 8, range_in=0, range=0), 1) 181 | self.assertEqual(vsutil.scale_value(1, 8, 8, range_in=1, range=1), 1) 182 | self.assertEqual(vsutil.scale_value(1, 32, 32, range_in=1, range=0), 1) 183 | self.assertEqual(vsutil.scale_value(1, 32, 32, range_in=0, range=1), 1) 184 | 185 | # range conversion 186 | self.assertEqual(vsutil.scale_value(219, 8, 8, range_in=0, range=1, scale_offsets=False, chroma=False), 255) 187 | self.assertEqual(vsutil.scale_value(255, 8, 8, range_in=1, range=0, scale_offsets=False, chroma=False), 219) 188 | 189 | self.assertEqual(vsutil.scale_value(224, 8, 8, range_in=0, range=1, scale_offsets=False, chroma=True), 255) 190 | self.assertEqual(vsutil.scale_value(255, 8, 8, range_in=1, range=0, scale_offsets=False, chroma=True), 224) 191 | 192 | self.assertEqual(vsutil.scale_value(235, 8, 8, range_in=0, range=1, scale_offsets=True, chroma=False), 255) 193 | self.assertEqual(vsutil.scale_value(255, 8, 8, range_in=1, range=0, scale_offsets=True, chroma=False), 235) 194 | 195 | self.assertEqual(vsutil.scale_value(240, 8, 8, range_in=0, range=1, scale_offsets=True, chroma=True), 255) 196 | self.assertEqual(vsutil.scale_value(255, 8, 8, range_in=1, range=0, scale_offsets=True, chroma=True), 240) 197 | 198 | # int to int (upsample) 199 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=0, range=0, scale_offsets=False, chroma=False), 256) 200 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=1, range=1, scale_offsets=False, chroma=False), 257) 201 | self.assertEqual(vsutil.scale_value(219, 8, 16, range_in=0, range=1, scale_offsets=False, chroma=False), 65535) 202 | self.assertEqual(vsutil.scale_value(255, 8, 16, range_in=1, range=0, scale_offsets=False, chroma=False), 219 << 8) 203 | 204 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=0, range=0, scale_offsets=False, chroma=True), 256) 205 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=1, range=1, scale_offsets=False, chroma=True), 257) 206 | self.assertEqual(vsutil.scale_value(224, 8, 16, range_in=0, range=1, scale_offsets=False, chroma=True), 65535) 207 | self.assertEqual(vsutil.scale_value(255, 8, 16, range_in=1, range=0, scale_offsets=False, chroma=True), 224 << 8) 208 | 209 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=0, range=0, scale_offsets=True, chroma=False), 256) 210 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=1, range=1, scale_offsets=True, chroma=False), 257) 211 | self.assertEqual(vsutil.scale_value(235, 8, 16, range_in=0, range=1, scale_offsets=True, chroma=False), 65535) 212 | self.assertEqual(vsutil.scale_value(255, 8, 16, range_in=1, range=0, scale_offsets=True, chroma=False), 235 << 8) 213 | 214 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=0, range=0, scale_offsets=True, chroma=True), 256) 215 | self.assertEqual(vsutil.scale_value(1, 8, 16, range_in=1, range=1, scale_offsets=True, chroma=True), 257) 216 | self.assertEqual(vsutil.scale_value(240, 8, 16, range_in=0, range=1, scale_offsets=True, chroma=True), 65535) 217 | self.assertEqual(vsutil.scale_value(255, 8, 16, range_in=1, range=0, scale_offsets=True, chroma=True), 240 << 8) 218 | 219 | # int to flt 220 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=0, range=1, scale_offsets=False, chroma=False), 1 / 219) 221 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=1, range=1, scale_offsets=False, chroma=False), 1 / 255) 222 | self.assertEqual(vsutil.scale_value(219, 8, 32, range_in=0, range=1, scale_offsets=False, chroma=False), 1) 223 | self.assertEqual(vsutil.scale_value(255, 8, 32, range_in=1, range=1, scale_offsets=False, chroma=False), 1) 224 | 225 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=0, range=1, scale_offsets=False, chroma=True), 1 / 224) 226 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=1, range=1, scale_offsets=False, chroma=True), 1 / 255) 227 | self.assertEqual(vsutil.scale_value(224, 8, 32, range_in=0, range=1, scale_offsets=False, chroma=True), 1) 228 | self.assertEqual(vsutil.scale_value(255, 8, 32, range_in=1, range=1, scale_offsets=False, chroma=True), 1) 229 | 230 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=0, range=1, scale_offsets=True, chroma=False), (1 - 16) / 219) 231 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=1, range=1, scale_offsets=True, chroma=False), 1 / 255) 232 | self.assertEqual(vsutil.scale_value(235, 8, 32, range_in=0, range=1, scale_offsets=True, chroma=False), 1) 233 | self.assertEqual(vsutil.scale_value(255, 8, 32, range_in=1, range=1, scale_offsets=True, chroma=False), 1) 234 | 235 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=0, range=1, scale_offsets=True, chroma=True), (1 - 128) / 224) 236 | self.assertEqual(vsutil.scale_value(1, 8, 32, range_in=1, range=1, scale_offsets=True, chroma=True), (1 - 128) / 255) 237 | self.assertEqual(vsutil.scale_value(240, 8, 32, range_in=0, range=1, scale_offsets=True, chroma=True), 0.5) 238 | self.assertEqual(vsutil.scale_value(255, 8, 32, range_in=1, range=1, scale_offsets=True, chroma=True), (255 - 128) / 255) 239 | 240 | # int to int (downsample) 241 | self.assertEqual(vsutil.scale_value(256, 16, 8, range_in=0, range=0, scale_offsets=False, chroma=False), 1) 242 | self.assertEqual(vsutil.scale_value(257, 16, 8, range_in=1, range=1, scale_offsets=False, chroma=False), 1) 243 | self.assertEqual(vsutil.scale_value(65535, 16, 8, range_in=1, range=0, scale_offsets=False, chroma=False), 219) 244 | self.assertEqual(vsutil.scale_value(219 << 8, 16, 8, range_in=0, range=1, scale_offsets=False, chroma=False), 255) 245 | 246 | self.assertEqual(vsutil.scale_value(256, 16, 8, range_in=0, range=0, scale_offsets=False, chroma=True), 1) 247 | self.assertEqual(vsutil.scale_value(257, 16, 8, range_in=1, range=1, scale_offsets=False, chroma=True), 1) 248 | self.assertEqual(vsutil.scale_value(65535, 16, 8, range_in=1, range=0, scale_offsets=False, chroma=True), 224) 249 | self.assertEqual(vsutil.scale_value(224 << 8, 16, 8, range_in=0, range=1, scale_offsets=False, chroma=True), 255) 250 | 251 | self.assertEqual(vsutil.scale_value(256, 16, 8, range_in=0, range=0, scale_offsets=True, chroma=False), 1) 252 | self.assertEqual(vsutil.scale_value(257, 16, 8, range_in=1, range=1, scale_offsets=True, chroma=False), 1) 253 | self.assertEqual(vsutil.scale_value(65535, 16, 8, range_in=1, range=0, scale_offsets=True, chroma=False), 235) 254 | self.assertEqual(vsutil.scale_value(235 << 8, 16, 8, range_in=0, range=1, scale_offsets=True, chroma=False), 255) 255 | 256 | self.assertEqual(vsutil.scale_value(256, 16, 8, range_in=0, range=0, scale_offsets=True, chroma=True), 1) 257 | self.assertEqual(vsutil.scale_value(257, 16, 8, range_in=1, range=1, scale_offsets=True, chroma=True), 1) 258 | self.assertEqual(vsutil.scale_value(65535, 16, 8, range_in=1, range=0, scale_offsets=True, chroma=True), 240) 259 | self.assertEqual(vsutil.scale_value(240 << 8, 16, 8, range_in=0, range=1, scale_offsets=True, chroma=True), 255) 260 | 261 | # flt to int 262 | self.assertEqual(vsutil.scale_value(1 / 219, 32, 8, range_in=1, range=0, scale_offsets=False, chroma=False), 1) 263 | self.assertEqual(vsutil.scale_value(1 / 255, 32, 8, range_in=1, range=1, scale_offsets=False, chroma=False), 1) 264 | self.assertEqual(vsutil.scale_value(1, 32, 8, range_in=1, range=0, scale_offsets=False, chroma=False), 219) 265 | self.assertEqual(vsutil.scale_value(1, 32, 8, range_in=1, range=1, scale_offsets=False, chroma=False), 255) 266 | 267 | self.assertEqual(vsutil.scale_value(1 / 224, 32, 8, range_in=1, range=0, scale_offsets=False, chroma=True), 1) 268 | self.assertEqual(vsutil.scale_value(1 / 255, 32, 8, range_in=1, range=1, scale_offsets=False, chroma=True), 1) 269 | self.assertEqual(vsutil.scale_value(1, 32, 8, range_in=1, range=0, scale_offsets=False, chroma=True), 224) 270 | self.assertEqual(vsutil.scale_value(1, 32, 8, range_in=1, range=1, scale_offsets=False, chroma=True), 255) 271 | 272 | self.assertEqual(vsutil.scale_value((1 - 16) / 219, 32, 8, range_in=1, range=0, scale_offsets=True, chroma=False), 1) 273 | self.assertEqual(vsutil.scale_value(1 / 255, 32, 8, range_in=1, range=1, scale_offsets=True, chroma=False), 1) 274 | self.assertEqual(vsutil.scale_value(1, 32, 8, range_in=1, range=0, scale_offsets=True, chroma=False), 235) 275 | self.assertEqual(vsutil.scale_value(1, 32, 8, range_in=1, range=1, scale_offsets=True, chroma=False), 255) 276 | 277 | self.assertEqual(vsutil.scale_value((1 - 128) / 224, 32, 8, range_in=1, range=0, scale_offsets=True, chroma=True), 1) 278 | self.assertEqual(vsutil.scale_value((1 - 128) / 255, 32, 8, range_in=1, range=1, scale_offsets=True, chroma=True), 1) 279 | self.assertEqual(vsutil.scale_value(0.5, 32, 8, range_in=1, range=0, scale_offsets=True, chroma=True), 240) 280 | self.assertEqual(vsutil.scale_value((255 - 128) / 255, 32, 8, range_in=1, range=1, scale_offsets=True, chroma=True), 255) 281 | 282 | def test_get_lowest_value(self): 283 | FLOAT_CLIP = self.YUV420P8_CLIP.resize.Point(format=self.YUV420P8_CLIP.format.replace(bits_per_sample=32, sample_type=vs.FLOAT)) 284 | 285 | self.assertEqual(vsutil.get_lowest_value(self.YUV420P8_CLIP, False), 0.) 286 | self.assertEqual(vsutil.get_lowest_value(self.YUV420P8_CLIP, True), 0.) 287 | 288 | self.assertEqual(vsutil.get_lowest_value(FLOAT_CLIP, False), 0.) 289 | self.assertEqual(vsutil.get_lowest_value(FLOAT_CLIP, True), -0.5) 290 | 291 | def test_get_neutral_value(self): 292 | FLOAT_CLIP = self.YUV420P8_CLIP.resize.Point(format=self.YUV420P8_CLIP.format.replace(bits_per_sample=32, sample_type=vs.FLOAT)) 293 | 294 | self.assertEqual(vsutil.get_neutral_value(self.YUV420P8_CLIP, False), 128.) 295 | self.assertEqual(vsutil.get_neutral_value(self.YUV420P8_CLIP, True), 128.) 296 | 297 | self.assertEqual(vsutil.get_neutral_value(FLOAT_CLIP, False), 0.5) 298 | self.assertEqual(vsutil.get_neutral_value(FLOAT_CLIP, True), 0.) 299 | 300 | def test_get_peak_value(self): 301 | FLOAT_CLIP = self.YUV420P8_CLIP.resize.Point(format=self.YUV420P8_CLIP.format.replace(bits_per_sample=32, sample_type=vs.FLOAT)) 302 | 303 | self.assertEqual(vsutil.get_peak_value(self.YUV420P8_CLIP, False), 255.) 304 | self.assertEqual(vsutil.get_peak_value(self.YUV420P8_CLIP, True), 255.) 305 | 306 | self.assertEqual(vsutil.get_peak_value(FLOAT_CLIP, False), 1.0) 307 | self.assertEqual(vsutil.get_peak_value(FLOAT_CLIP, True), 0.5) 308 | 309 | def test_depth(self): 310 | with self.assertRaisesRegex(ValueError, 'sample_type must be in'): 311 | vsutil.depth(self.RGB24_CLIP, 8, sample_type=2) 312 | with self.assertRaisesRegex(ValueError, 'range must be in'): 313 | vsutil.depth(self.RGB24_CLIP, 8, range=2) 314 | with self.assertRaisesRegex(ValueError, 'range_in must be in'): 315 | vsutil.depth(self.RGB24_CLIP, 8, range_in=2) 316 | with self.assertRaisesRegex(ValueError, 'dither_type must be in'): 317 | vsutil.depth(self.RGB24_CLIP, 8, dither_type='test') 318 | 319 | full_clip = vs.core.std.BlankClip(format=vs.RGB24) 320 | int_10_clip = full_clip.resize.Point(format=full_clip.format.replace(bits_per_sample=10)) 321 | int_16_clip = full_clip.resize.Point(format=full_clip.format.replace(bits_per_sample=16)) 322 | float_16_clip = full_clip.resize.Point(format=full_clip.format.replace(bits_per_sample=16, sample_type=vs.FLOAT)) 323 | float_32_clip = full_clip.resize.Point(format=full_clip.format.replace(bits_per_sample=32, sample_type=vs.FLOAT)) 324 | 325 | limited_clip = vs.core.std.BlankClip(format=vs.YUV420P8) 326 | l_int_10_clip = limited_clip.resize.Point(format=limited_clip.format.replace(bits_per_sample=10)) 327 | l_int_16_clip = limited_clip.resize.Point(format=limited_clip.format.replace(bits_per_sample=16)) 328 | l_float_16_clip = limited_clip.resize.Point(format=limited_clip.format.replace(bits_per_sample=16, sample_type=vs.FLOAT)) 329 | l_float_32_clip = limited_clip.resize.Point(format=limited_clip.format.replace(bits_per_sample=32, sample_type=vs.FLOAT)) 330 | 331 | self.assertEqual(vsutil.depth(full_clip, 8), full_clip) 332 | self.assert_same_format(vsutil.depth(full_clip, 10), int_10_clip) 333 | self.assert_same_format(vsutil.depth(full_clip, 16), int_16_clip) 334 | self.assert_same_format(vsutil.depth(full_clip, 16, sample_type=vs.FLOAT), float_16_clip) 335 | self.assert_same_format(vsutil.depth(full_clip, 32), float_32_clip) 336 | 337 | self.assert_same_format(vsutil.depth(float_16_clip, 16, sample_type=vs.INTEGER), int_16_clip) 338 | 339 | self.assertEqual(vsutil.depth(limited_clip, 8), limited_clip) 340 | self.assert_same_format(vsutil.depth(limited_clip, 10), l_int_10_clip) 341 | self.assert_same_format(vsutil.depth(limited_clip, 16), l_int_16_clip) 342 | self.assert_same_format(vsutil.depth(limited_clip, 16, sample_type=vs.FLOAT), l_float_16_clip) 343 | self.assert_same_format(vsutil.depth(limited_clip, 32), l_float_32_clip) 344 | 345 | self.assert_same_format(vsutil.depth(l_float_16_clip, 16, sample_type=vs.INTEGER), l_int_16_clip) 346 | 347 | def test_readable_enums(self): 348 | self.assertEqual(vsutil.types._readable_enums(vsutil.Range), ', ') 349 | 350 | def test_resolve_enum(self): 351 | self.assertEqual(vsutil.types.resolve_enum(vsutil.Range, None, 'test'), None) 352 | self.assertEqual(vsutil.types.resolve_enum(vs.SampleType, 0, 'test'), vs.SampleType(0)) 353 | 354 | with self.assertRaisesRegex(ValueError, 'vapoursynth.GRAY'): 355 | vsutil.types.resolve_enum(vs.ColorFamily, 999, 'test') 356 | 357 | def test_should_dither(self): 358 | # --- True --- 359 | # Range conversion 360 | self.assertTrue(vsutil.clips._should_dither(1, 1, in_range=vsutil.Range.LIMITED, out_range=vsutil.Range.FULL)) 361 | # Float to int 362 | self.assertTrue(vsutil.clips._should_dither(1, 1, in_sample_type=vs.FLOAT)) 363 | # Upsampling full range 10 -> 12 364 | self.assertTrue(vsutil.clips._should_dither(10, 12, in_range=vsutil.Range.FULL, out_range=vsutil.Range.FULL)) 365 | # Downsampling 366 | self.assertTrue(vsutil.clips._should_dither(10, 8, in_sample_type=vs.INTEGER)) 367 | self.assertTrue(vsutil.clips._should_dither(10, 8, in_sample_type=vs.INTEGER, in_range=vsutil.Range.FULL, out_range=vsutil.Range.FULL)) 368 | self.assertTrue(vsutil.clips._should_dither(10, 8, in_sample_type=vs.INTEGER, in_range=vsutil.Range.LIMITED, out_range=vsutil.Range.LIMITED)) 369 | 370 | # --- False --- 371 | # Int to int 372 | self.assertFalse(vsutil.clips._should_dither(8, 8, in_sample_type=vs.INTEGER)) 373 | # Upsampling full range 8 -> 16 374 | self.assertFalse(vsutil.clips._should_dither(8, 16, in_range=vsutil.Range.FULL, out_range=vsutil.Range.FULL)) 375 | # Upsampling 376 | self.assertFalse(vsutil.clips._should_dither(8, 16, in_sample_type=vs.INTEGER)) 377 | self.assertFalse(vsutil.clips._should_dither(8, 16, in_sample_type=vs.INTEGER, in_range=vsutil.Range.LIMITED, out_range=vsutil.Range.LIMITED)) 378 | # Float output 379 | self.assertFalse(vsutil.clips._should_dither(32, 32, in_sample_type=vs.INTEGER)) 380 | self.assertFalse(vsutil.clips._should_dither(32, 16, in_sample_type=vs.INTEGER, out_sample_type=vs.FLOAT)) 381 | 382 | def test_decorators(self): 383 | with self.assertRaisesRegex(ValueError, 'Variable-format'): 384 | vsutil.get_subsampling(self.VARIABLE_FORMAT_CLIP) 385 | 386 | def test_function(self): 387 | # It should work generally. 388 | self.assert_same_metadata(vsutil.function("std", "BlankClip")(), vs.core.std.BlankClip()) 389 | self.assert_same_frame(vsutil.function("std", "BlankClip")(), vs.core.std.BlankClip()) 390 | 391 | # It should behave like @staticmethod 392 | self.assert_same_metadata(self.CLASS_FUNCTION(), vs.core.std.BlankClip()) 393 | self.assert_same_frame(self.CLASS_FUNCTION(), vs.core.std.BlankClip()) 394 | 395 | # This is probably pointless, 396 | # as the test is actually far earlier: 397 | # It should not prevent loading the module 398 | # when using vs-engine's vpy-unittest tool. 399 | self.assert_same_metadata(MODULE_FUNCTION(), vs.core.std.BlankClip()) 400 | self.assert_same_frame(MODULE_FUNCTION(), vs.core.std.BlankClip()) 401 | 402 | # It should have the same attributes. 403 | alias_func = vsutil.function("std", "BlankClip") 404 | orig_func = vs.core.std.BlankClip 405 | self.assertEqual(alias_func.name, orig_func.name) 406 | self.assertEqual(alias_func.signature, orig_func.signature) 407 | self.assertEqual(alias_func.return_signature, orig_func.return_signature) 408 | -------------------------------------------------------------------------------- /vsutil/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | VSUtil. A collection of general-purpose VapourSynth functions to be reused in modules and scripts. 3 | """ 4 | 5 | # export all public function directly 6 | from .clips import * 7 | from .func import * 8 | from .info import * 9 | from .types import * 10 | 11 | # for wildcard imports 12 | _mods = ['clips', 'func', 'info', 'types'] 13 | 14 | __all__ = [] 15 | for _pkg in _mods: 16 | __all__ += __import__(__name__ + '.' + _pkg, fromlist=_mods).__all__ 17 | 18 | try: 19 | from ._metadata import __author__, __version__ 20 | except ImportError: 21 | __author__ = __version__ = 'unknown' 22 | -------------------------------------------------------------------------------- /vsutil/_metadata.py: -------------------------------------------------------------------------------- 1 | __author__ = 'kageru ' 2 | __version__ = '0.8.0' 3 | -------------------------------------------------------------------------------- /vsutil/clips.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions that modify/return a clip. 3 | """ 4 | __all__ = ['depth', 'frame2clip', 'get_y', 'insert_clip', 'join', 'plane', 'split'] 5 | 6 | from typing import Any, List, Optional, Sequence, Union, cast 7 | 8 | import vapoursynth as vs 9 | 10 | from . import func, info, types 11 | 12 | core = vs.core 13 | 14 | 15 | @func.disallow_variable_format 16 | def depth(clip: vs.VideoNode, 17 | bitdepth: int, 18 | /, 19 | sample_type: Optional[Union[int, vs.SampleType]] = None, 20 | *, 21 | range: Optional[Union[int, types.Range]] = None, 22 | range_in: Optional[Union[int, types.Range]] = None, 23 | dither_type: Optional[Union[types.Dither, str]] = None, 24 | ) -> vs.VideoNode: 25 | """A bit depth converter only using ``vapoursynth.core.resize()`` and ``vapoursynth.Format.replace()``. 26 | By default, outputs ``vapoursynth.FLOAT`` sample type for 32-bit and ``vapoursynth.INTEGER`` for anything else. 27 | 28 | >>> src_8 = vs.core.std.BlankClip(format=vs.YUV420P8) 29 | >>> src_10 = depth(src_8, 10) 30 | >>> src_10.format.name 31 | 'YUV420P10' 32 | 33 | >>> src2_10 = vs.core.std.BlankClip(format=vs.RGB30) 34 | >>> src2_8 = depth(src2_10, 8, dither_type=Dither.RANDOM) # override default dither behavior 35 | >>> src2_8.format.name 36 | 'RGB24' 37 | 38 | :param clip: Input clip. 39 | :param bitdepth: Desired `bits_per_sample` of output clip. 40 | :param sample_type: Desired `sample_type` of output clip. Allows overriding default float/integer behavior. 41 | Accepts ``vapoursynth.SampleType`` enums ``vapoursynth.INTEGER`` and ``vapoursynth.FLOAT`` 42 | or their values, ``0`` and ``1`` respectively. 43 | :param range: Output pixel range (defaults to input `clip`'s range). See :class:`Range`. 44 | :param range_in: Input pixel range (defaults to input `clip`'s range). See :class:`Range`. 45 | :param dither_type: Dithering algorithm. Allows overriding default dithering behavior. See :class:`Dither`. 46 | 47 | Defaults to :attr:`Dither.ERROR_DIFFUSION`, or Floyd-Steinberg error diffusion, when downsampling, 48 | converting between ranges, or upsampling full range input. 49 | Defaults to :attr:`Dither.NONE`, or round to nearest, otherwise. 50 | See `_should_dither()` comments for more information. 51 | 52 | :return: Converted clip with desired bit depth and sample type. ``ColorFamily`` will be same as input. 53 | """ 54 | sample_type = types.resolve_enum(vs.SampleType, sample_type, 'sample_type', depth) 55 | range = types.resolve_enum(types.Range, range, 'range', depth) 56 | range_in = types.resolve_enum(types.Range, range_in, 'range_in', depth) 57 | dither_type = types.resolve_enum(types.Dither, dither_type, 'dither_type', depth) 58 | 59 | curr_depth = info.get_depth(clip) 60 | sample_type = func.fallback(sample_type, vs.FLOAT if bitdepth == 32 else vs.INTEGER) 61 | 62 | if (curr_depth, clip.format.sample_type, range_in) == (bitdepth, sample_type, range): 63 | return clip 64 | 65 | should_dither = _should_dither(curr_depth, bitdepth, range_in, range, clip.format.sample_type, sample_type) 66 | dither_type = func.fallback(dither_type, types.Dither.ERROR_DIFFUSION if should_dither else types.Dither.NONE) 67 | 68 | new_format = clip.format.replace(bits_per_sample=bitdepth, sample_type=sample_type).id 69 | 70 | return clip.resize.Point(format=new_format, range=range, range_in=range_in, dither_type=dither_type) 71 | 72 | 73 | _unused: Any = [] 74 | 75 | 76 | def frame2clip(frame: vs.VideoFrame, /, *, enforce_cache=_unused) -> vs.VideoNode: 77 | """Converts a VapourSynth frame to a clip. 78 | 79 | :param frame: The frame to convert. 80 | :param enforce_cache: Unused, deprecated parameter. Kept for compatibility. 81 | 82 | :return: A one-frame clip that yields the `frame` passed to the function. 83 | """ 84 | if enforce_cache is not _unused: 85 | import warnings 86 | warnings.warn("enforce_cache is deprecated.", DeprecationWarning) 87 | 88 | bc = core.std.BlankClip( 89 | width=frame.width, 90 | height=frame.height, 91 | length=1, 92 | fpsnum=1, 93 | fpsden=1, 94 | format=frame.format.id 95 | ) 96 | frame = frame.copy() 97 | result = bc.std.ModifyFrame([bc], lambda n, f: frame.copy()) 98 | return result 99 | 100 | 101 | @func.disallow_variable_format 102 | def get_y(clip: vs.VideoNode, /) -> vs.VideoNode: 103 | """Helper to get the luma plane of a clip. 104 | 105 | If passed a single-plane ``vapoursynth.GRAY`` clip, :func:`plane` will assume it to `be` the luma plane 106 | itself and returns the `clip` (no-op). 107 | 108 | :param clip: Input clip. 109 | 110 | :return: Luma plane of the input `clip`. Will return the input `clip` if it is a single-plane grayscale clip. 111 | """ 112 | if clip.format.color_family not in (vs.YUV, vs.GRAY): 113 | raise ValueError('The clip must have a luma plane.') 114 | return plane(clip, 0) 115 | 116 | 117 | def insert_clip(clip: vs.VideoNode, /, insert: vs.VideoNode, start_frame: int) -> vs.VideoNode: 118 | """Convenience method to insert a shorter clip into a longer one. 119 | 120 | The `insert` clip cannot go beyond the last frame of the source `clip` or an exception is raised. 121 | The `insert` clip frames replace the `clip` frames, unlike a normal splice-in. 122 | 123 | :param clip: Longer clip to insert shorter clip into. 124 | :param insert: Insert clip. 125 | :param start_frame: First frame of the longer `clip` to replace. 126 | 127 | :return: Longer clip with frames replaced by the shorter clip. 128 | """ 129 | if start_frame == 0: 130 | return insert + clip[insert.num_frames:] 131 | pre = clip[:start_frame] 132 | frame_after_insert = start_frame + insert.num_frames 133 | if frame_after_insert > clip.num_frames: 134 | raise ValueError('Inserted clip is too long.') 135 | if frame_after_insert == clip.num_frames: 136 | return pre + insert 137 | post = clip[start_frame + insert.num_frames:] 138 | return pre + insert + post 139 | 140 | 141 | def join(planes: Sequence[vs.VideoNode], family: vs.ColorFamily = vs.YUV) -> vs.VideoNode: 142 | """Joins the supplied sequence of planes into a single VideoNode (defaults to YUV). 143 | 144 | >>> planes = [Y, U, V] 145 | >>> clip_YUV = join(planes) 146 | >>> plane = core.std.BlankClip(format=vs.GRAY8) 147 | >>> clip_GRAY = join([plane], family=vs.GRAY) 148 | 149 | :param planes: Sequence of one-plane ``vapoursynth.GRAY`` clips to merge. 150 | :param family: Output color family. 151 | 152 | :return: Merged clip of the supplied `planes`. 153 | """ 154 | return planes[0] if len(planes) == 1 and family == vs.GRAY \ 155 | else core.std.ShufflePlanes(planes, [0, 0, 0], family) 156 | 157 | 158 | @func.disallow_variable_format 159 | def plane(clip: vs.VideoNode, planeno: int, /) -> vs.VideoNode: 160 | """Extracts the plane with the given index from the input clip. 161 | 162 | If given a one-plane clip and ``planeno=0``, returns `clip` (no-op). 163 | 164 | >>> src = vs.core.std.BlankClip(format=vs.YUV420P8) 165 | >>> V = plane(src, 2) 166 | 167 | :param clip: The clip to extract the plane from. 168 | :param planeno: The index of which plane to extract. 169 | 170 | :return: A grayscale clip that only contains the given plane. 171 | """ 172 | if clip.format.num_planes == 1 and planeno == 0: 173 | return clip 174 | return core.std.ShufflePlanes(clip, planeno, vs.GRAY) 175 | 176 | 177 | @func.disallow_variable_format 178 | def split(clip: vs.VideoNode, /) -> List[vs.VideoNode]: 179 | """Returns a list of planes (VideoNodes) from the given input clip. 180 | 181 | >>> src = vs.core.std.BlankClip(format=vs.RGB27) 182 | >>> R, G, B = split(src) 183 | >>> src2 = vs.core.std.BlankClip(format=vs.GRAY8) 184 | >>> split(src2) 185 | [] # always returns a list, even if single plane 186 | 187 | :param clip: Input clip. 188 | 189 | :return: List of planes from the input `clip`. 190 | """ 191 | return [clip] if clip.format.num_planes == 1 else cast(List[vs.VideoNode], clip.std.SplitPlanes()) 192 | 193 | 194 | def _should_dither(in_bits: int, 195 | out_bits: int, 196 | in_range: Optional[types.Range] = None, 197 | out_range: Optional[types.Range] = None, 198 | in_sample_type: Optional[vs.SampleType] = None, 199 | out_sample_type: Optional[vs.SampleType] = None, 200 | ) -> bool: 201 | """ 202 | Determines whether dithering is needed for a given depth/range/sample_type conversion. 203 | 204 | If an input range is specified, and output range *should* be specified otherwise it assumes a range conversion. 205 | 206 | For an explanation of when dithering is needed: 207 | - Dithering is NEVER needed if the conversion results in a float sample type. 208 | - Dithering is ALWAYS needed for a range conversion (i.e. full to limited or vice-versa). 209 | - Dithering is ALWAYS needed to convert a float sample type to an integer sample type. 210 | - Dithering is needed when upsampling full range content with the exception of 8 -> 16 bit upsampling, 211 | as this is simply (0-255) * 257 -> (0-65535). 212 | - Dithering is needed when downsampling limited or full range. 213 | 214 | Dithering is theoretically needed when converting from an integer depth greater than 10 to half float, 215 | despite the higher bit depth, but zimg's internal resampler currently does not dither for float output. 216 | """ 217 | out_sample_type = func.fallback(out_sample_type, vs.FLOAT if out_bits == 32 else vs.INTEGER) 218 | in_sample_type = func.fallback(in_sample_type, vs.FLOAT if in_bits == 32 else vs.INTEGER) 219 | 220 | if out_sample_type == vs.FLOAT: 221 | return False 222 | 223 | range_conversion = in_range != out_range 224 | float_to_int = in_sample_type == vs.FLOAT 225 | upsampling = in_bits < out_bits 226 | downsampling = in_bits > out_bits 227 | 228 | return bool(range_conversion 229 | or float_to_int 230 | or (in_range == types.Range.FULL and upsampling and (in_bits, out_bits) != (8, 16)) 231 | or downsampling) 232 | -------------------------------------------------------------------------------- /vsutil/func.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | """ 3 | Decorators and non-VapourSynth-related functions. 4 | """ 5 | __all__ = [ 6 | # decorators 7 | 'disallow_variable_format', 'disallow_variable_resolution', 8 | # misc non-vapoursynth related 9 | 'fallback', 'iterate', 10 | # misc vapoursynth related 11 | 'function' 12 | ] 13 | 14 | import inspect 15 | from functools import partial, wraps 16 | from typing import Union, Any, TypeVar, Callable, cast, overload, Optional 17 | 18 | import vapoursynth as vs 19 | 20 | F = TypeVar('F', bound=Callable) 21 | T = TypeVar('T') 22 | R = TypeVar('R') 23 | 24 | 25 | def _check_variable( 26 | function: F, vname: str, only_first: bool, check_func: Callable[[vs.VideoNode], bool] 27 | ) -> Any: 28 | def _check(x: Any) -> bool: 29 | return isinstance(x, vs.VideoNode) and check_func(x) 30 | 31 | @wraps(function) 32 | def _wrapper(*args: Any, **kwargs: Any) -> Any: 33 | for obj in args[:1] if only_first else [*args, *kwargs.values()]: 34 | if _check(obj): 35 | raise ValueError( 36 | f"{function.__name__}: 'Variable-{vname} clips not supported.'" 37 | ) 38 | 39 | if not only_first: 40 | for name, param in inspect.signature(function).parameters.items(): 41 | if param.default is not inspect.Parameter.empty and _check(param.default): 42 | raise ValueError( 43 | f"{function.__name__}: 'Variable-{vname} clip not allowed in default argument `{name}`.'" 44 | ) 45 | 46 | return function(*args, **kwargs) 47 | 48 | return cast(F, _wrapper) 49 | 50 | 51 | @overload 52 | def disallow_variable_format(*, only_first: bool = False) -> Callable[[F], F]: 53 | ... 54 | 55 | 56 | @overload 57 | def disallow_variable_format(function: F | None = None, /) -> F: 58 | ... 59 | 60 | 61 | def disallow_variable_format(function: F | None = None, /, *, only_first: bool = False) -> Callable[[F], F] | F: 62 | """Function decorator that raises an exception if input clips have variable format. 63 | 64 | :param function: Function to wrap. 65 | :param only_first: Whether to check only the first argument or not. 66 | 67 | :return: Wrapped function. 68 | """ 69 | 70 | if function is None: 71 | return cast(Callable[[F], F], partial(disallow_variable_format, only_first=only_first)) 72 | 73 | assert function 74 | 75 | return _check_variable( 76 | function, 'format', only_first, lambda x: x.format is None 77 | ) 78 | 79 | 80 | @overload 81 | def disallow_variable_resolution(*, only_first: bool = False) -> Callable[[F], F]: 82 | ... 83 | 84 | 85 | @overload 86 | def disallow_variable_resolution(function: F | None = None, /) -> F: 87 | ... 88 | 89 | 90 | def disallow_variable_resolution(function: F | None = None, /, *, only_first: bool = False) -> Callable[[F], F] | F: 91 | """Function decorator that raises an exception if input clips have variable resolution. 92 | 93 | :param function: Function to wrap. 94 | :param only_first: Whether to check only the first argument or not. 95 | 96 | :return: Wrapped function. 97 | """ 98 | 99 | if function is None: 100 | return cast(Callable[[F], F], partial(disallow_variable_resolution, only_first=only_first)) 101 | 102 | assert function 103 | 104 | return _check_variable( 105 | function, 'format', only_first, lambda x: not all({x.width, x.height}) 106 | ) 107 | 108 | 109 | def fallback(value: Optional[T], fallback_value: T) -> T: 110 | """Utility function that returns a value or a fallback if the value is ``None``. 111 | 112 | >>> fallback(5, 6) 113 | 5 114 | >>> fallback(None, 6) 115 | 6 116 | 117 | :param value: Argument that can be ``None``. 118 | :param fallback_value: Fallback value that is returned if `value` is ``None``. 119 | 120 | :return: The input `value` or `fallback_value` if `value` is ``None``. 121 | """ 122 | return fallback_value if value is None else value 123 | 124 | 125 | def iterate(base: T, function: Callable[[Union[T, R]], R], count: int) -> Union[T, R]: 126 | """Utility function that executes a given function a given number of times. 127 | 128 | >>> def double(x): 129 | ... return x * 2 130 | ... 131 | >>> iterate(5, double, 2) 132 | 20 133 | 134 | :param base: Initial value. 135 | :param function: Function to execute. 136 | :param count: Number of times to execute `function`. 137 | 138 | :return: `function`'s output after repeating `count` number of times. 139 | """ 140 | if count < 0: 141 | raise ValueError('Count cannot be negative.') 142 | 143 | v: Union[T, R] = base 144 | for _ in range(count): 145 | v = function(v) 146 | return v 147 | 148 | 149 | # This function is actually implemented as a class. 150 | # This makes sure that, 151 | # when it is used as the value of a class-variable, 152 | # python does not prefix calls to this function with ``self``. 153 | # It also allows to forward calls to 154 | # - `plugin`, 155 | # - `signature`, 156 | # - and `return_signature` 157 | # to the current `vapoursynth.Function`-instance. 158 | class function: 159 | """This function aliases arbitrary vapoursynth plugin functions so that you can alias them on module-level. 160 | 161 | >>> import vapoursynth as vs 162 | >>> Point = vs.core.resize.Point # This is illegal as might crash vsscript-based previewers. 163 | >>> Point = function("resize", "Point") # Equivalent, but always uses the correct core. 164 | 165 | The result of function is safe to use within a class-definition. 166 | It behaves like a static-method in this case. 167 | 168 | :param plugin: The name of the plugin that provides the function. 169 | :param name: The name of the function to alias. 170 | 171 | :return: A wrapper function around the given plugin function. 172 | """ 173 | 174 | def __init__(self, plugin: str, name: str): 175 | self.plugin_name = plugin 176 | self.name = name 177 | 178 | @property 179 | def plugin(self) -> vs.Plugin: 180 | """The `Plugin` object the function belongs to. 181 | """ 182 | return getattr(vs.core, self.plugin_name) 183 | 184 | @property 185 | def resolved(self) -> vs.Function: 186 | """Returns the instance of function 187 | """ 188 | return getattr(self.plugin, self.name) 189 | 190 | @property 191 | def signature(self) -> str: 192 | """Raw function signature string. Identical to the string used to register the function. 193 | """ 194 | return self.resolved.signature 195 | 196 | @property 197 | def return_signature(self) -> str: 198 | """Raw function signature string. Identical to the return type string used to register the function. 199 | """ 200 | return self.resolved.return_signature 201 | 202 | def __call__(self, *args: Any, **kwargs: Any) -> Any: 203 | return self.resolved(*args, **kwargs) 204 | 205 | -------------------------------------------------------------------------------- /vsutil/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions that give information about clips or mathematical helpers. 3 | """ 4 | __all__ = ['get_depth', 'get_plane_size', 'get_subsampling', 'get_w', 'is_image', 'scale_value', 'get_lowest_value', 'get_neutral_value', 'get_peak_value'] 5 | 6 | from mimetypes import types_map 7 | from os import path 8 | from typing import Optional, Tuple, TypeVar, Union 9 | 10 | import vapoursynth as vs 11 | 12 | from . import func, types 13 | 14 | core = vs.core 15 | 16 | R = TypeVar('R') 17 | T = TypeVar('T') 18 | 19 | 20 | @func.disallow_variable_format 21 | def get_depth(clip: vs.VideoNode, /) -> int: 22 | """Returns the bit depth of a VideoNode as an integer. 23 | 24 | >>> src = vs.core.std.BlankClip(format=vs.YUV420P10) 25 | >>> get_depth(src) 26 | 10 27 | 28 | :param clip: Input clip. 29 | 30 | :return: Bit depth of the input `clip`. 31 | """ 32 | return clip.format.bits_per_sample 33 | 34 | 35 | def get_plane_size(frame: Union[vs.VideoFrame, vs.VideoNode], /, planeno: int) -> Tuple[int, int]: 36 | """Calculates the dimensions (width, height) of the desired plane. 37 | 38 | >>> src = vs.core.std.BlankClip(width=1920, height=1080, format=vs.YUV420P8) 39 | >>> get_plane_size(src, 0) 40 | (1920, 1080) 41 | >>> get_plane_size(src, 1) 42 | (960, 540) 43 | 44 | :param frame: Can be a clip or frame. 45 | :param planeno: The desired plane's index. 46 | 47 | :return: Tuple of width and height of the desired plane. 48 | """ 49 | # Add additional checks on VideoNodes as their size and format can be variable. 50 | if isinstance(frame, vs.VideoNode): 51 | if frame.width == 0: 52 | raise ValueError('Cannot calculate plane size of variable size clip. Pass a frame instead.') 53 | if frame.format is None: 54 | raise ValueError('Cannot calculate plane size of variable format clip. Pass a frame instead.') 55 | 56 | width, height = frame.width, frame.height 57 | if planeno != 0: 58 | width >>= frame.format.subsampling_w 59 | height >>= frame.format.subsampling_h 60 | return width, height 61 | 62 | 63 | @func.disallow_variable_format 64 | def get_subsampling(clip: vs.VideoNode, /) -> Union[None, str]: 65 | """Returns the subsampling of a VideoNode in human-readable format. 66 | Returns ``None`` for formats without subsampling. 67 | 68 | >>> src1 = vs.core.std.BlankClip(format=vs.YUV420P8) 69 | >>> get_subsampling(src1) 70 | '420' 71 | >>> src_rgb = vs.core.std.BlankClip(format=vs.RGB30) 72 | >>> get_subsampling(src_rgb) is None 73 | True 74 | 75 | :param clip: Input clip. 76 | 77 | :return: Subsampling of the input `clip` as a string (i.e. ``'420'``) or ``None``. 78 | """ 79 | if clip.format.color_family != vs.YUV: 80 | return None 81 | if clip.format.subsampling_w == 1 and clip.format.subsampling_h == 1: 82 | return '420' 83 | elif clip.format.subsampling_w == 1 and clip.format.subsampling_h == 0: 84 | return '422' 85 | elif clip.format.subsampling_w == 0 and clip.format.subsampling_h == 0: 86 | return '444' 87 | elif clip.format.subsampling_w == 2 and clip.format.subsampling_h == 2: 88 | return '410' 89 | elif clip.format.subsampling_w == 2 and clip.format.subsampling_h == 0: 90 | return '411' 91 | elif clip.format.subsampling_w == 0 and clip.format.subsampling_h == 1: 92 | return '440' 93 | else: 94 | raise ValueError('Unknown subsampling.') 95 | 96 | 97 | def get_w(height: int, aspect_ratio: float = 16 / 9, *, only_even: Optional[bool] = None, mod: Optional[int] = None) -> int: 98 | """Calculates the width for a clip with the given height and aspect ratio. 99 | 100 | >>> get_w(720) 101 | 1280 102 | >>> get_w(480) 103 | 854 104 | 105 | :param height: Input height. 106 | :param aspect_ratio: Aspect ratio for the calculation. (Default: ``16/9``) 107 | :param only_even: Will return the nearest even integer. 108 | ``True`` by default because it imitates the math behind most standard resolutions 109 | (e.g. 854x480). 110 | This parameter has been deprecated in favor of the ``mod`` param. For old behavior 111 | use ``mod=2`` for ``True`` and ``mod=1`` for ``False`` 112 | :param mod: Ensure output is divisible by this number, for when subsampling or filter 113 | restrictions set specific requirements (e.g. 4 for interlaced content). 114 | Defaults to 2 to mimic the math behind most standard resolutions. 115 | Any values passed to this argument will override ``only_even`` behavior! 116 | 117 | :return: Calculated width based on input `height`. 118 | """ 119 | width = height * aspect_ratio 120 | if only_even is not None: 121 | import warnings 122 | warnings.warn("only_even is deprecated.", DeprecationWarning) 123 | 124 | mod = func.fallback(mod, 2 if only_even in [None, True] else 1) 125 | return round(width / mod) * mod 126 | 127 | 128 | def is_image(filename: str, /) -> bool: 129 | """Returns ``True`` if the filename refers to an image. 130 | 131 | :param filename: String representing a path to a file. 132 | 133 | :return: ``True`` if the `filename` is a path to an image file, otherwise ``False``. 134 | """ 135 | return types_map.get(path.splitext(filename)[-1], '').startswith('image/') 136 | 137 | 138 | def scale_value(value: Union[int, float], 139 | input_depth: int, 140 | output_depth: int, 141 | range_in: Union[int, types.Range] = 0, 142 | range: Optional[Union[int, types.Range]] = None, 143 | scale_offsets: bool = False, 144 | chroma: bool = False, 145 | ) -> Union[int, float]: 146 | """Scales a given numeric value between bit depths, sample types, and/or ranges. 147 | 148 | >>> scale_value(16, 8, 32, range_in=Range.LIMITED) 149 | 0.0730593607305936 150 | >>> scale_value(16, 8, 32, range_in=Range.LIMITED, scale_offsets=True) 151 | 0.0 152 | >>> scale_value(16, 8, 32, range_in=Range.LIMITED, scale_offsets=True, chroma=True) 153 | -0.5 154 | 155 | :param value: Numeric value to be scaled. 156 | :param input_depth: Bit depth of the `value` parameter. Use ``32`` for float sample type. 157 | :param output_depth: Bit depth to scale the input `value` to. 158 | :param range_in: Pixel range of the input `value`. No clamping is performed. See :class:`Range`. 159 | :param range: Pixel range of the output `value`. No clamping is performed. See :class:`Range`. 160 | :param scale_offsets: Whether or not to apply YUV offsets to float chroma and/or TV range integer values. 161 | (When scaling a TV range value of ``16`` to float, setting this to ``True`` will return ``0.0`` 162 | rather than ``0.073059...``) 163 | :param chroma: Whether or not to treat values as chroma instead of luma. 164 | 165 | :return: Scaled numeric value. 166 | """ 167 | range_in = types.resolve_enum(types.Range, range_in, 'range_in', scale_value) 168 | range = types.resolve_enum(types.Range, range, 'range', scale_value) 169 | range = func.fallback(range, range_in) 170 | 171 | if input_depth == 32: 172 | range_in = 1 173 | 174 | if output_depth == 32: 175 | range = 1 176 | 177 | def peak_pixel_value(bits: int, range_: Union[int, types.Range], chroma_: bool) -> int: 178 | """ 179 | _ 180 | """ 181 | if bits == 32: 182 | return 1 183 | if range_: 184 | return (1 << bits) - 1 185 | return (224 if chroma_ else 219) << (bits - 8) 186 | 187 | input_peak = peak_pixel_value(input_depth, range_in, chroma) 188 | 189 | output_peak = peak_pixel_value(output_depth, range, chroma) 190 | 191 | if input_depth == output_depth and range_in == range: 192 | return value 193 | 194 | if scale_offsets: 195 | if output_depth == 32 and chroma: 196 | value -= 128 << (input_depth - 8) 197 | elif range and not range_in: 198 | value -= 16 << (input_depth - 8) 199 | 200 | value *= output_peak / input_peak 201 | 202 | if scale_offsets: 203 | if input_depth == 32 and chroma: 204 | value += 128 << (output_depth - 8) 205 | elif range_in and not range: 206 | value += 16 << (output_depth - 8) 207 | 208 | return value 209 | 210 | 211 | @func.disallow_variable_format 212 | def get_lowest_value(clip: vs.VideoNode, chroma: bool = False) -> float: 213 | """Returns the lowest possible value for the combination 214 | of the plane type and bit depth/type of the clip as float. 215 | 216 | :param clip: Input clip. 217 | :param chroma: Whether to get luma (default) or chroma plane value. 218 | 219 | :return: Lowest possible value. 220 | """ 221 | is_float = clip.format.sample_type == vs.FLOAT 222 | 223 | return -0.5 if chroma and is_float else 0. 224 | 225 | 226 | @func.disallow_variable_format 227 | def get_neutral_value(clip: vs.VideoNode, chroma: bool = False) -> float: 228 | """Returns the neutral value for the combination 229 | of the plane type and bit depth/type of the clip as float. 230 | 231 | :param clip: Input clip. 232 | :param chroma: Whether to get luma (default) or chroma plane value. 233 | 234 | :return: Neutral value. 235 | """ 236 | is_float = clip.format.sample_type == vs.FLOAT 237 | 238 | return (0. if chroma else 0.5) if is_float else float(1 << (get_depth(clip) - 1)) 239 | 240 | 241 | @func.disallow_variable_format 242 | def get_peak_value(clip: vs.VideoNode, chroma: bool = False) -> float: 243 | """Returns the highest possible value for the combination 244 | of the plane type and bit depth/type of the clip as float. 245 | 246 | :param clip: Input clip. 247 | :param chroma: Whether to get luma (default) or chroma plane value. 248 | 249 | :return: Highest possible value. 250 | """ 251 | is_float = clip.format.sample_type == vs.FLOAT 252 | 253 | return (0.5 if chroma else 1.) if is_float else (1 << get_depth(clip)) - 1. 254 | -------------------------------------------------------------------------------- /vsutil/py.typed: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /vsutil/types.py: -------------------------------------------------------------------------------- 1 | """ 2 | Enums and related functions. 3 | """ 4 | __all__ = ['Dither', 'Range', 'EXPR_VARS', 'resolve_enum'] 5 | 6 | # this file only depends on the stdlib and should stay that way 7 | from enum import Enum 8 | from typing import Any, Callable, Optional, Type, TypeVar, Union 9 | 10 | E = TypeVar('E', bound=Enum) 11 | 12 | 13 | class _NoSubmoduleRepr: 14 | def __repr__(self): 15 | """Removes submodule name from standard repr, helpful since we re-export everything at the top-level.""" 16 | return '<%s.%s.%s: %r>' % (self.__module__.split('.')[0], self.__class__.__name__, self.name, self.value) 17 | 18 | 19 | class Dither(_NoSubmoduleRepr, str, Enum): 20 | """ 21 | Enum for `zimg_dither_type_e`. 22 | """ 23 | NONE = 'none' 24 | """Round to nearest.""" 25 | ORDERED = 'ordered' 26 | """Bayer patterned dither.""" 27 | RANDOM = 'random' 28 | """Pseudo-random noise of magnitude 0.5.""" 29 | ERROR_DIFFUSION = 'error_diffusion' 30 | """Floyd-Steinberg error diffusion.""" 31 | 32 | 33 | class Range(_NoSubmoduleRepr, int, Enum): 34 | """ 35 | Enum for `zimg_pixel_range_e`. 36 | """ 37 | LIMITED = 0 38 | """Studio (TV) legal range, 16-235 in 8 bits.""" 39 | FULL = 1 40 | """Full (PC) dynamic range, 0-255 in 8 bits.""" 41 | 42 | 43 | EXPR_VARS: str = 'xyzabcdefghijklmnopqrstuvw' 44 | """ 45 | This constant contains a list of all variables that can appear inside an expr-string ordered 46 | by assignment. So the first clip will have the name *EXPR_VARS[0]*, the second one will 47 | have the name *EXPR_VARS[1]*, and so on. 48 | 49 | This can be used to automatically generate expr-strings. 50 | """ 51 | 52 | 53 | def _readable_enums(enum: Type[Enum]) -> str: 54 | """ 55 | Returns a list of all possible values in `enum`. 56 | Since VapourSynth imported enums don't carry the correct module name, use a special case for them. 57 | """ 58 | if 'importlib' in enum.__module__: 59 | return ', '.join([f'' for i in enum]) 60 | else: 61 | return ', '.join([repr(i) for i in enum]) 62 | 63 | 64 | def resolve_enum(enum: Type[E], value: Any, var_name: str, fn: Optional[Callable] = None) -> Union[E, None]: 65 | """ 66 | Attempts to evaluate `value` in `enum` if value is not ``None``, otherwise returns ``None``. 67 | 68 | >>> def my_fn(family: Optional[int]): 69 | ... return resolve_enum(vs.ColorFamily, family, 'family', my_fn) 70 | >>> my_fn(None) 71 | None 72 | >>> my_fn(3) 73 | 74 | >>> my_fn(9) 75 | ValueError: my_fn: family must be in , , ... 76 | 77 | :param enum: Enumeration, i.e. ``vapoursynth.ColorFamily``. 78 | :param value: Value to check. Can be ``None``. 79 | :param var_name: User-provided parameter name that needs to be checked. 80 | :param fn: Function that should be causing the exception (used for error message only). 81 | 82 | :return: The enum member or ``None``. 83 | """ 84 | if value is None: 85 | return None 86 | try: 87 | return enum(value) 88 | except ValueError: 89 | raise ValueError(f"{fn.__name__ + ': ' if fn else ''}{var_name} must be in {_readable_enums(enum)}.") from None 90 | --------------------------------------------------------------------------------