diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index dd99caf..e4cf4df 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -18,13 +18,16 @@ jobs: version: 1.7.0 - name: Installing dependencies... - run: poetry install --with dev,test + run: poetry install --no-interaction --with dev,test - name: Formatting check + if: always() run: poetry run black --check . - - name: Type checking - run: poetry run mypy . - - name: Linting + if: always() run: poetry run ruff . + + - name: Type checking + if: always() + run: poetry run mypy . diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 3d3c1b1..bf4355a 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -2,8 +2,8 @@ name: Publish documentation on: push: - branches: - - main + tags: + - "*" permissions: contents: write @@ -37,5 +37,5 @@ jobs: restore-keys: | mkdocs-material- - - run: poetry install --with docs + - run: poetry install --no-interaction --with docs - run: poetry run mkdocs gh-deploy --force diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 0000000..2e0ccc9 --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,23 @@ +name: Publish package to PyPi + +on: + release: + types: [published] + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - name: Install Poetry Action + uses: snok/install-poetry@v1.3.4 + with: + version: 1.7.0 + + - name: Publish to PyPi + run: poetry publish --build --username __token__ --password ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..d1f4e47 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: Unit tests + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + unit-tests: + runs-on: self-hosted + + concurrency: + group: uses-tvpaint + cancel-in-progress: true + + strategy: + fail-fast: true + max-parallel: 1 + matrix: + python-version: [python39, python310, python311, python312] + + env: + VENV_PATH: ${{ github.workspace }}/env_${{ matrix.python-version }} + PYTHON_EXE: C:/Users/usermuster/scoop/apps/${{ matrix.python-version }}/current/python.exe + + steps: + - uses: actions/checkout@v4 + + - name: Setup Poetry in venv + run: | + ${{ env.PYTHON_EXE }} -m venv ${{ env.VENV_PATH }} + ${{ env.VENV_PATH }}/Scripts/pip.exe install --quiet poetry + + - name: Installing dependencies with Poetry... + env: + POETRY_EXE: ${{ env.VENV_PATH }}/Scripts/poetry.exe + POETRY_VENV: ${{ github.workspace }}/.venv + shell: powershell + run: | + if (test-path ${{ env.POETRY_VENV }}) { rm -r ${{ env.POETRY_VENV }} } + ${{ env.POETRY_EXE }} config virtualenvs.in-project true + ${{ env.POETRY_EXE }} install --no-interaction --with test + + - name: Pytest with coverage + run: ${{ env.VENV_PATH }}/Scripts/poetry.exe run pytest --maxfail 5 --cov=pytvpaint diff --git a/README.md b/README.md index de930f2..0ffccd0 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,17 @@ -# Pytvpaint 🐍 → 🦋 +# PyTVPaint 🐍 → 🦋 [![](https://img.shields.io/github/actions/workflow/status/brunchstudio/pytvpaint/docs-deploy.yml?label=docs)](https://brunchstudio.github.io/pytvpaint/) [![](https://img.shields.io/github/license/brunchstudio/pytvpaint)](https://github.com/brunchstudio/pytvpaint/blob/main/LICENSE.md) [![](https://img.shields.io/pypi/v/pytvpaint)](https://pypi.org/project/pytvpaint/) [![Downloads](https://static.pepy.tech/badge/pytvpaint/month)](https://pepy.tech/project/pytvpaint) [![](https://img.shields.io/pypi/pyversions/pytvpaint)](https://pypi.org/project/pytvpaint/) +[![](https://custom-icon-badges.demolab.com/badge/custom-11.5+-blue.svg?logo=butterfly_1f98b&label=TVPaint)](https://www.tvpaint.com/doc/tvp11/)

-**Pytvpaint** is a type-safe Python library that wraps the George programming language commands in order to interact with the 2D animation software TVPaint. +**PyTVPaint** is a type-safe Python library that wraps the George programming language commands in order to interact with the 2D animation software TVPaint. It communicates through WebSocket to a [custom C++ plugin](https://github.com/brunchstudio/tvpaint-rpc) running in an opened TVPaint instance. @@ -18,9 +19,14 @@ You can check the [documentation](https://brunchstudio.github.io/pytvpaint/) for ## Installation -First install the [TVPaint RPC plugin](https://brunchstudio.github.io/pytvpaint/installation/#tvpaint-plugin-installation). +### Requirements -Then install the package with Pip: +- Windows (for now, see [this](https://brunchstudio.github.io/pytvpaint/limitations/#windows-only)) +- Python v3.9+ +- TVPaint v11.5+ +- TVPaint RPC plugin (install instructions [here](https://brunchstudio.github.io/pytvpaint/installation/#tvpaint-plugin-installation)) + +Install the package with Pip: ```console ❯ pip install pytvpaint @@ -59,7 +65,17 @@ project.close() Pull requests are welcome. For major changes, please [open an issue](https://github.com/brunchstudio/pytvpaint/issues/new/choose) first to discuss what you would like to change. -Please make sure to update tests as appropriate. +Please make sure to [update tests](https://brunchstudio.github.io/pytvpaint/contributing/developer_setup/#unit-tests) as appropriate. + +## Disclaimer + +PyTVPaint is a project created at BRUNCH Studio to facilitate our developer experience with George. The API is targeted at experienced developers and is by no means a replacement for TVPaint or George but simply builds on it. + +We are not affiliated with the TVPaint development team and therefore can't fix any bugs in the software or the George API. + +Please direct your issues appropriately; any issues with PyTVPaint should be submitted as [an issue in this repository](https://github.com/brunchstudio/pytvpaint/issues) or the [C++ plugin's repository](https://github.com/brunchstudio/tvpaint-rpc), any issues with TVPaint the software should be addressed to the [tvp support team](https://tvpaint.odoo.com/en_US/contactus). + +For any questions on the limitations of our API, please head to [this page](https://brunchstudio.github.io/pytvpaint/limitations/). ## License @@ -67,4 +83,6 @@ Please make sure to update tests as appropriate.
-Made with ❤️ at [BRUNCH Studio](https://brunchstudio.tv/) 🥐🍳 +Made with ❤️ at + +[](https://brunchstudio.tv/) diff --git a/docs/assets/logo_brunch_black.svg b/docs/assets/logo_brunch_black.svg new file mode 100644 index 0000000..511f8ed --- /dev/null +++ b/docs/assets/logo_brunch_black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/assets/logo_brunch_white.svg b/docs/assets/logo_brunch_white.svg new file mode 100644 index 0000000..6d15511 --- /dev/null +++ b/docs/assets/logo_brunch_white.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/contributing/developer_setup.md b/docs/contributing/developer_setup.md new file mode 100644 index 0000000..9a04c83 --- /dev/null +++ b/docs/contributing/developer_setup.md @@ -0,0 +1,145 @@ +# Developer setup + +This guide will explain how to setup your environment in order to contribute to PyTVPaint. + +## Requirements + +- [Python](https://www.python.org/) 3.9 or greater is the supported version for PyTVPaint. + +- We use [Poetry](https://python-poetry.org/) which is the packaging and dependency management tool. It handles your dev virtualenv with your working dependencies. + +## PyTVPaint + +First clone the repository: + +```shell +❯ git clone https://github.com/brunchstudio/pytvpaint.git + +# or if you use SSH auth +❯ git clone git@github.com:brunchstudio/pytvpaint.git +``` + +Then install the dependencies in a virtualenv with Poetry: + +```shell +❯ poetry install +``` + +Note that this will only install the library dependency. To install optional [dependency groups](https://python-poetry.org/docs/managing-dependencies/#dependency-groups) (to build the documentation, run tests, etc...) you can use the `--with` parameter: + +```shell +❯ poetry install --with dev,docs,test +``` + +### Code formatting + +We use [Black](https://black.readthedocs.io/en/stable/) to ensure that the code format is always the same. Black has strong defaults and is easy to use: + +```shell +# Will format all the .py files in the current directory +(venv) ❯ black . + +# To only check if the format is correct +(venv) ❯ black --check . +``` + +!!! Tip + + Use `poetry shell` to enter a new shell in the virtualenv. In this page commands marked `(venv) ❯` can also be run with `poetry run ` + +### Linting + +We use [Ruff](https://docs.astral.sh/ruff/) which is a super powerful and fast Python linter. It combines a lot of rules from other projects like Flake8, pyupgrade, pydocstyle, isort, etc... + +```shell +(venv) ❯ ruff . + +# Will apply autofixes +(venv) ❯ ruff --fix . +``` + +### Type checking + +Mypy is the go-to static type checker for Python. It ensures that variables and functions are used correctly and can catch refactor errors when modify some code. + +```shell +(venv) ❯ mypy . +``` + +!!! info + + We currently exclude [Fileseq](https://github.com/justinfx/fileseq) and [websocket-client](https://github.com/websocket-client/websocket-client) untyped calls in [`pyproject.toml`](https://github.com/brunchstudio/pytvpaint/blob/main/pyproject.toml) with [`untyped_calls_exclude`](https://mypy.readthedocs.io/en/stable/config_file.html#untyped-definitions-and-calls) + +### Documentation + +The documentation is built using [MkDocs](https://www.mkdocs.org/) which is a static site generator that uses Markdown as the source format. + +On top of that we use [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) which provide the beautiful Material look and other nice features. + +You can either run the development server or build the entire documentation: + +```shell +# Will serve the doc on http://127.0.0.1:8000 with hot reload +(venv) ❯ mkdocs serve + +# Build the doc as static files +(venv) ❯ mkdocs build +``` + +The [Python API documentation](https://brunchstudio.github.io/pytvpaint/api/objects/project/) is auto-generated from the docstrings in the code by using [mkdocstrings](https://mkdocstrings.github.io/). We use the [Google style](https://mkdocstrings.github.io/griffe/docstrings/#google-style) for docstrings. + +For example: + +```python +def tv_request(msg: str, confirm_text: str = "Yes", cancel_text: str = "No") -> bool: + """Open a confirmation prompt with a message. + + Args: + msg: the message to display + confirm_text: the confirm button text. Defaults to "Yes". + cancel_text: the cancel button text. Defaults to "No". + + Returns: + bool: True if clicked on "Yes" + """ + return bool(int(send_cmd("tv_Request", msg, confirm_text, cancel_text))) +``` + +### Unit tests + +For the unit tests, we use [Pytest](https://docs.pytest.org/). Fixtures are located in the `conftest.py` file. + +To run the tests you'll need an opened TVPaint instance with the [tvpaint-rpc plugin](https://github.com/brunchstudio/tvpaint-rpc) installed. + +To run them, use the following commands: + +```shell +# Will run all the tests +(venv) ❯ pytest + +# Run with verbosity enabled (use PYTVPAINT_LOG_LEVEL to DEBUG) to see George commands +(venv) ❯ pytest -v -s + +# Only run specific tests with pattern matching +(venv) ❯ pytest -k test_tv_clip + +# See the coverage statistics with pytest-cov +(venv) ❯ pytest --cov=pytvpaint +``` + +### Publishing to PyPi + +There's two ways to publish the package to PyPi. + +The best way is to use the PyPi [API token](https://pypi.org/help/#apitoken): + +```shell +❯ poetry config pypi-token.pypi +❯ poetry publish --build +``` + +In CI, we use the token from secrets directly: + +```shell +❯ poetry publish --build --username __token__ --password ${{ secrets.PYPI_TOKEN }} +``` diff --git a/docs/contributing/how_it_works.md b/docs/contributing/internals.md similarity index 83% rename from docs/contributing/how_it_works.md rename to docs/contributing/internals.md index 5b8a4d1..e6ef877 100644 --- a/docs/contributing/how_it_works.md +++ b/docs/contributing/internals.md @@ -1,17 +1,17 @@ -# How it works +# How it works internally -The following diagram shows how each processes interact to make Pytvpaint work: +The following diagram shows how each processes interact to make PyTVPaint work: ```mermaid sequenceDiagram - participant Pytvpaint + participant PyTVPaint participant TVPaint RPC server participant TVPaint - Pytvpaint->>TVPaint RPC server: {"jsonrpc": "2.0", "method": "execute_george", "params": ["tv_version"], "id": 45} + PyTVPaint->>TVPaint RPC server: {"jsonrpc": "2.0", "method": "execute_george", "params": ["tv_version"], "id": 45} TVPaint RPC server->>TVPaint: TVSendCmd(_, "tv_Version", buf) TVPaint->>TVPaint RPC server: '"TVP Animation 11 Pro" 11.7.1 en' - TVPaint RPC server->>Pytvpaint: {'id': 45, 'jsonrpc': '2.0', 'result': '"TVP Animation 11 Pro" 11.7.1 en'} + TVPaint RPC server->>PyTVPaint: {'id': 45, 'jsonrpc': '2.0', 'result': '"TVP Animation 11 Pro" 11.7.1 en'} ``` 1. The end user calls [`tv_version()`](../api/george/misc.md#pytvpaint.george.grg_base.tv_version) from `pytvpaint.george` in Python. @@ -20,4 +20,4 @@ sequenceDiagram 4. The JSON-RPC client sends the serialized JSON payload to the server `self.ws_handle.send(json.dumps(payload))` 5. The C++ plugin receives the message and [store it in the George commands queue](https://github.com/brunchstudio/tvpaint-rpc/blob/main/src/server.cpp#L59). 6. George commands are executed in the main thread, so at each plugin tick we check if we have commands to execute, execute them with the C++ SDK function `TVSendCmd` and [send back the result](https://github.com/brunchstudio/tvpaint-rpc/blob/main/src/main.cpp#L110). -7. We get back the result in Pytvpaint and use the `tv_parse_list` function to parse the resulting string from George and return a tuple from `tv_version`. +7. We get back the result in PyTVPaint and use the `tv_parse_list` function to parse the resulting string from George and return a tuple from `tv_version`. diff --git a/docs/contributing/modify_objects.md b/docs/contributing/modify_objects.md index 1e31edc..170beaa 100644 --- a/docs/contributing/modify_objects.md +++ b/docs/contributing/modify_objects.md @@ -88,7 +88,7 @@ def scenes(self) -> Iterator[Scene]: !!! Tip - Most of the `Iterator` in Pytvpaint are generators which mean it will only get the data (and send requests to TVPaint) if you want the next element. + Most of the `Iterator` in PyTVPaint are generators which mean it will only get the data (and send requests to TVPaint) if you want the next element. So you can stop whenever you want: diff --git a/docs/contributing/wrap_george.md b/docs/contributing/wrap_george.md index b4e345f..aba163b 100644 --- a/docs/contributing/wrap_george.md +++ b/docs/contributing/wrap_george.md @@ -173,7 +173,7 @@ Notes: ## Command that get and set a value -Some George commands are a getter and a setter at the same time. In Pytvpaint, we split the command in two separate functions with `_get` and `_set` suffix. +Some George commands are a getter and a setter at the same time. In PyTVPaint, we split the command in two separate functions with `_get` and `_set` suffix. !!! Example diff --git a/docs/cpp/index.md b/docs/cpp/index.md index cdd60f2..11cb03d 100644 --- a/docs/cpp/index.md +++ b/docs/cpp/index.md @@ -1,4 +1,4 @@ -# Pytvpaint's C++ plugin +# PyTVPaint's C++ plugin To communicate with TVPaint, we developed a TVPaint plugin using C++ and their SDK. diff --git a/docs/credits.md b/docs/credits.md index e126fee..f1b60cf 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -1,17 +1,17 @@ # Credits -## 💻 Brunch Dev Team +## :computer: Brunch Dev Team - [:simple-github:](https://github.com/rlahmidi) Radouane Lahmidi - [:simple-github:](https://github.com/jhenrybrunch) Joseph Henry - [:simple-firefoxbrowser:](https://www.chloeoternaud.com/about) Chloe Oternaud - [:simple-github:](https://github.com/aprayez) Alexis Prayez -## 🙏 Special Thanks +## :pray: Special Thanks ### Brunch Studio for supporting the project -- Fabin Cellier : Head of Studio [:simple-linkedin:](https://www.linkedin.com/in/fabien-cellier-03545826/) +- Fabien Cellier : Head of Studio [:simple-linkedin:](https://www.linkedin.com/in/fabien-cellier-03545826/) - Jean-Charles Kerninon : Head of CG [:simple-linkedin:](https://www.linkedin.com/in/jean-charles-kerninon-14309b3/) - Emilie Revert : Head of 2D Productions [:simple-linkedin:](https://www.linkedin.com/in/emilie-revert-236279109/) @@ -21,13 +21,13 @@ - Also thanks to [Jakub Trllo](https://www.linkedin.com/in/jakub-trllo-751387a6/) from Ynput who helped with the C++ implementation on their Discord server. - The TVPaint dev team for their patience and help with our questions and the [George commands documentation](https://www.tvpaint.com/doc/tvpaint-animation-11/george-commands) from TVPaint. -### Logo +## :snake: Logo ![](./assets/pytvpaint_logo.png){ width="300" } -The official Pytvpaint logo was made by Juliette Danesi ( [:simple-linkedin:](https://www.linkedin.com/in/juliette-danesi-1427561b6/) [:link:](https://juliettedanesi.wixsite.com/monsite/layout-posing) ) at BRUNCH Studio. :sparkles: +The official PyTVPaint logo was made by Juliette Danesi ( [:simple-linkedin:](https://www.linkedin.com/in/juliette-danesi-1427561b6/) [:link:](https://juliettedanesi.wixsite.com/monsite/layout-posing) ) at BRUNCH Studio. :sparkles: -## 💎 Brunch gems +## :gem: Brunch gems A special thanks also to our wonderful artists at the studio who also submitted some very interesting logos for the project. diff --git a/docs/index.md b/docs/index.md index ef8ab84..16392ad 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,39 +1,40 @@ -# Pytvpaint 🐍 → 🦋 +# PyTVPaint 🐍 → 🦋 [![](https://img.shields.io/github/actions/workflow/status/brunchstudio/pytvpaint/docs-deploy.yml?label=docs)](https://brunchstudio.github.io/pytvpaint/) [![](https://img.shields.io/github/license/brunchstudio/pytvpaint)](https://github.com/brunchstudio/pytvpaint/blob/main/LICENSE.md) [![](https://img.shields.io/pypi/v/pytvpaint)](https://pypi.org/project/pytvpaint/) [![Downloads](https://static.pepy.tech/badge/pytvpaint/month)](https://pepy.tech/project/pytvpaint) [![](https://img.shields.io/pypi/pyversions/pytvpaint)](https://pypi.org/project/pytvpaint/) +[![](https://custom-icon-badges.demolab.com/badge/custom-11.5+-blue.svg?logo=butterfly_1f98b&label=TVPaint)](https://www.tvpaint.com/doc/tvp11/) -Pytvpaint is a library that allows you to script for [TVPaint](https://www.tvpaint.com/) in Python instead of [George](https://www.tvpaint.com/doc/tvp11/index.php?id=lesson-advanced-functions-george-introduction). +PyTVPaint is a library that allows you to script for [TVPaint](https://www.tvpaint.com/) in Python instead of [George](https://www.tvpaint.com/doc/tvp11/index.php?id=lesson-advanced-functions-george-introduction). -Python is the go-to language when it comes to scripting, Pytvpaint offers a high level object-oriented API as well as low-level George commands in a fully type-hinted library. +Python is the go-to language when it comes to scripting, PyTVPaint offers a high level object-oriented API as well as low-level George commands in a fully type-hinted library. -Pytvpaint communicates through WebSocket to a [custom C++ plugin](https://github.com/brunchstudio/tvpaint-rpc) running in an opened TVPaint instance. +PyTVPaint communicates through WebSocket to a [custom C++ plugin](https://github.com/brunchstudio/tvpaint-rpc) running in an opened TVPaint instance. ![](./assets/pytvpaint_code_banner.png) !!! warning - Pytvpaint only works on Windows for now (because of the C++ plugin, the python code is agnostic to the platform but hasn't yet been tested on other OSes). + PyTVPaint only works on Windows for now (because of the C++ plugin, the python code is agnostic to the platform but hasn't yet been tested on other OSes). Support for Linux and MacOS can be added later. If you're interested, please [submit an issue](https://github.com/brunchstudio/tvpaint-rpc/issues/new) or a pull request ! -## Why use Pytvpaint? +## Why use PyTVPaint? - **Coding in George is not optimal** - it produces hard to maintain code, has bugs and poor support in IDEs (except syntax highlighting in IDEs, for example [VSCode](https://marketplace.visualstudio.com/items?itemName=johhnry.vscode-george)). - **Fully documented** - all modules are fully documented and the george docstring is up-to-date, clearer and fixes some of the mistakes in TVPaints George documentation. -- **Fully type-hinted and tested API** - the library uses [MyPy](https://mypy.readthedocs.io) to strictly check the Python code, [Ruff](https://docs.astral.sh/ruff/) to lint and detect errors and [Pytest](https://docs.pytest.org) with ~2500 unit tests and has a test coverage of more than 90%. +- **Fully type-hinted and tested API** - the library uses [MyPy](https://mypy.readthedocs.io) to strictly check the Python code, [Ruff](https://docs.astral.sh/ruff/) to lint and detect errors and [Pytest](https://docs.pytest.org) with 2000+ unit tests and has a test coverage of more than 90%. -- **Seamless coding experience** - no need to manually connect or disconnect to the WebSocket server, you can start coding directly and Pytvpaint will do everything for you! Just code in your favourite language (Python) and it will work! +- **Seamless coding experience** - no need to manually connect or disconnect to the WebSocket server, you can start coding directly and PyTVPaint will do everything for you! Just code in your favourite language (Python) and it will work! - **Fully extensible** - a George function wasn't implemented? You can either submit an issue on the repository or [code it yourself](./contributing/wrap_george.md)! We provide tools to directly speak in George with TVPaint and parse the resulting values. -- **Used in production** - Pytvpaint comes from a frustration of coding in the George programming language which made our codebase really hard to maintain here at [BRUNCH Studio](https://brunchstudio.tv/). It's now used in production to support our pipeline. +- **Used in production** - PyTVPaint comes from a frustration of coding in the George programming language which made our codebase really hard to maintain here at [BRUNCH Studio](https://brunchstudio.tv/). It's now used in production to support our pipeline. -## Pytvpaint examples +## PyTVPaint examples Get the name of all the layers in the current clip: @@ -84,3 +85,13 @@ from pytvpaint import Layer for instance in Layer.current_layer().instances: print(instance.start, instance.name) ``` + +## Disclaimer + +PyTVPaint is a project created at BRUNCH Studio to facilitate our developer experience with George. The API is targeted at experienced developers and is by no means a replacement for TVPaint or George but simply builds on it. + +We are not affiliated with the TVPaint development team and therefore can't fix any bugs in the software or the George API. + +Please direct your issues appropriately; any issues with PyTVPaint should be submitted as [an issue in this repository](https://github.com/brunchstudio/pytvpaint/issues) or the [C++ plugin's repository](https://github.com/brunchstudio/tvpaint-rpc), any issues with TVPaint the software should be addressed to the [tvp support team](https://tvpaint.odoo.com/en_US/contactus). + +For any questions on the limitations of our API, please head to [this page](limitations.md). diff --git a/docs/installation.md b/docs/installation.md index 55d937c..97ac55d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,7 +2,7 @@ ## TVPaint plugin installation -Pytvpaint works by sending George commands to a custom C++ plugin running a WebSocket server in TVPaint. +PyTVPaint works by sending George commands to a custom C++ plugin running a WebSocket server in TVPaint. You need to manually install it in your TVPaint installation folder. @@ -19,7 +19,7 @@ You need to manually install it in your TVPaint installation folder. You may need Administrator rights to copy the DLL file. -## Pytvpaint package +## PyTVPaint package Simply install it with Pip: diff --git a/docs/limitations.md b/docs/limitations.md index 1101f5d..89ba395 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,12 +1,12 @@ # Limitations -This page list all the current limitations of Pytvpaint in its current state. +This page list all the current limitations of PyTVPaint in its current state. ## Windows only As stated on the homepage, the [`tvpaint-rpc`](https://github.com/brunchstudio/tvpaint-rpc) C++ plugin is currently compiled for Windows only. -We are interested in making it available for Linux and MacOS, but being a Windows Studio we have not needed nor have we had time to do so yet. If you want to contribute on this, please open an issue or do a pull request on the [plugin repository](https://github.com/brunchstudio/tvpaint-rpc/issues). +We are interested in making it available for Linux and MacOS, but being a Windows Studio we have not needed nor had time to do so yet. If you want to contribute on this, please open an issue or do a pull request on the [plugin repository](https://github.com/brunchstudio/tvpaint-rpc/issues). ## Control characters in George results @@ -56,14 +56,15 @@ Therefore, it is currently impossible to determine if it's actually an antislash !!! Info - We contacted the TVPaint technical support and they'll see what they can do to fix it in the future. + The TVPaint dev team have been made aware of the issue, and we are hopeful that it will be fixed in the future. ## Misbehaving George functions Here is a list of the bugs/inconsistencies in the George commands: -| Method | Description | -| :------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------------------------------- | -| [`tv_ratio`](api/george/project.md#pytvpaint.george.grg_project.tv_ratio) | Always return an empty string (`""`) | -| [`tv_instance_name`](api/george/layer.md#pytvpaint.george.grg_layer.tv_instance_name) | Crashes if we give a wrong `layer_id` | -| `tv_camera_path` | Confusing arguments and seemingly incorrect results (see [this](https://forum.tvpaint.com/viewtopic.php?t=15677)) | +| Method | Description | +|:-------------------------------------------------------------------------------------|:------------------------------------------------------------------------------------------------------------------| +| [`tv_Ratio`](api/george/project.md#pytvpaint.george.grg_project.tv_ratio) | Always return an empty string (`""`) | +| [`tv_InstanceName`](api/george/layer.md#pytvpaint.george.grg_layer.tv_instance_name) | Crashes if we give a wrong `layer_id` | +| `tv_CameraPath` | Confusing arguments and seemingly incorrect results (see [this](https://forum.tvpaint.com/viewtopic.php?t=15677)) | +| `tv_SoundClipReload` | Doesn't accept a proper clip id, only `0` seem to work for the current clip | diff --git a/docs/overrides/partials/copyright.html b/docs/overrides/partials/copyright.html new file mode 100644 index 0000000..62c9632 --- /dev/null +++ b/docs/overrides/partials/copyright.html @@ -0,0 +1,16 @@ + diff --git a/docs/usage.md b/docs/usage.md index f41aa91..6638185 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,11 +1,23 @@ # Usage -Pytvpaint offers **two ways** to interact with TVPaint. +PyTVPaint offers **two ways** to interact with TVPaint. The recommended one is to use the [**object-oriented API**](#object-oriented-api) which handles all the nitty-gritty details of George and provide an extra layer of features. The classes can be imported from `pytvpaint.*` The other way is to use the [**wrapped George functions**](#george-functions) in Python which behaves almost extactly the same as real George commands. Those can be imported from `pytvpaint.george.*`. +## Environment variables + +Here are the environment variables that you can set for PyTVPaint: + +| Name | Default value | Description | +| :----------------------------- | :--------------- | :----------------------------------------------------------------------------------------------------------------------------- | +| `PYTVPAINT_LOG_LEVEL` | `INFO` | Changes the log level of PyTVPaint. Use the `DEBUG` value to see the RPC requests and responses for debugging George commands. | +| `PYTVPAINT_WS_HOST` | `ws://localhost` | The hostname of the RPC over WebSocket server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | +| `PYTVPAINT_WS_PORT` | `3000` | The port of the RPC over WebSocket server ([tvpaint-rpc](https://github.com/brunchstudio/tvpaint-rpc) plugin). | +| `PYTVPAINT_WS_STARTUP_CONNECT` | `1` / `True` | Wether or not PyTVPaint should automatically connect to the WebSocket server at startup (module import). | +| `PYTVPAINT_WS_TIMEOUT` | `60` seconds | The timeout after which we stop reconnecting at startup and if the connection was lost. | + ## Automatic client connection When you first import `pytvpaint`, a WebSocket client is automatically created and connects to the server runned by the [C++ plugin you installed](./installation.md). @@ -21,9 +33,13 @@ For example in an interactive Python shell: Set the `PYTVPAINT_LOG_LEVEL` environment variable to `INFO` to see the log above. +!!! tip + + You can disable this automatic behavior by setting the `PYTVPAINT_WS_STARTUP_CONNECT` variable to `0` if you want to control it yourself. + ## Object-oriented API -Pytvpaint provides a high level object-oriented API that handles the George calls behind the scene. Every element in TVPaint has its own object (`Project`, `Scene`, `Clip`, `Layer`...). +PyTVPaint provides a high level object-oriented API that handles the George calls behind the scene. Every element in TVPaint has its own object (`Project`, `Scene`, `Clip`, `Layer`...). ### Getting the current data in TVPaint diff --git a/mkdocs.yml b/mkdocs.yml index 208926e..aa957eb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,4 +1,4 @@ -site_name: Pytvpaint +site_name: PyTVPaint site_description: Python scripting for TVPaint strict: true site_url: https://brunchstudio.github.io/pytvpaint/ @@ -11,6 +11,7 @@ copyright: Made with ❤️ by BRUNCH Studio developers theme: name: material + custom_dir: docs/overrides logo: assets/pytvpaint_logo.png favicon: assets/pytvpaint_logo.png icon: @@ -35,12 +36,13 @@ extra: nav: - Get Started: - - Welcome to Pytvpaint: index.md + - Welcome to PyTVPaint: index.md - Installation: installation.md - Usage: usage.md - Limitations: limitations.md - Contributing: - - How it works: contributing/how_it_works.md + - Developer setup: contributing/developer_setup.md + - Internals: contributing/internals.md - Wrapping George commands: contributing/wrap_george.md - Modifying high-level classes: contributing/modify_objects.md - Credits: credits.md diff --git a/poetry.lock b/poetry.lock index 5327b52..d3e313d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -16,33 +16,33 @@ dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] [[package]] name = "black" -version = "24.2.0" +version = "24.3.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-24.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29"}, - {file = "black-24.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430"}, - {file = "black-24.2.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f"}, - {file = "black-24.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a"}, - {file = "black-24.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd"}, - {file = "black-24.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2"}, - {file = "black-24.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92"}, - {file = "black-24.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23"}, - {file = "black-24.2.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b"}, - {file = "black-24.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9"}, - {file = "black-24.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693"}, - {file = "black-24.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982"}, - {file = "black-24.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4"}, - {file = "black-24.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218"}, - {file = "black-24.2.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0"}, - {file = "black-24.2.0-cp38-cp38-win_amd64.whl", hash = "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d"}, - {file = "black-24.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8"}, - {file = "black-24.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8"}, - {file = "black-24.2.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540"}, - {file = "black-24.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31"}, - {file = "black-24.2.0-py3-none-any.whl", hash = "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6"}, - {file = "black-24.2.0.tar.gz", hash = "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894"}, + {file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"}, + {file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"}, + {file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"}, + {file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"}, + {file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"}, + {file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"}, + {file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"}, + {file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"}, + {file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"}, + {file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"}, + {file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"}, + {file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"}, + {file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"}, + {file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"}, + {file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"}, + {file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"}, + {file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"}, + {file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"}, + {file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"}, + {file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"}, + {file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"}, + {file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"}, ] [package.dependencies] @@ -197,63 +197,63 @@ files = [ [[package]] name = "coverage" -version = "7.4.3" +version = "7.4.4" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, - {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, - {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"}, - {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"}, - {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"}, - {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"}, - {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"}, - {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"}, - {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"}, - {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"}, - {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"}, - {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"}, - {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"}, - {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"}, - {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"}, - {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"}, - {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"}, - {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"}, - {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"}, - {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"}, - {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"}, - {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"}, - {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"}, - {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"}, - {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"}, - {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"}, - {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"}, - {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"}, - {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"}, - {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"}, - {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"}, - {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"}, - {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"}, - {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"}, - {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"}, - {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"}, - {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"}, - {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"}, - {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"}, - {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"}, - {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"}, - {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"}, - {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"}, - {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"}, - {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"}, - {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"}, - {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"}, - {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"}, - {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"}, - {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"}, - {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"}, - {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, ] [package.dependencies] @@ -381,13 +381,13 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "markdown" -version = "3.5.2" +version = "3.6" description = "Python implementation of John Gruber's Markdown." optional = false python-versions = ">=3.8" files = [ - {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, - {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, + {file = "Markdown-3.6-py3-none-any.whl", hash = "sha256:48f276f4d8cfb8ce6527c8f79e2ee29708508bf4d40aa410fbc3b4ee832c850f"}, + {file = "Markdown-3.6.tar.gz", hash = "sha256:ed4f41f6daecbeeb96e576ce414c41d2d876daa9a16cb35fa8ed8c2ddfad0224"}, ] [package.dependencies] @@ -526,13 +526,13 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "9.5.13" +version = "9.5.14" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.5.13-py3-none-any.whl", hash = "sha256:5cbe17fee4e3b4980c8420a04cc762d8dc052ef1e10532abd4fce88e5ea9ce6a"}, - {file = "mkdocs_material-9.5.13.tar.gz", hash = "sha256:d8e4caae576312a88fd2609b81cf43d233cdbe36860d67a68702b018b425bd87"}, + {file = "mkdocs_material-9.5.14-py3-none-any.whl", hash = "sha256:a45244ac221fda46ecf8337f00ec0e5cb5348ab9ffb203ca2a0c313b0d4dbc27"}, + {file = "mkdocs_material-9.5.14.tar.gz", hash = "sha256:2a1f8e67cda2587ab93ecea9ba42d0ca61d1d7b5fad8cf690eeaeb39dcd4b9af"}, ] [package.dependencies] @@ -595,18 +595,17 @@ python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] [[package]] name = "mkdocstrings-python" -version = "1.9.0" +version = "1.8.0" description = "A Python handler for mkdocstrings." optional = false python-versions = ">=3.8" files = [ - {file = "mkdocstrings_python-1.9.0-py3-none-any.whl", hash = "sha256:fad27d7314b4ec9c0359a187b477fb94c65ef561fdae941dca1b717c59aae96f"}, - {file = "mkdocstrings_python-1.9.0.tar.gz", hash = "sha256:6e1a442367cf75d30cf69774cbb1ad02aebec58bfff26087439df4955efecfde"}, + {file = "mkdocstrings_python-1.8.0-py3-none-any.whl", hash = "sha256:4209970cc90bec194568682a535848a8d8489516c6ed4adbe58bbc67b699ca9d"}, + {file = "mkdocstrings_python-1.8.0.tar.gz", hash = "sha256:1488bddf50ee42c07d9a488dddc197f8e8999c2899687043ec5dd1643d057192"}, ] [package.dependencies] griffe = ">=0.37" -markdown = ">=3.3,<3.6" mkdocstrings = ">=0.20" [[package]] @@ -1164,13 +1163,13 @@ test = ["websockets"] [[package]] name = "zipp" -version = "3.18.0" +version = "3.18.1" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.18.0-py3-none-any.whl", hash = "sha256:c1bb803ed69d2cce2373152797064f7e79bc43f0a3748eb494096a867e0ebf79"}, - {file = "zipp-3.18.0.tar.gz", hash = "sha256:df8d042b02765029a09b157efd8e820451045890acc30f8e37dd2f94a060221f"}, + {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, + {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, ] [package.extras] diff --git a/pyproject.toml b/pyproject.toml index 2dfb595..0a8a9cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,6 +47,9 @@ mkdocstrings = { extras = ["python"], version = "^0.24.0" } requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" +[tool.black] +target-version = ["py39", "py310", "py311", "py312"] + [tool.ruff] target-version = "py39" @@ -96,3 +99,4 @@ warn_return_any = true warn_unused_configs = true warn_unused_ignores = true extra_checks = true +untyped_calls_exclude = "fileseq,websocket" diff --git a/pytvpaint/__init__.py b/pytvpaint/__init__.py index d7fc5c6..9f38f33 100644 --- a/pytvpaint/__init__.py +++ b/pytvpaint/__init__.py @@ -1,4 +1,4 @@ -"""Pytvpaint package logger.""" +"""PyTVPaint package logger.""" from __future__ import annotations diff --git a/pytvpaint/clip.py b/pytvpaint/clip.py index af79d08..e2ab390 100644 --- a/pytvpaint/clip.py +++ b/pytvpaint/clip.py @@ -9,7 +9,7 @@ from fileseq.filesequence import FileSequence from fileseq.frameset import FrameSet -from pytvpaint import george +from pytvpaint import george, log from pytvpaint.camera import Camera from pytvpaint.layer import Layer, LayerColor from pytvpaint.sound import ClipSound @@ -410,6 +410,122 @@ def load_media( return new_layer + def _handle_output_range( # noqa: C901 + self, + output_path: Path | str | FileSequence, + start: int | None = None, + end: int | None = None, + use_camera: bool = False, + force_range: bool = False, + ) -> tuple[FileSequence, int, int, bool, bool]: + """Handle the different options for output paths and range, whether the user provided a range (start-end) or a filesequence with a range or not, this functions ensures we always end up with a valid range to render. + + Args: + output_path: user provided output path + start: user provided start frame + end: user provided end frame + use_camera: use the camera for rendering, otherwise render the whole canvas. Defaults to False. + force_range: force the provided range even if it isn't incorrect or might output wrong frames + + Raises: + ValueError: if output range (start/end) are inferior to clip start frame + + Bug: + TVPaint will not render the requested/correct range in some cases. Having no control over this inconsistent + behaviour and to avoid issues we will raise a ValueError if an invalid range is detected. You can however + force an incorrect range using `force_range=True`, we will then log a warning when this happens (to help + with debugging) and the function will render with your range without checking the output at the end. + For more details on the different issues with frame ranges and the timeline in TVPaint, please check the + `Limitations` section of the documentation which explains this in more detail. + + Returns: + file_sequence: output path as a FileSequence object + start: computed start frame + end: computed end frame + is_sequence: whether the output is a sequence or not + is_image: whether the output is an image or not (a movie) + """ + # we handle all outputs as a FileSequence, makes it a bit easier to handle ranges and padding + if not isinstance(output_path, FileSequence): + file_sequence = FileSequence(Path(output_path).as_posix()) + else: + file_sequence = output_path + + frame_set = file_sequence.frameSet() + is_image = george.SaveFormat.is_image(file_sequence.extension()) + + # if the provided sequence has a range, and we don't, use the sequence range + if frame_set and len(frame_set) >= 1 and is_image: + start = start or file_sequence.start() + end = end or file_sequence.end() + + # check characteristics of output path + fseq_has_range = frame_set and len(frame_set) > 1 + fseq_is_single_image = frame_set and len(frame_set) == 1 + fseq_no_range_padding = not frame_set and file_sequence.padding() + range_is_seq = start and end and start != end + range_is_single_image = start and end and start == end + + is_single_image = bool( + is_image + and (fseq_is_single_image or not frame_set) + and range_is_single_image + ) + is_sequence = bool( + is_image and (fseq_has_range or fseq_no_range_padding or range_is_seq) + ) + + # if no range provided, use clip mark in/out, if none, use clip start/end + start = start or self.mark_in or self.start + + end = ( + start + if (is_single_image and not end) + else (end or self.mark_out or self.end) + ) + + frame_set = FrameSet(f"{start}-{end}") + + if not file_sequence.padding() and is_image and len(frame_set) > 1: + file_sequence.setPadding("#") + + # we should have a range by now, set it in the sequence + if (is_image and not is_single_image) or file_sequence.padding(): + file_sequence.setFrameSet(frame_set) + + err_msg = "" + clip_start = self.start + clip_end = self.end + + if use_camera: + if start < clip_start or end > clip_end: + err_msg = ( + f"TVPaint will not render the full range requested ({start}-{end})" + ) + else: + if start < clip_start or end < clip_start: + err_msg = ( + f"TVPaint will not render frames before clip start ({clip_start})" + ) + elif start == clip_start and end > clip_end: + err_msg = ( + f"TVPaint will not render the full range requested ({start}-{end})" + ) + elif clip_start <= start < end and end > clip_end: + err_msg = f"TVPaint will render the full range requested ({start}-{end}) but frames will be empty" + + if not is_image and start == end: + err_msg = "TVPaint will not render a movie that contains a single frame" + + if err_msg: + err_msg += ", check the documentation for more details !" + if force_range: + log.warning(err_msg) + else: + raise ValueError(err_msg) + + return file_sequence, start, end, is_sequence, is_image + @set_as_current def render( self, @@ -420,6 +536,7 @@ def render( layer_selection: list[Layer] | None = None, alpha_mode: george.AlphaSaveMode = george.AlphaSaveMode.PREMULTIPLY, format_opts: list[str] | None = None, + force_range: bool = False, ) -> None: """Render the clip to a single frame or frame sequence. @@ -431,39 +548,42 @@ def render( layer_selection: list of layers to render, if None render all of them. Defaults to None. alpha_mode: the alpha mode for rendering. Defaults to george.AlphaSaveMode.PREMULTIPLY. format_opts: custom format options. Defaults to None. + force_range: force the provided range even if it isn't incorrect or might output wrong frames Raises: + ValueError: if output range (start/end) are inferior to clip start frame FileNotFoundError: if the render failed and no files were found on disk - """ - start = start or self.mark_in or self.start - end = end or self.mark_out or self.end - file_sequence = ( - output_path - if isinstance(output_path, FileSequence) - else FileSequence(Path(output_path).as_posix()) - ) - - frame_set = file_sequence.frameSet() - is_sequence = (frame_set and len(frame_set) > 1) or ( - not frame_set and file_sequence.padding() + Bug: + TVPaint will not render the requested/correct range in some cases. Having no control over this inconsistent + behaviour and to avoid issues we will raise a ValueError if an invalid range is detected. You can however + force an incorrect range using `force_range=True`, we will then log a warning when this happens (to help + with debugging) and the function will render with your range without checking the output at the end. + For more details on the different issues with frame ranges and the timeline in TVPaint, please check the + `Limitations` section of the documentation which explains this in more detail. + """ + file_sequence, start, end, is_sequence, is_image = self._handle_output_range( + output_path, start, end, use_camera, force_range ) - frame_set = FrameSet(f"{start}-{end}") - file_sequence.setFrameRange(frame_set) + # get project start to get real values, note that using the camera changes the way we handle ranges + project_start_frame = self.project.start_frame if not use_camera else 0 + # get clip real start to clamp start and end frames + clip_real_start = self.start - project_start_frame - # get real frame numbers and clamp them with clip start frame - project_start_frame = self.project.start_frame - real_start = self.start - project_start_frame - - start = max(real_start, (start - project_start_frame)) - end = max(real_start, (end - project_start_frame)) + start = max(clip_real_start, (start - project_start_frame)) + end = max(clip_real_start, (end - project_start_frame)) save_format = george.SaveFormat.from_extension( file_sequence.extension().lower() ) + # render to output - first_frame = Path(file_sequence.frame(file_sequence.start())) + if is_image or file_sequence.padding(): + first_frame = Path(file_sequence.frame(file_sequence.start())) + else: + first_frame = Path(str(output_path)) + first_frame.parent.mkdir(exist_ok=True, parents=True) with render_context(alpha_mode, save_format, format_opts, layer_selection): @@ -479,18 +599,22 @@ def render( else: george.tv_save_sequence(first_frame, mark_in=start, mark_out=end) + if force_range: + return # user is forcing a possibly invalid range, let them handle output check + # make sure the output exists otherwise raise an error if is_sequence: + # raises error if sequence not found found_sequence = FileSequence.findSequenceOnDisk(str(file_sequence)) frame_set = found_sequence.frameSet() + file_sequence_frame_set = file_sequence.frameSet() - if not frame_set: - raise ValueError() + if frame_set is None or file_sequence_frame_set is None: + raise Exception("Frameset should be defined") - # raises error if sequence not found if not frame_set.issuperset(file_sequence.frameSet()): # not all frames found - missing_frames = frame_set.difference(found_sequence.frameSet()) + missing_frames = file_sequence_frame_set.difference(frame_set) raise FileNotFoundError( f"Not all frames found, missing frames ({missing_frames}) " f"in sequence : {output_path}" @@ -678,6 +802,8 @@ def export_sprites( with render_context(alpha_mode, save_format, format_opts, layer_selection): george.tv_clip_save_structure_sprite(export_path, layout, space) + # TODO check whether output was successful and files exist or not for this function and the others + @set_as_current def export_flix( self, diff --git a/pytvpaint/george/client/__init__.py b/pytvpaint/george/client/__init__.py index 44c6ff6..4feb512 100644 --- a/pytvpaint/george/client/__init__.py +++ b/pytvpaint/george/client/__init__.py @@ -26,6 +26,7 @@ def _connect_client( port = int(os.getenv("PYTVPAINT_WS_PORT", port)) startup_connect = bool(os.getenv("PYTVPAINT_WS_STARTUP_CONNECT", 1)) timeout = int(os.getenv("PYTVPAINT_WS_TIMEOUT", timeout)) + if timeout == 0: timeout = -1 @@ -35,7 +36,7 @@ def _connect_client( wait_duration = 5 connection_successful = False - while ((time() - start_time) < timeout) and startup_connect: + while startup_connect and ((time() - start_time) < timeout): with contextlib.suppress(ConnectionRefusedError): rpc_client.connect() connection_successful = True @@ -44,7 +45,7 @@ def _connect_client( log.warning(f"Connection refused, trying again in {wait_duration} seconds...") sleep(wait_duration) - if not connection_successful and startup_connect: + if startup_connect and not connection_successful: # Connection could not be established after timeout if rpc_client.is_connected: rpc_client.disconnect() diff --git a/pytvpaint/george/client/rpc.py b/pytvpaint/george/client/rpc.py index 299bd1f..6fefb44 100644 --- a/pytvpaint/george/client/rpc.py +++ b/pytvpaint/george/client/rpc.py @@ -1,8 +1,8 @@ """JSON-RPC client and data models.""" from __future__ import annotations -import contextlib +import contextlib import json import sys import threading @@ -74,7 +74,6 @@ def __init__(self, url: str, timeout: int = 60, version: str = "2.0") -> None: version: The JSON-RPC version. Defaults to "2.0". """ self.ws_handle = WebSocket() - self.ws_handle.settimeout(5) self.url = url self.rpc_id = 0 self.timeout = timeout @@ -88,21 +87,19 @@ def __init__(self, url: str, timeout: int = 60, version: str = "2.0") -> None: def _auto_reconnect(self) -> None: """Automatic WebSocket reconnection in a thread by pinging the server.""" while self.run_forever and not self.stop_ping.wait(1): - if self.is_connected: - continue - try: self.ws_handle.ping() + continue except (WebSocketException, ConnectionError): self.ws_handle.close() - with contextlib.suppress(ConnectionRefusedError): - self.connect() - log.info(f"Reconnected automatically to endpoint: {self.url}") - continue + with contextlib.suppress(ConnectionRefusedError): + self.connect() + log.info(f"Reconnected automatically to endpoint: {self.url}") + continue # There's a timeout after which we stop reconnecting - if self.timeout and time() - self._ping_start_time > self.timeout: + if self.timeout and (time() - self._ping_start_time) > self.timeout: raise ConnectionRefusedError( "Could not establish connection with a tvpaint instance before timeout !" ) @@ -159,7 +156,9 @@ def execute_remote( JSONRPCResponse: the JSON-RPC response payload """ if not self.is_connected: - raise ConnectionError("Can't send rpc message") + raise ConnectionError( + f"Can't send rpc message because the client is not connected to {self.url}" + ) payload: JSONRPCPayload = { "jsonrpc": self.jsonrpc_version, diff --git a/pytvpaint/george/grg_base.py b/pytvpaint/george/grg_base.py index 6cf3f1f..c845c4d 100644 --- a/pytvpaint/george/grg_base.py +++ b/pytvpaint/george/grg_base.py @@ -346,6 +346,30 @@ def from_extension(cls, extension: str) -> SaveFormat: ) return cast(SaveFormat, getattr(cls, extension.upper())) + @classmethod + def is_image(cls, extension: str) -> bool: + """Returns True if the extension correspond to an image format.""" + extension = extension.replace(".", "").lower() + image_formats = [ + "bmp", + "cin", + "deep", + "dpx", + "ilbm", + "jpg", + "jpeg", + "pcx", + "png", + "psd", + "sgi", + "pic", + "ras", + "sun", + "tga", + "tiff", + ] + return extension in image_formats + @dataclass(frozen=True) class RGBColor: @@ -609,6 +633,11 @@ def tv_version() -> tuple[str, str, str]: return software_name, version, language +def tv_quit() -> None: + """Closes the TVPaint instance.""" + send_cmd("tv_Quit") + + def tv_host2back() -> None: """Minimize the TVPaint window.""" send_cmd("tv_Host2Back") @@ -624,6 +653,11 @@ def tv_menu_hide() -> None: send_cmd("tv_MenuHide") +def add_some_magic() -> None: + """Makes your life sweeter (maybe).""" + send_cmd("tv_MagicNumber", 23) + + def tv_menu_show( menu_element: MenuElement | None = None, *menu_options: Any, current: bool = False ) -> None: @@ -1091,3 +1125,17 @@ def tv_rect_fill( if tool_mode: args.insert(0, "toolmode") send_cmd("tv_RectFill", *args) + + +def tv_fast_line( + x1: float, + y1: float, + x2: float, + y2: float, + r: int = 255, + b: int = 255, + g: int = 0, + a: int = 255, +) -> None: + """Draw a line (1 pixel size and not antialiased).""" + send_cmd("tv_fastline", x1, y1, x2, y2, r, g, b, a) diff --git a/pytvpaint/george/grg_clip.py b/pytvpaint/george/grg_clip.py index c07afbe..17f6dae 100644 --- a/pytvpaint/george/grg_clip.py +++ b/pytvpaint/george/grg_clip.py @@ -625,14 +625,14 @@ def tv_sound_clip_remove(track_index: int) -> None: def tv_sound_clip_reload(clip_id: int, track_index: int) -> None: - """Reload a sound track from its file. + """Reload a soundtrack from its file. Args: clip_id: the clip id (only works with `0` being the current clip) track_index: the sound clip track index Warning: - It doesn't accept a proper clip id, only `0` seem to work for the current clip + this doesn't accept a proper clip id, only `0` seem to work for the current clip """ send_cmd("tv_SoundClipReload", clip_id, track_index, error_values=[-1, -2, -3]) diff --git a/pytvpaint/george/grg_project.py b/pytvpaint/george/grg_project.py index 3ddfaeb..244dc83 100644 --- a/pytvpaint/george/grg_project.py +++ b/pytvpaint/george/grg_project.py @@ -229,7 +229,7 @@ def tv_ratio() -> float: """Get the current project pixel aspect ratio. Bug: - Doesn't work and always return an empty string + Doesn't work and always returns an empty string """ return float(send_cmd("tv_GetRatio", error_values=[GrgErrorValue.EMPTY])) diff --git a/pytvpaint/layer.py b/pytvpaint/layer.py index dabd9e9..fad36cf 100644 --- a/pytvpaint/layer.py +++ b/pytvpaint/layer.py @@ -26,20 +26,21 @@ @contextlib.contextmanager -def restore_current_frame( - clip: Clip, frame: int | None = None -) -> Generator[None, None, None]: - """Context that changes the current frame temporarily and restores the previous value. +def restore_current_frame(clip: Clip, frame: int) -> Generator[None, None, None]: + """Context that temporarily changes the current frame to the one provided and restores it when done. Args: clip: clip to change frame: frame to set. Defaults to None. """ previous_frame = clip.current_frame - if frame: + if frame != previous_frame: clip.current_frame = frame + yield - clip.current_frame = previous_frame + + if clip.current_frame != previous_frame: + clip.current_frame = previous_frame @dataclass @@ -80,6 +81,35 @@ def duplicate(self) -> None: with restore_current_frame(self.layer.clip, self.start): george.tv_layer_insert_image(duplicate=True) + @classmethod + def new( + cls, + layer: Layer, + start: int | None, + nb_frames: int = 1, + direction: george.InsertDirection | None = None, + ) -> LayerInstance: + """Crates a new instance. + + Args: + layer: parent layer instance + start: start frame + nb_frames: number of frames in the new instance + direction: direction where new frames will be added/inserted + + Returns: + LayerInstance: new layer instance + """ + if not nb_frames: + raise ValueError("Instance number of frames must be at least 1") + start = start if start is not None else layer.clip.current_frame + + layer.make_current() + with restore_current_frame(layer.clip, start): + george.tv_layer_insert_image(count=nb_frames, direction=direction) + + return cls(layer, start) + @property def next(self) -> LayerInstance | None: """Returns the next instance. @@ -87,6 +117,7 @@ def next(self) -> LayerInstance | None: Returns: the next instance or None if at the end of the layer """ + self.layer.make_current() with restore_current_frame(self.layer.clip, self.start): next_frame = george.tv_exposure_next() @@ -99,6 +130,7 @@ def previous(self) -> LayerInstance | None: Returns: the previous instance, None if there isn't """ + self.layer.make_current() with restore_current_frame(self.layer.clip, self.start): prev_frame = george.tv_exposure_prev() @@ -645,7 +677,7 @@ def new_background_layer( clip: Clip | None = None, color: LayerColor | None = None, image: Path | str | None = None, - stretch: bool = False + stretch: bool = False, ) -> Layer: """Create a new background layer with hold as pre- and post-behavior. @@ -664,7 +696,7 @@ def new_background_layer( layer.post_behavior = george.LayerBehavior.HOLD layer.thumbnails_visible = True - image = Path(image or '') + image = Path(image or "") if image.is_file(): layer.load_image(image, stretch=stretch) @@ -709,16 +741,18 @@ def load_image( Raises: FileNotFoundError: if the file doesn't exist at provided path - """ image_path = Path(image_path) if not image_path.exists(): raise FileNotFoundError(f"Image not found at : {image_path}") - if frame is not None: - self.clip.current_frame = frame + frame = frame or self.clip.current_frame + with restore_current_frame(self.clip, frame): + # if no instance at the specified frame, then create a new one + if not self.get_instance(frame): + LayerInstance.new(self, frame) - george.tv_load_image(image_path.as_posix(), stretch) + george.tv_load_image(image_path.as_posix(), stretch) @set_as_current def render_frame( @@ -869,15 +903,22 @@ def instances(self) -> Iterator[LayerInstance]: # Exposure frames starts at 0 instance_frame = self.start - self.clip.current_frame = self.start - - while True: - instance = self.get_instance(instance_frame) - if instance is None: - break - yield instance - with restore_current_frame(self.clip, instance_frame): - instance_frame = george.tv_exposure_next() + project_start_frame + + with restore_current_frame(self.clip, self.start): + while True: + instance = self.get_instance(instance_frame) + if instance is None: + break + yield instance + + self.clip.current_frame = instance_frame + new_instance_frame = george.tv_exposure_next() + project_start_frame + + # In TVPaint 11.5 and before, tv_exposure_next returns the same frame if it's the last one + if new_instance_frame == instance_frame: + break + + instance_frame = new_instance_frame def get_instance(self, frame: int) -> LayerInstance | None: """Get the instance at that frame. diff --git a/pytvpaint/project.py b/pytvpaint/project.py index 9828bb1..1f4de26 100644 --- a/pytvpaint/project.py +++ b/pytvpaint/project.py @@ -494,13 +494,9 @@ def new_from_camera(self, export_path: Path | str | None = None) -> Project: def duplicate(self) -> Project: """Duplicate the project and return the new one.""" george.tv_project_duplicate() - - duplicated_name = f"{self.name}Copy" - for project in Project.open_projects(): - if project.name == duplicated_name: - return project - - raise Exception(f"Couldn't find project {duplicated_name}") + duplicated = Project.current_project() + self.make_current() + return duplicated def close(self) -> None: """Closes the project.""" @@ -508,12 +504,18 @@ def close(self) -> None: george.tv_project_close(self._id) @classmethod - def close_all(cls) -> None: - """Closes all the projects.""" - projects = list(cls.open_projects()) - for project in projects: + def close_all(cls, close_tvp: bool = False) -> None: + """Closes all open projects. + + Args: + close_tvp: close the TVPaint instance as well + """ + for project in list(cls.open_projects()): project.close() + if close_tvp: + george.tv_quit() + @classmethod def load(cls, project_path: Path | str, silent: bool = True) -> Project: """Load an existing .tvpp/.tvp project or .tvpx file.""" diff --git a/pytvpaint/sound.py b/pytvpaint/sound.py index 1976f27..889555d 100644 --- a/pytvpaint/sound.py +++ b/pytvpaint/sound.py @@ -239,6 +239,7 @@ def remove(self) -> None: george.tv_sound_clip_remove(self.track_index) self.mark_removed() + @set_as_current def reload(self) -> None: """Reload the sound from file.""" george.tv_sound_clip_reload(self._parent.id, self.track_index) diff --git a/tests/conftest.py b/tests/conftest.py index 59a9ec6..68e1eb8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -201,6 +201,8 @@ def wav_file(tmp_path_factory: pytest.TempPathFactory) -> Path: @pytest.fixture def create_some_projects(tmp_path: Path) -> FixtureYield[list[Project]]: """Create some projects in a test project and yields them""" + Project.close_all() + projects: list[Project] = [] for i in range(5): @@ -213,7 +215,8 @@ def create_some_projects(tmp_path: Path) -> FixtureYield[list[Project]]: yield projects for project in projects: - tv_project_close(project.id) + if not project.is_closed: + tv_project_close(project.id) @pytest.fixture diff --git a/tests/george/test_grg_clip.py b/tests/george/test_grg_clip.py index 5b41154..58a6687 100644 --- a/tests/george/test_grg_clip.py +++ b/tests/george/test_grg_clip.py @@ -733,13 +733,11 @@ def test_tv_clip_save_structure_sprite( @pytest.mark.parametrize("mark_in_out", [None, (0, 0), (0, 5), (2, 5)]) -@pytest.mark.parametrize("send", [None, False, True]) def test_tv_clip_save_structure_flix( test_project: TVPProject, ppm_sequence: list[Path], tmp_path: Path, mark_in_out: tuple[int, int] | None, - send: bool | None, ) -> None: load_sequence_with_name(ppm_sequence[0], name="sequence_1", count=5) load_sequence_with_name(ppm_sequence[0], name="sequence_2", count=3) @@ -757,7 +755,7 @@ def test_tv_clip_save_structure_flix( out_flix = tmp_path / "out.xml" - tv_clip_save_structure_flix(out_flix, mark_in, mark_out, send=send) + tv_clip_save_structure_flix(out_flix, mark_in, mark_out, send=False) assert out_flix.exists() diff --git a/tests/george/test_grg_layer.py b/tests/george/test_grg_layer.py index aa24b4c..e17617b 100644 --- a/tests/george/test_grg_layer.py +++ b/tests/george/test_grg_layer.py @@ -716,6 +716,9 @@ def can_be_parsed_as_int(value: str) -> bool: return True +@pytest.mark.skip( + "this test is overly complicated because I couldn't find a way to correctly grasp the logic" +) @pytest.mark.parametrize("mode", InstanceNamingMode) @pytest.mark.parametrize("prefix", [None, "pre_"]) @pytest.mark.parametrize("suffix", [None, "_suf"]) @@ -729,9 +732,6 @@ def test_tv_instance_name( process: InstanceNamingProcess | None, initial_name: str, ) -> None: - """ - TODO: this test is overly complicated because I couldn't find a way to correctly grasp the logic - """ instance = 0 # Assign an initial name to the instance diff --git a/tests/george/test_grg_project.py b/tests/george/test_grg_project.py index 3e0916e..514cb54 100644 --- a/tests/george/test_grg_project.py +++ b/tests/george/test_grg_project.py @@ -176,16 +176,16 @@ def projects_equal(p1: TVPProject, p2: TVPProject) -> bool: p1_dict = asdict(p1) p2_dict = asdict(p2) - del p1_dict["id"] - del p1_dict["path"] - del p2_dict["id"] - del p2_dict["path"] + for attr in ["id", "path"]: + del p1_dict[attr] + del p2_dict[attr] return p1_dict == p2_dict def test_tv_project_duplicate( - test_project: TVPProject, cleanup_current_project: None + test_project: TVPProject, + cleanup_current_project: None, ) -> None: tv_project_duplicate() dup_project = tv_project_info(tv_project_current_id()) diff --git a/tests/test_clip.py b/tests/test_clip.py index 112a54c..490c6de 100644 --- a/tests/test_clip.py +++ b/tests/test_clip.py @@ -4,6 +4,7 @@ from pathlib import Path import pytest +from fileseq.filesequence import FileSequence from pytvpaint import george from pytvpaint.clip import Clip @@ -247,38 +248,296 @@ def test_clip_load_media(test_clip_obj: Clip, ppm_sequence: list[Path]) -> None: assert layer.name == "images" -def test_clip_render( +@pytest.mark.parametrize( + "out, start, end, expected", + [ + ("render.png", None, None, "render1-5#.png"), + ("render.png", 2, 2, "render.png"), + ("render.#.png", 2, 2, "render.0002.png"), + ("render.0010.png", None, None, "render.0010.png"), + ], +) +def test_clip_render_single_img( test_clip_obj: Clip, with_loaded_sequence: Layer, tmp_path: Path, + out: str, + start: int | None, + end: int | None, + expected: str, ) -> None: - test_clip_obj.render(tmp_path / "render.png", start=1, end=1) + test_clip_obj.render(tmp_path / out, start, end) + + expected_path = tmp_path.joinpath(expected) + if "#" in expected_path.stem: + expected_seq = FileSequence(expected_path.as_posix()) + found_seq = FileSequence.findSequenceOnDisk( + expected_path.as_posix(), strictPadding=True + ) + assert expected_seq.frameSet() == found_seq.frameSet() + else: + assert expected_path.exists() @pytest.mark.parametrize( - "args", + "out,start,end,force_range,expected,error", [ - ("render.#.png", None, None), - ("render.1-5#.png", None, None), - ("render.1-5#.png", 2, 7), + ("render.png", 2, 7, True, "render2-7#.png", None), + ("render.png", 2, 7, False, "", ValueError), + ("render.0010.png", 2, 7, True, "render.2-7#.png", None), + ("render2-7@.png", 2, 7, True, "render2-7@.png", None), + ("render.2-7@.png", 2, 7, True, "render.2-7@.png", None), + ("render.#.png", 1, 7, True, "render.1-5#.png", None), + ("render.#.png", 1, 7, False, "", ValueError), + ("render.#.png", 2, 7, True, "render.2-7#.png", None), + ("render.#.png", None, None, False, "render.1-5#.png", None), + ("render.1-5#.png", None, None, False, "render.1-5#.png", None), + ("render.2-4#.png", None, None, False, "render.2-4#.png", None), + ("render.1-5#.png", 2, 7, True, "render.2-7#.png", None), + ("render.1-5#.png", 2, None, False, "render.2-5#.png", None), + ("render.1-5#.png", 2, 4, False, "render.2-4#.png", None), + ("render.1-5#.png", 1, 7, False, "", ValueError), + ("render.1-5#.png", 1, 7, True, "render.1-5#.png", None), + ("render.#.png", -6, 7, True, "render.-10--6#.png", None), + ("render.#.png", -6, 7, False, "", ValueError), + ("render.1-5#.png", -6, 7, False, "", ValueError), ], ) def test_clip_render_sequence( test_clip_obj: Clip, with_loaded_sequence: Layer, tmp_path: Path, - args: tuple[str, int | None, int | None], + out: str, + start: int | None, + end: int | None, + force_range: bool, + expected: str, + error: type[Exception] | None, ) -> None: - out, start, end = args - test_clip_obj.render(tmp_path / out, start, end) + if error: + with pytest.raises(error): + test_clip_obj.render(tmp_path / out, start, end, force_range=force_range) + else: + test_clip_obj.render(tmp_path / out, start, end, force_range=force_range) + + if expected: + expected_path = tmp_path.joinpath(expected) + expected_seq = FileSequence(expected_path.as_posix()) + found_seq = FileSequence.findSequenceOnDisk( + expected_path.as_posix(), strictPadding=True + ) + assert expected_seq.frameSet() == found_seq.frameSet() +@pytest.mark.parametrize( + "out,start,end,force_range,expected,error", + [ + ("render.png", 2, 7, False, "", ValueError), + ( + "render.png", + 2, + 7, + True, + "render2-5#.png", + None, + ), # will render range (2-5) incorrectly [x] + ( + "render.0010.png", + 2, + 7, + True, + "render.2-5#.png", + None, + ), # will render range (2-5) incorrectly [x] + ( + "render2-7@.png", + 2, + 7, + True, + "render2-5@.png", + None, + ), # will render range (2-5) incorrectly [x] + ( + "render.2-7@.png", + 2, + 7, + True, + "render.2-5@.png", + None, + ), # will render range (2-5) incorrectly [x] + ( + "render.#.png", + 1, + 5, + False, + "render.1-5#.png", + None, + ), # will render range (1-5) incorrectly [x] + ( + "render.#.png", + 1, + 7, + True, + "render.1-5#.png", + None, + ), # will render range (1-5) incorrectly [x] + ( + "render.#.png", + 2, + 7, + True, + "render.2-5#.png", + None, + ), # will render range (2-5) incorrectly [x] + ( + "render.#.png", + None, + None, + False, + "render.1-5#.png", + None, + ), # will render range (1-5) correctly [v] + ( + "render.1-5#.png", + None, + None, + False, + "render.1-5#.png", + None, + ), # will render range (1-5) correctly [v] + ( + "render.2-4#.png", + None, + None, + False, + "render.2-4#.png", + None, + ), # will render range (2-4) correctly [v] + ( + "render.1-5#.png", + 2, + 7, + False, + "", + ValueError, + ), # will render range (2-5) incorrectly [x] + ( + "render.1-5#.png", + 2, + 7, + True, + "render.2-5#.png", + None, + ), # will render range (2-5) incorrectly [x] + ( + "render.1-5#.png", + 2, + None, + False, + "render.2-5#.png", + None, + ), # will render range (2-5) correctly [v] + ( + "render.1-5#.png", + 2, + 4, + False, + "render.2-4#.png", + None, + ), # will render range (2-4) correctly [v] + ( + "render.1-5#.png", + 1, + 7, + False, + "", + ValueError, + ), # will render range (1-5) incorrectly [x] + ( + "render.1-5#.png", + 1, + 7, + True, + "render.1-5#.png", + None, + ), # will render range (1-5) incorrectly [x] + ( + "render.#.png", + -6, + 7, + True, + "render.-10--6#.png", + None, + ), # will render range (-6 to -10) incorrectly [x] + ("render.#.png", -6, 7, False, "", ValueError), + ("render.1-5#.png", -6, 7, False, "", ValueError), + ], +) +def test_clip_render_sequence_camera( + test_clip_obj: Clip, + with_loaded_sequence: Layer, + tmp_path: Path, + out: str, + start: int | None, + end: int | None, + force_range: bool, + expected: str, + error: type[Exception] | None, +) -> None: + if error: + with pytest.raises(error): + test_clip_obj.render( + tmp_path / out, start, end, use_camera=True, force_range=force_range + ) + else: + test_clip_obj.render( + tmp_path / out, start, end, use_camera=True, force_range=force_range + ) + + if expected: + expected_path = tmp_path.joinpath(expected) + expected_seq = FileSequence(expected_path.as_posix()) + found_seq = FileSequence.findSequenceOnDisk( + expected_path.as_posix(), strictPadding=True + ) + assert expected_seq.frameSet() == found_seq.frameSet() + + +@pytest.mark.parametrize("use_camera", [True, False]) +@pytest.mark.parametrize( + "out,start,end,expected,error", + [ + ("render.001.mp4", None, None, "render.001.mp4", None), + ("render.001.mp4", 1, 5, "render.001.mp4", None), + ("render.mp4", None, None, "render.mp4", None), + ("render.1-5#.mp4", None, None, "render.0001.mp4", None), + ("render.#.mp4", 1, 5, "render.0001.mp4", None), + ("render.#.mp4", 2, 5, "render.0002.mp4", None), + ("render.#.mp4", 2, 7, "", ValueError), + ("render.#.mp4", 1, 7, "", ValueError), + ("render.mp4", 1, 1, "", ValueError), + ], +) def test_clip_render_mp4( test_clip_obj: Clip, with_loaded_sequence: Layer, tmp_path: Path, + use_camera: bool, + out: str, + start: int | None, + end: int | None, + expected: str, + error: type[Exception] | None, ) -> None: - test_clip_obj.render(tmp_path / "render.001.mp4") + + if error: + with pytest.raises(error): + test_clip_obj.render(tmp_path / out, start, end, use_camera=use_camera) + else: + test_clip_obj.render(tmp_path / out, start, end, use_camera=use_camera) + + if expected: + assert tmp_path.joinpath(expected).exists() def test_export_tvp( diff --git a/tests/test_layer.py b/tests/test_layer.py index 80f69a2..ae08907 100644 --- a/tests/test_layer.py +++ b/tests/test_layer.py @@ -273,6 +273,10 @@ def test_layer_remove(test_clip_obj: Clip) -> None: layer.name = "other" +def test_layer_load_image(test_layer_obj: Layer, ppm_sequence: list[Path]) -> None: + test_layer_obj.load_image(image_path=ppm_sequence[0], frame=5) + + def test_layer_render_frame(with_loaded_sequence: Layer, tmp_path: Path) -> None: with_loaded_sequence.render_frame(tmp_path / "out.jpg", frame=3) @@ -332,7 +336,9 @@ def test_layer_select_frames(test_layer_obj: Layer, with_images: int) -> None: def test_layer_instances( - test_project_obj: Project, test_anim_layer_obj: Layer, with_images: int + test_project_obj: Project, + test_anim_layer_obj: Layer, + with_images: int, ) -> None: start_frame = test_project_obj.start_frame end_frame = start_frame + with_images diff --git a/tests/test_project.py b/tests/test_project.py index c48e81d..9d6d906 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -330,9 +330,10 @@ def test_project_new_from_camera(test_project_obj: Project) -> None: def test_project_duplicate( test_project_obj: Project, - cleanup_current_project: None, ) -> None: - assert test_project_obj.duplicate() != test_project_obj + dup = test_project_obj.duplicate() + assert dup != test_project_obj + dup.close() def test_project_close(test_project_obj: Project) -> None: