Full Code of CoffeeStraw/PyonFX for AI

master c342624ee92f cached
59 files
603.0 KB
211.9k tokens
202 symbols
1 requests
Download .txt
Showing preview only (629K chars total). Download the full file or copy to clipboard to get everything.
Repository: CoffeeStraw/PyonFX
Branch: master
Commit: c342624ee92f
Files: 59
Total size: 603.0 KB

Directory structure:
gitextract_9dsir29s/

├── .flake8
├── .github/
│   ├── scripts/
│   │   └── install-fonts.ps1
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .readthedocs.yaml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── docs/
│   ├── Makefile
│   ├── make.bat
│   └── source/
│       ├── conf.py
│       ├── index.rst
│       ├── quick start.rst
│       └── reference/
│           ├── ass core.rst
│           ├── convert.rst
│           ├── index.rst
│           ├── shape.rst
│           └── utils.rst
├── examples/
│   ├── 1 - Basics/
│   │   ├── 1 - Look into ASS values.py
│   │   ├── 2 - Create the First Output.py
│   │   ├── 3 - More lines.py
│   │   ├── 4 - Organizing the code.py
│   │   └── in.ass
│   ├── 2 - Beginner/
│   │   ├── 1 - First Simple Effect.py
│   │   ├── 2 - Utilities.py
│   │   ├── 3 - Variants.py
│   │   ├── 4 - Accelerate Demo.py
│   │   ├── 5 - Image to Pixels Demo.py
│   │   ├── in.ass
│   │   └── in2.ass
│   ├── 3 - Advanced/
│   │   ├── 1 - WIP.py
│   │   ├── 2 - Testing Pixels (WIP).py
│   │   ├── 3 - Morphing.py
│   │   └── in.ass
│   └── 4 - Community/
│       └── 1 - Dangos/
│           ├── dango_config.py
│           ├── in.ass
│           └── main.py
├── pyonfx/
│   ├── __init__.py
│   ├── ass_core.py
│   ├── convert.py
│   ├── font.py
│   ├── pixel.py
│   ├── shape.py
│   └── utils.py
├── pyproject.toml
├── requirements.txt
└── tests/
    ├── Ass/
    │   ├── ass_core.ass
    │   ├── in.ass
    │   └── in_with_spacing.ass
    ├── __init__.py
    ├── shape/
    │   ├── __init__.py
    │   ├── fixtures.py
    │   ├── test_elements.py
    │   ├── test_generation.py
    │   └── test_operations.py
    ├── test_ass.py
    ├── test_convert.py
    └── test_utils.py

================================================
FILE CONTENTS
================================================

================================================
FILE: .flake8
================================================
[flake8]
max-line-length = 127
extend-ignore = E203, W503

================================================
FILE: .github/scripts/install-fonts.ps1
================================================
$clnt = new-object System.Net.WebClient
$clnt.DownloadFile($args[0], "tmp.zip")

Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory("tmp.zip", "C:\InstallFont\")
[System.IO.File]::Delete("tmp.zip")

$SourceDir   = "C:\InstallFont\"
$Source      = "C:\InstallFont\*"
$Destination = (New-Object -ComObject Shell.Application).Namespace(0x14)
$TempFolder  = "C:\Windows\Temp\Fonts"

# Create the source directory if it doesn't already exist
New-Item -ItemType Directory -Force -Path $SourceDir
New-Item $TempFolder -Type Directory -Force | Out-Null
Get-ChildItem -Path $Source -Include '*.ttf','*.ttc','*.otf' -Recurse | ForEach {
    If (-not(Test-Path "C:\Windows\Fonts\$($_.Name)")) {

        $Font = "$TempFolder\$($_.Name)"
        
        # Copy font to local temporary folder
        Copy-Item $($_.FullName) -Destination $TempFolder
        
        # Install font
        $Destination.CopyHere($Font,0x10)

        # Delete temporary copy of font
        Remove-Item $Font -Force
    }
}

================================================
FILE: .github/workflows/ci.yml
================================================
# Workflow label
name: CI

# Workflow trigger
on: [push, pull_request]

# Cancel previous runs on new push
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

# Workflow tasks
jobs:
  # Apply lint, check formatting
  lint:
    name: "Lint (Python ${{ matrix.python-version }})"
    runs-on: windows-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Cache Python dependencies
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ runner.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}

      - name: Install Python requirements
        run: |
          pip install --upgrade pip
          pip install --upgrade --upgrade-strategy eager .[dev]
          pip install flake8

      - name: Lint with flake8
        run: |
          # Stop the build if there are Python syntax errors or undefined names
          flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
          # Exit-zero treats all errors as warnings. Use same line length as .flake8 config
          flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics

      - name: Cancelling pipeline (failed)
        if: failure()
        uses: andymckay/cancel-action@0.5

      - name: Check formatting with black
        run: black --check .
      - name: Check imports ordering with isort
        run: isort --check-only .

  # Execute pytest to check PyonFX's functionalities
  test:
    name: "Test (${{matrix.os}}, Python ${{ matrix.python-version }})"
    runs-on: ${{matrix.os}}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
        python-version: ["3.10", "3.11", "3.12", "3.13"]

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install platform-specific requirements (Ubuntu)
        if: matrix.os == 'ubuntu-latest'
        run: |
          sudo apt-get update
          sudo apt-get install libgirepository-2.0-dev gobject-introspection libcairo2-dev python3-dev build-essential gir1.2-gtk-3.0 python3-gi python3-gi-cairo
      - name: Install platform-specific requirements (macOS)
        if: matrix.os == 'macos-latest'
        run: brew install python py3cairo pygobject3 pango cairo glib

      - name: Set DYLD_FALLBACK_LIBRARY_PATH for Homebrew (macOS)
        if: matrix.os == 'macos-latest'
        run: echo "DYLD_FALLBACK_LIBRARY_PATH=$(brew --prefix)/lib" >> $GITHUB_ENV

      - name: Setup Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Install fonts (non-Windows)
        if: matrix.os != 'windows-latest'
        run: |
          go install github.com/Crosse/font-install@latest
          font-install "https://github.com/itouhiro/mixfont-mplus-ipa/releases/download/v2020.0307/migu-1p-20200307.zip"
        shell: bash
      - name: Install fonts (Windows)
        if: matrix.os == 'windows-latest'
        run: ./.github/scripts/install-fonts.ps1 'https://github.com/itouhiro/mixfont-mplus-ipa/releases/download/v2020.0307/migu-1p-20200307.zip'
        shell: pwsh

      - name: Cache Python dependencies
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: ${{ matrix.os }}-${{ matrix.python-version }}-pip-${{ hashFiles('pyproject.toml') }}
      - name: Install Python requirements
        run: pip install --upgrade pip && pip install --upgrade --upgrade-strategy eager .[dev]

      - name: Test with pytest
        run: PANGOCAIRO_BACKEND=fc pytest -v
        shell: bash

  # Build the package and publish it on PyPi
  build-n-publish:
    needs: [lint, test]
    if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags')

    name: "Build and publish distributions to PyPI and TestPyPI"
    runs-on: ubuntu-latest

    environment:
      name: pypi
      url: https://pypi.org/p/pyonfx
    permissions:
      id-token: write  # Trusted publishing

    steps:
      - uses: actions/checkout@v4

      - name: Set up Python 3.13
        uses: actions/setup-python@v5
        with:
          python-version: 3.13

      - name: Install build dependencies
        run: |
          pip install --upgrade pip
          pip install build twine

      - name: Build package
        run: python -m build

      - name: Check distribution
        run: twine check dist/*

      - name: Publish distribution 📦 to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1


================================================
FILE: .gitignore
================================================
# Created by https://www.gitignore.io/api/python,virtualenv
# Edit at https://www.gitignore.io/?templates=python,virtualenv

### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST

# PyInstaller
#  Usually these files are written by a python script from a template
#  before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Scrapy stuff:
.scrapy

# Sphinx documentation
docs/_build/

# PyBuilder
target/

# pyenv
.python-version

# pipenv
#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
#   However, in case of collaboration, if having platform-specific dependencies or dependencies
#   having no cross-platform support, pipenv may install dependencies that don't work, or not
#   install all needed dependencies.
#Pipfile.lock

# celery beat schedule file
celerybeat-schedule

# SageMath parsed files
*.sage.py

# Spyder project settings
.spyderproject
.spyproject

# Rope project settings
.ropeproject

# Mr Developer
.mr.developer.cfg
.project
.pydevproject

# mkdocs documentation
/site

# mypy
.mypy_cache/
.dmypy.json
dmypy.json

# Pyre type checker
.pyre/

### VirtualEnv ###
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
pyvenv.cfg
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
pip-selfcheck.json

# End of https://www.gitignore.io/api/python,virtualenv

### PyonFX ###

# Sphinx Documentation
docs/build/

# Example & test outputs
output.ass

# Misc
tests/Ass - Crazy/
examples/scripts/

# VS Code stuff
.vscode/


================================================
FILE: .readthedocs.yaml
================================================
version: "2"

build:
  os: "ubuntu-24.04"
  apt_packages:
    - libgirepository-2.0-dev
    - gobject-introspection
    - libcairo2-dev
    - build-essential
    - gir1.2-gtk-3.0
    - python3-gi
    - python3-gi-cairo
  tools:
    python: "3.13"

python:
  install:
    - method: pip
      path: .
      extra_requirements:
      - dev

sphinx:
  configuration: docs/source/conf.py

================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Code of Conduct

## Our Pledge

In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.

## Our Standards

Examples of behavior that contributes to creating a positive environment
include:

* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members

Examples of unacceptable behavior by participants include:

* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
  address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
  professional setting

## Our Responsibilities

Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.

Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.

## Scope

This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.

Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.

## Attribution

This Code of Conduct is adapted from the Contributor Covenant, version 1.4, available at https://contributor-covenant.org/version/1/4.

================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to PyonFX
Welcome to **PyonFX**!

If you intend to contribute to this project, please read following text carefully to avoid bad misunderstandings and wasted work. Contributions are appreciated but just under correct terms.

## Table of contents
1) [Introduction](#introduction)
2) [Expectations](#expectations)
3) [Style guideline](#style-guideline)
4) [What we're looking for](#what-were-looking-for)
5) [How to contribute](#how-to-contribute)
6) [Community](#community)

## Introduction
Please start by reading our [license](https://github.com/CoffeeStraw/PyonFX/blob/master/LICENSE) and [code-of-conduct](https://github.com/CoffeeStraw/PyonFX/blob/master/CODE_OF_CONDUCT.md). If you don't agree with them, this isn't your project.

For further questions, visit our [discord chat](https://discord.gg/Xxy3YAv).

## Expectations
* We're **mentoring** but just to a certain degree. An initial effort should have been done, training in programming basics or computer science isn't part of the offer. We want to speed up development, not slowing it down.
* **Quality** has priority, not getting fastly done. Faults by unnecessary hurry or sentences like "at least it works" are the opposite of "being welcome" here. Putting experiments on users is disrespectful for their time investment.
* Don't get emotional, act logical. **Personal taste** has to back off if there's no explanation why _xy_ makes more sense for others too. Let's combine the best of all!
* Contributing should happen as **teamwork**, not as competition. Join discussions, look through PRs and issues, merge compatible forks. Ignoring the progress of others leads to lost time (and motivation).

## Style guideline
* Continue present **conventions** and follow **best practices**. Don't break our pattern, code needs no multi-culture.
* **Comment & document** your changes. Keep in mind others may work on same project parts too and don't want to spend much time understanding what you've done. 10 seconds you saved costs another contributor 10 minutes.
* **Include unit tests** when you contribute *new features*, as they help to a) prove that your code works correctly, and b) guard against future breaking changes to lower the maintenance cost.
* *Bug fixes* also generally require unit tests, because the presence of bugs usually indicates insufficient test coverage.
* **Format your code** using [black](https://github.com/psf/black).
* **Include a license** at the top of new files ([Python license example](https://github.com/CoffeeStraw/PyonFX/blob/master/pyonfx/ass_core.py#L1-L16)).

## What we're looking for
* Bugfixes
* Feature ideas
* Examples or tutorials
* Tests

## How to contribute
Main contribution ways are to [open issues](https://github.com/CoffeeStraw/PyonFX/issues) and [fork with later pull requests](https://github.com/CoffeeStraw/PyonFX/network/members). Furthermore you can discuss issues, review PRs or mention this project in public/chat with the community about your experience.

If you want to contribute with a pull request, please remember to:
* Install the PyonFX package in development mode with the command ``pip install -e .[dev]``. Packages included in ``dev`` allows you to run tests, format your code and generate documentation.
* Install the font [Migu 1P](https://www.freejapanesefont.com/migu-font-%E3%83%9F%E3%82%B0%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88/). It is necessary to have installed it to be able to run some test.
* Check if your changes are consistent with the [Style guideline](#style-guideline).
* Increase the version number in the [``pyproject.toml``](https://github.com/CoffeeStraw/PyonFX/blob/master/pyproject.toml) file. It follows the following setup: ``MainVersion.NewFeature.BugFixes``. This means that if your PR contains bux fixes, you must increment the final number. If you've added a new feature, increase the second number. You should never have to touch the first number, as 0 stands for "beta stage" and 1 for "release stage".

## Community
The community has a high value for this project!

As much as possible should be discussed in public, important decisions made by multiple individuals and people not getting excluded just because of a little dispute.

================================================
FILE: LICENSE
================================================
                   GNU LESSER GENERAL PUBLIC LICENSE
                       Version 3, 29 June 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.


  This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.

  0. Additional Definitions.

  As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.

  "The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.

  An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.

  A "Combined Work" is a work produced by combining or linking an
Application with the Library.  The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".

  The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.

  The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.

  1. Exception to Section 3 of the GNU GPL.

  You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.

  2. Conveying Modified Versions.

  If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:

   a) under this License, provided that you make a good faith effort to
   ensure that, in the event an Application does not supply the
   function or data, the facility still operates, and performs
   whatever part of its purpose remains meaningful, or

   b) under the GNU GPL, with none of the additional permissions of
   this License applicable to that copy.

  3. Object Code Incorporating Material from Library Header Files.

  The object code form of an Application may incorporate material from
a header file that is part of the Library.  You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:

   a) Give prominent notice with each copy of the object code that the
   Library is used in it and that the Library and its use are
   covered by this License.

   b) Accompany the object code with a copy of the GNU GPL and this license
   document.

  4. Combined Works.

  You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:

   a) Give prominent notice with each copy of the Combined Work that
   the Library is used in it and that the Library and its use are
   covered by this License.

   b) Accompany the Combined Work with a copy of the GNU GPL and this license
   document.

   c) For a Combined Work that displays copyright notices during
   execution, include the copyright notice for the Library among
   these notices, as well as a reference directing the user to the
   copies of the GNU GPL and this license document.

   d) Do one of the following:

       0) Convey the Minimal Corresponding Source under the terms of this
       License, and the Corresponding Application Code in a form
       suitable for, and under terms that permit, the user to
       recombine or relink the Application with a modified version of
       the Linked Version to produce a modified Combined Work, in the
       manner specified by section 6 of the GNU GPL for conveying
       Corresponding Source.

       1) Use a suitable shared library mechanism for linking with the
       Library.  A suitable mechanism is one that (a) uses at run time
       a copy of the Library already present on the user's computer
       system, and (b) will operate properly with a modified version
       of the Library that is interface-compatible with the Linked
       Version.

   e) Provide Installation Information, but only if you would otherwise
   be required to provide such information under section 6 of the
   GNU GPL, and only to the extent that such information is
   necessary to install and execute a modified version of the
   Combined Work produced by recombining or relinking the
   Application with a modified version of the Linked Version. (If
   you use option 4d0, the Installation Information must accompany
   the Minimal Corresponding Source and Corresponding Application
   Code. If you use option 4d1, you must provide the Installation
   Information in the manner specified by section 6 of the GNU GPL
   for conveying Corresponding Source.)

  5. Combined Libraries.

  You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:

   a) Accompany the combined library with a copy of the same work based
   on the Library, uncombined with any other library facilities,
   conveyed under the terms of this License.

   b) Give prominent notice with the combined library that part of it
   is a work based on the Library, and explaining where to find the
   accompanying uncombined form of the same work.

  6. Revised Versions of the GNU Lesser General Public License.

  The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.

  Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.

  If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.


================================================
FILE: README.md
================================================
<h1 align="center"><img src="https://github.com/CoffeeStraw/PyonFX/blob/master/docs/source/_static/PyonFX%20Logo.png?raw=true" alt="PyonFX Logo" width="600"></h1>

<h4 align="center">An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation Alpha).</h4>
<p align="center">Powered by <b>Python3</b>, PyonFX aims to offer <b>stability</b>, <b>efficiency</b>, and <b>ease of use</b> to anyone who wants to create something more visually complex within ASS.</p>

<p align="center"><a href="https://discord.gg/Xxy3YAv"><img src="https://img.shields.io/discord/562766544061595650.svg?label=Discord%20Server&logo=discord&style=for-the-badge" alt="Discord Official Server"></a> <br> <a href="https://www.python.org/"><img src="https://img.shields.io/pypi/pyversions/pyonfx?style=for-the-badge" alt="PyPI - Python Version"></a> <img src="https://img.shields.io/pypi/v/pyonfx?style=for-the-badge" alt="PyPI - Version"></p>

<p align="center"><img src="https://github.com/CoffeeStraw/PyonFX/blob/master/docs/source/_static/PyonFX_Showcase.jpg?raw=true" alt="Showcase of Effects doable with PyonFX"></p>

# Documentation

You can find the full documentation of PyonFX, as well as a quick-start guide and examples, on [Read The Docs](http://pyonfx.rtfd.io/).

## Contributing

If you want to contribute to PyonFX, please make sure to review the [contribution
guidelines](https://github.com/CoffeeStraw/PyonFX/blob/master/CONTRIBUTING.md).

This project makes use of [GitHub issues](https://github.com/CoffeeStraw/PyonFX/issues) for
tracking **requests and bugs only**, so please *don't* use issues for general questions and discussion.

## License

This project is licensed under the LGPL v3.0 License — see the [LICENSE](https://github.com/CoffeeStraw/PyonFX/blob/master/LICENSE) file for further details.

## Acknowledgments

* **[Youka](https://github.com/Youka)** for the original main functions ideas for **[NyuFX](https://github.com/Youka/NyuFX)**;
* **[McWhite](https://github.com/BastianGanze)** for the original functions ideas of his library for **[NyuFX](https://github.com/Youka/NyuFX)**;
* **[moi15moi](https://github.com/moi15moi)** for creating **[VideoTimestamps](https://github.com/moi15moi/VideoTimestamps)** and contributing significant improvements to the frame processing utilities;
* **Siplas** for helping me out in the realization of the current logo for **PyonFX**;
* **Preacer** for the name suggestion.


================================================
FILE: docs/Makefile
================================================
# Minimal makefile for Sphinx documentation
#

# You can set these variables from the command line.
SPHINXOPTS    =
SPHINXBUILD   = sphinx-build
SOURCEDIR     = source
BUILDDIR      = build

# Put it first so that "make" without argument is like "make help".
help:
	@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

.PHONY: help Makefile

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

================================================
FILE: docs/make.bat
================================================
@ECHO OFF

pushd %~dp0

REM Command file for Sphinx documentation

if "%SPHINXBUILD%" == "" (
	set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build

if "%1" == "" goto help

%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
	echo.
	echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
	echo.installed, then set the SPHINXBUILD environment variable to point
	echo.to the full path of the 'sphinx-build' executable. Alternatively you
	echo.may add the Sphinx directory to PATH.
	echo.
	echo.If you don't have Sphinx installed, grab it from
	echo.http://sphinx-doc.org/
	exit /b 1
)

%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
goto end

:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%

:end
popd


================================================
FILE: docs/source/conf.py
================================================
# -*- coding: utf-8 -*-
#
# Configuration file for the Sphinx documentation builder.

import os
import sys

import sphinx_rtd_theme

# Updating path
sys.path.insert(0, os.path.abspath("..//.."))
sys.setrecursionlimit(1500)

from importlib.metadata import version as get_version

# -- Project information -----------------------------------------------------

project = "PyonFX"
copyright = "2019, Antonio Strippoli"
author = "Antonio Strippoli (CoffeeStraw/YellowFlash)"

# The short X.Y version
version = ""
# The full version, including alpha/beta/rc tags
release = get_version("pyonfx")

# -- General configuration ---------------------------------------------------
autodoc_typehints = "signature"
autodoc_member_order = "bysource"

extensions = [
    "sphinx.ext.autodoc",
    "sphinx.ext.intersphinx",
    "sphinx.ext.ifconfig",
    "sphinx.ext.viewcode",
    "sphinx.ext.napoleon",
    "sphinx_panels",
]

# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]

# The suffix(es) of source filenames.
source_suffix = ".rst"

# The master toctree document.
master_doc = "index"

# The language for content autogenerated by Sphinx.
language = "en"

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []

# The name of the Pygments (syntax highlighting) style to use.
pygments_style = None


# -- Options for HTML output -------------------------------------------------

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]

# Custom sidebar templates, must be a dictionary that maps document names
# to template names.
#
# The default sidebars (for documents that don't match any pattern) are
# defined by theme itself.  Builtin themes are using these templates by
# default: ``['localtoc.html', 'relations.html', 'sourcelink.html',
# 'searchbox.html']``.
#
# html_sidebars = {}

html_logo = "_static/PyonFX Logo.png"

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
html_theme = "sphinx_rtd_theme"

# Theme options are theme-specific and customize the look and feel of a theme
# further.
html_theme_options = {
    "canonical_url": "",
    "logo_only": True,
    "prev_next_buttons_location": "bottom",
    "style_external_links": True,
    "collapse_navigation": True,
    "sticky_navigation": True,
    "navigation_depth": 4,
    "includehidden": True,
    "titles_only": False,
}

# -- Options for HTMLHelp output ---------------------------------------------

# Output file base name for HTML help builder.
htmlhelp_basename = "PyonFXdoc"


# -- Options for LaTeX output ------------------------------------------------

latex_elements = {
    "papersize": "letterpaper",
    "pointsize": "10pt",
    "preamble": "",
    "figure_align": "htbp",
    "extraclassoptions": "openany,oneside",
}

# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
#  author, documentclass [howto, manual, or own class]).
latex_documents = [
    (
        master_doc,
        "PyonFX.tex",
        "PyonFX Documentation",
        "Antonio Strippoli (CoffeeStraw/YellowFlash)",
        "manual",
    ),
]


# -- Options for manual page output ------------------------------------------

# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [(master_doc, "pyonfx", "PyonFX Documentation", [author], 1)]


# -- Options for Texinfo output ----------------------------------------------

# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
#  dir menu entry, description, category)
texinfo_documents = [
    (
        master_doc,
        "PyonFX",
        "PyonFX Documentation",
        author,
        "PyonFX",
        "An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha).",
        "Miscellaneous",
    ),
]


# -- Options for Epub output -------------------------------------------------

# Bibliographic Dublin Core info.
epub_title = project

# A list of files that should not be packed into the epub file.
epub_exclude_files = ["search.html"]


# -- Extension configuration -------------------------------------------------
napoleon_google_docstring = True
napoleon_use_admonition_for_examples = True

# -- Options for intersphinx extension ---------------------------------------

# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"python": ("https://docs.python.org/3", None)}


================================================
FILE: docs/source/index.rst
================================================
The PyonFX Library Documentation
********************************

	"PyonFX is an easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation Alpha)."

PyonFX is a Python library that helps you combine tags, text, and shapes following the ASS format. It also supports tasks like timecode adjustments and layering with the help of the extra data included within an ASS file.

And that's not all! PyonFX also offers some special functions to help you perform some tricky tasks, like frame per frame operations, shape manipulation, or pixel-by-pixel effects. This makes it much easier to create **impressive visual 2D effects**.

In comparison to other karaoke effect programs, PyonFX is written in `Python3 <https://www.python.org/>`_, which means that you will have all the advantages provided by this modern scripting language which is constantly updated and perfect for both beginner and advanced users.

You can learn on how to start using the library in the :ref:`quick-start` section.

.. toctree::
	:hidden:
	:maxdepth: 2

	quick start
	reference/index


================================================
FILE: docs/source/quick start.rst
================================================
.. _quick-start:

Quick Start Guide
-----------------

First things first, you must have a good idea of how to create your effects. You will need to learn (if you haven't already) the following:

* **ASS format**. As PyonFX is an advanced tool for typesetting and karaoke, it is meant to be used by experienced typesetters who are familiar with the tags Libass supports, as well as how they function. Check the footnote [#f1]_ for a complete list of tags.
* **Python3 scripting language**. A programming language like Python allows you to define a set of instructions to be executed by your computer. Compared to softwares with GUI it gives you much more freedom, as you aren't tied to buttons or sliders. **You only need to know the basics for this module**. Knowledge on how to use variable, functions, conditions, loops, comparisons, string formatting, lists, and dictionaries is more than enough. You can find a link to some good tutorials in the footnotes [#f2]_.

To use PyonFX, you'll have to write a Python3 script. Within it you will fully define the process of your KFX or advanced typesetting creation.

If you don't know how to install Python3, there are resources online that can help you out, like https://realpython.com/installing-python/ for example.

Installation
++++++++++++

.. dropdown:: Windows
   :title: font-weight-bold

   If you haven't installed it yet, make sure to **install** Python3.
   You can **download** it from the `official website <https://www.python.org/downloads/>`_.
   Make sure you check the box that says "Add Python 3.x to PATH". This is very important to avoid some extra steps that would make Python callable in every directory from the command prompt.

   Run the following command below. It will use pip to install and update the library:

   .. code-block:: sh
      :emphasize-lines: 1

      pip install --upgrade pyonfx

   That's all you need to do for now. If you need to update this library at a later date, run that same command again.

.. dropdown:: Ubuntu/Debian
   :title: font-weight-bold

   ⚠️Warning: The first of the following commands is not well tested. If you run into any problems, please create an issue or refer to the `official installation guide <https://pygobject.readthedocs.io/en/latest/getting_started.html>`_.

   .. code-block:: sh
      :emphasize-lines: 1,2

      sudo apt install python3 python3-pip libgirepository-2.0-dev gobject-introspection libcairo2-dev build-essential gir1.2-gtk-3.0 python3-gi python3-gi-cairo
      python3 -m pip install --upgrade pyonfx

.. dropdown:: Fedora
   :title: font-weight-bold

   ⚠️Warning: The first of the following commands is not well tested. If you run into any problems, please create an issue or refer to the `official installation guide <https://pygobject.readthedocs.io/en/latest/getting_started.html>`_.

   .. code-block:: sh
      :emphasize-lines: 1,2

      sudo dnf install python3 python3-pip gcc gobject-introspection-devel cairo-devel pkg-config python3-devel python3-gobject gtk3
      python3 -m pip install --upgrade pyonfx

.. dropdown:: Arch Linux
   :title: font-weight-bold

   `AUR package: <https://aur.archlinux.org/packages/python-pyonfx>`_

   .. code-block:: sh
      :emphasize-lines: 1

      paru -S python-pyonfx

   Manual installation:

   .. code-block:: sh
      :emphasize-lines: 1,2

      sudo pacman -S --needed python python-pip python-cairo python-gobject pango
      python -m pip install --upgrade pyonfx

.. dropdown:: OpenSUSE
   :title: font-weight-bold

   ⚠️Warning: The first of the following commands is not well tested. If you run into any problems, please create an issue or refer to the `official installation guide <https://pygobject.readthedocs.io/en/latest/getting_started.html>`_.

   .. code-block:: sh
      :emphasize-lines: 1,2

      sudo zypper install python3 python3-pip cairo-devel pkg-config python3-devel gcc gobject-introspection-devel python3-gobject python3-gobject-Gdk typelib-1_0-Gtk-3_0 libgtk-3-0
      python3 -m pip install --upgrade pyonfx

.. dropdown:: macOS
   :title: font-weight-bold

   You may need to install `Homebrew <https://brew.sh/>`_ first.

   ⚠️Warning: The first of the following commands is not well tested. If you run into any problems, please create an issue or refer to the `official installation guide <https://pygobject.readthedocs.io/en/latest/getting_started.html>`_.

   .. code-block:: sh
      :emphasize-lines: 1,2

      brew install python py3cairo pygobject3 pango cairo glib
      python3 -m pip install --upgrade pyonfx

   ⚠️Warning: If you experience output not rendered correctly, you might need to change the PangoCairo backend to fontconfig.

   .. code-block:: sh
      :emphasize-lines: 1

      PANGOCAIRO_BACKEND=fc python3 namefile.py


Installation - Extra Step
+++++++++++++++++++++++++

This step is not mandatory to start working with the library, but I personally consider Aegisub to be quite old and heavy, so I needed a more comfortable work setup.

That's why PyonFX integrates an additional way to reproduce your works in softsub faster after each generation, using the `MPV player <https://mpv.io/>`_. Installing it should be enough to make everything work if you're **not** on Windows.

If you're on Windows, you will need to add it to PATH after downloading it so the library will be able to utilize it. There are several guides for that, `like this one <https://www.architectryan.com/2018/03/17/add-to-the-path-on-windows-10/>`_.

You need to add the folder that contains the .exe of mpv, generally '*C:\\Program Files\\mpv*'.


Starting out
++++++++++++

Before starting, you may want to make sure everything works as intended. I suggest you to try running some of the examples in the `official GitHub repository of the project <https://github.com/CoffeeStraw/PyonFX/tree/master/examples>`_.

To run a script in python, execute the following command:

.. code-block:: sh
   :emphasize-lines: 1

   python namefile.py

Or if this for some reason doesn't work (like if you're not on Windows and both Python2 and Python3 are installed):

.. code-block:: sh
   :emphasize-lines: 1

   python3 namefile.py

I highly suggest you generate and study every single example in the examples folder (download always up-to-date `here <https://minhaskamal.github.io/DownGit/#/home?url=https://github.com/CoffeeStraw/PyonFX/tree/master/examples>`_). These are meant to help out beginners to advanced users by explaining all the relevant functions of the library and how they work in detail.

Tips
++++

* Don't make KFX in one go. Take breaks, go for a walk, obtain inspiration from your surroundings;
* Pick elements of the video. Your effects should ideally blend in with the video;
* Consider human recognition. Humans notice motion first, then contrasts, then colors. Too much of any of this can result in headaches, but too little can be boring to look at;
* Use modern styles to impress (light, curves, particles, gradients) and old ones for readability (solid colors, thick borders, static positions);
* When backgrounds are too flashy, try to insert a panel shape to put your text on 'safe terrain';
* Adjust to karaoke timing and voice. Fast sung lines will have very short syllable durations for effects, and may not always be visible.

----------

.. rubric:: Footnotes
.. [#f1] List of all ASS tags with usage explanation: https://web.archive.org/web/20200722050630/http://docs.aegisub.org/3.2/ASS_Tags/
.. [#f2] Suggested tutorials for learning Python3:

   * Italian: https://github.com/AllenDowney/ThinkPythonItalian/blob/master/thinkpython_italian.pdf
   * English: http://greenteapress.com/thinkpython2/thinkpython2.pdf


================================================
FILE: docs/source/reference/ass core.rst
================================================
.. _ass-core-ref:

Ass Core
========

.. automodule:: pyonfx.ass_core
	:members:

================================================
FILE: docs/source/reference/convert.rst
================================================
.. _convert-ref:

Convert Functions
=================

.. automodule:: pyonfx.convert
	:members:

================================================
FILE: docs/source/reference/index.rst
================================================
.. _reference-index:

#################################
  The PyonFX Library Reference
#################################

| This reference manual describes all the classes and functions provided by the library.
| It is terse, but attempts to be exact and complete.

For ASS parsing functions and object's classes, you can go on :ref:`ass-core-ref` section. 

For Convert functions usefull to convert everything based on ASS format to something more comfortable (and the other way around), you can go on :ref:`convert-ref` section. 

For Shape functions that will let you do complex calculations with shapes in ASS format, you can go on :ref:`shape-ref` section. 

For general utility functions, you can go on :ref:`utils-ref` section.


.. toctree::
   :maxdepth: 2

   ass core
   convert
   shape
   utils

================================================
FILE: docs/source/reference/shape.rst
================================================
.. _shape-ref:

Shape Functions
=================

.. automodule:: pyonfx.shape
	:members:

================================================
FILE: docs/source/reference/utils.rst
================================================
.. _utils-ref:

Utils
=====

.. automodule:: pyonfx.utils
	:members:

================================================
FILE: examples/1 - Basics/1 - Look into ASS values.py
================================================
"""
This script visualizes which ASS values you got from input ASS file.

First of all you need to create an Ass object, which will help you to manage
input/output. Once created, it will automatically extract all the informations
from the input .ass file.

For more info about the use of Ass class:
https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Ass

By executing this script, you'll discover how ASS contents,
like video resolution, styles, lines etc. are stored into objects and lists.
It's important to understand it, because these Python lists and objects
are exactly the values you'll be working with the whole time to create KFX.

Don't worry about the huge output, there are a lot of information
even in a small input file like the one in this folder.

You can find more info about each object used to represent the input .ass file here:
https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html
"""

from pyonfx import *

# Open the input ASS file and get the data
#     Note that by default, PyonFX will show a progress bar when iterating over lines.
#     In this example, we'll disable it through progress=False to avoid cluttering the output.
io = Ass("in.ass")
meta, styles, lines = io.get_data()

# Print the META object
print("📋 META OBJECT:")
print(f"    {meta}\n")

print("─" * 80 + "\n")  # ──────────────

# Print the STYLES dictionary
print("🎨 STYLES:")
for style_name, style in styles.items():
    print(f'    "{style_name}":')
    print(f"        {style}\n")

print("─" * 80 + "\n")  # ──────────────

# Print the LINES list
print("📝 LINES:")
for line in lines:
    print(f"    {line}\n")

print("─" * 80 + "\n")  # ──────────────

# Print the first word of the first line
print("🔤 FIRST WORD OF THE FIRST LINE:")
print(f"    {lines[0].words[0]}\n")

print("─" * 80 + "\n")  # ──────────────

# Print the first syllable of the first line
print("🎤 FIRST SYLLABLE OF THE FIRST LINE:")
print(f"    {lines[0].syls[0]}\n")

print("─" * 80 + "\n")  # ──────────────

# Print the first char of the first line
print("🅰️  FIRST CHAR OF THE FIRST LINE:")
print(f"    {lines[0].chars[0]}\n")


================================================
FILE: examples/1 - Basics/2 - Create the First Output.py
================================================
"""
This script creates your first dialog line in "Output.ass", which is
the default name for the output that will contains our original dialog lines (commented) + our new generated lines.

The magic function is write_line(), a class function of Ass
which converts a dialog line of class Line back to text form and appends it to "Output.ass".
For more info: https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Ass.write_line

To show the first manipulation, we take the first line of our input
and print it back on the output changing only the text.

It's not a good idea doing it this way because the original line text is overwritten and
for future manipulations you will not be able to take the line's original values anymore
without re parsing again the input file by creating a new Ass object.

Instead, you should always create a copy of line to save the original, we will see how in the following examples.

At the end, you have to call save() class method to actually write your output.
PyonFX will also automatically print how many lines you've written and the process duration.
For more info: https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Ass.save

Finally, we call the open_aegisub method to open the output with Aegisub. Generally you would prefer to open it
with MPV by using the open_mpv method, but the examples'ass file do not have videos.
For more info:
- open_aegisub: https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Ass.open_aegisub
- open_mpv: https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Ass.open_mpv
"""

from pyonfx import *

io = Ass("in.ass")
meta, styles, lines = io.get_data()

lines[0].text = "I am a new line!"
io.write_line(lines[0])

io.save()
io.open_aegisub()


================================================
FILE: examples/1 - Basics/3 - More lines.py
================================================
"""
Let's go a bit further.

In this script we will iterate through all the lines of our .ass,
create a copy for each of them (see the reason for that in the previous example)
and finally write them back on our output with time shifted by 2000ms.

For more info about the copy method:
https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Line.copy
"""

from pyonfx import *

io = Ass("in.ass")
meta, styles, lines = io.get_data()

for line in lines:
    l = line.copy()

    l.start_time += 2000
    l.end_time += 2000

    io.write_line(l)

io.save()
io.open_aegisub()


================================================
FILE: examples/1 - Basics/4 - Organizing the code.py
================================================
"""
Time to manage the effect creation process.
If we want to take things further, it is better to structure our code.

In this example, you will see how generally an effect should be structured.
You could use this file as a template for your future effects.
Line manipulation and output was outsourced to functions, which are called
by passing the original line and a copy, on which you will work on.
You can order every effect to a function.

Lines with alignment over than or equal at 7 will be our romaji lines,
the ones with alignment less than or equal at 3 will be our subtitle (translation) lines,
the others (4, 5, 6) will be meant for vertical kanji.

If you have seen the documentation of Ass class, you should have already seen that
it contains a vertical_kanji parameter, that will automatically calculate vertical positioning
for lines with alignment equal at 4, 5 or 6. If you don't want to let pyon automatically position
kanji in vertical alignment, you can specify this parameter to False.

Note that this code will not do anything, because there is nothing written in the romaji, kanji, sub functions.
We will create our first effect in the next section: 2 - Beginner
"""

from pyonfx import *

io = Ass("in.ass")
meta, styles, lines = io.get_data()


def romaji(line, l):
    # You will write here :D
    pass


def kanji(line, l):
    # You will write here :)
    pass


def sub(line, l):
    # You will write here :P
    pass


for line in lines:
    # Generating lines
    if line.styleref.alignment >= 7:
        romaji(line, line.copy())
    elif line.styleref.alignment >= 4:
        kanji(line, line.copy())
    else:
        sub(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/1 - Basics/in.ass
================================================
[Script Info]
; Script generated by Aegisub 8975-master-8d77da3
; http://www.aegisub.org/
Title: New subtitles
ScriptType: v4.00+
WrapStyle: 0
PlayResX: 1280
PlayResY: 720
ScaledBorderAndShadow: yes
Video Aspect Ratio: 0
Video Zoom: 4

[Aegisub Project Garbage]
Last Style Storage: Default
Video File: ?dummy:23.976000:2250:1280:720:11:135:226:c
Video AR Value: 1.777778
Video Zoom Percent: 0.625000
Video Position: 24

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Romaji,Arial,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,8,12,15,15,1
Style: Subtitle,Arial,40,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,2,12,15,15,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:11.00,Romaji,,0,0,0,,{\k500}Hello {\k500}world!
Dialogue: 0,0:00:01.00,0:00:11.00,Subtitle,,0,0,0,,Ciao mondo!


================================================
FILE: examples/2 - Beginner/1 - First Simple Effect.py
================================================
"""
And here we are with our first complete effect.
As you can see, we have now filled our romaji, kanji and sub functions.

Starting from the simple one, the sub function make use of leadin and leadout times for fitting line-to-line changes.
We then construct the text of each line, giving an alignment, a position and a fad to make a soft entrance and exit.
    (Docs: https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Line.leadin)

In the romaji function instead, we want to create an effect that works with syllables.
In order to do do that, every syllable has to be one dialog line,
so we loop through syllable entries of current line.
Using a utility provided in Utils module, all_non_empty(), we assure
that we will not work with blank syllables or syls with duration equals to zero.
    (Docs: https://pyonfx.readthedocs.io/en/latest/reference/utils.html#pyonfx.utils.Utils.all_non_empty)

In a similiar fashion to what we did in the sub function, we create a leadin and a leadout using fad tag,
then we create our first main effect by using a simple trasformation, obtaining a grow/shrink effect.

Remember to always set the layer for the line. Usually, main effects should have an higher value than leadin and leadout,
beacuse they are more important, so by doing this they will be drawn over the other effects.

For the kanji function, we are calling the same functions of romaji, but using chars instead of syls.
"""

from pyonfx import *

io = Ass("in.ass", vertical_kanji=True)
meta, styles, lines = io.get_data()


@io.track
def leadin_effect(line: Line, obj: Syllable | Char, l: Line):
    l.layer = 0
    l.start_time = line.start_time - line.leadin // 2
    l.end_time = line.start_time + obj.start_time

    tags = rf"\an5\pos({obj.center},{obj.middle})\fad({line.leadin // 2},0)"
    l.text = f"{{{tags}}}{obj.text}"

    io.write_line(l)


@io.track
def main_effect(line: Line, obj: Syllable | Char, l: Line):
    l.layer = 1
    l.start_time = line.start_time + obj.start_time
    l.end_time = line.start_time + obj.end_time

    # Original values
    c1 = line.styleref.color1
    c3 = line.styleref.color3
    fscx = line.styleref.scale_x
    fscy = line.styleref.scale_y

    # New values
    new_fscx = fscx * 1.25
    new_fscy = fscy * 1.25
    new_c1 = "&HFFFFFF&"
    new_c3 = "&HABABAB&"

    tags = (
        rf"\an5\pos({obj.center},{obj.middle})"
        rf"\t(0,{obj.duration // 3},0.5, \fscx{new_fscx}\fscy{new_fscy}\1c{new_c1}\3c{new_c3})"
        rf"\t({obj.duration // 3},{obj.duration},1.5, \fscx{fscx}\fscy{fscy}\1c{c1}\3c{c3})"
    )
    l.text = f"{{{tags}}}{obj.text}"

    io.write_line(l)


@io.track
def leadout_effect(line: Line, obj: Syllable | Char, l: Line):
    l.layer = 0
    l.start_time = line.start_time + obj.end_time
    l.end_time = line.end_time + line.leadout // 2

    tags = rf"\an5\pos({obj.center},{obj.middle})\fad(0,{line.leadout // 2})"
    l.text = f"{{{tags}}}{obj.text}"

    io.write_line(l)


@io.track
def romaji(line: Line, l: Line):
    for syl in Utils.all_non_empty(line.syls):
        leadin_effect(line, syl, l)
        main_effect(line, syl, l)
        leadout_effect(line, syl, l)


@io.track
def kanji(line: Line, l: Line):
    for char in Utils.all_non_empty(line.chars):
        leadin_effect(line, char, l)
        main_effect(line, char, l)
        leadout_effect(line, char, l)


@io.track
def sub(line: Line, l: Line):
    l.start_time = line.start_time - line.leadin // 2
    l.end_time = line.end_time + line.leadout // 2

    tags = rf"\fad({line.leadin // 2}, {line.leadout // 2})"
    l.text = f"{{{tags}}}{line.text}"

    io.write_line(l)


# Generating lines
for line in lines:
    if line.styleref.alignment >= 7:
        romaji(line, line.copy())
    elif line.styleref.alignment >= 4:
        kanji(line, line.copy())
    else:
        sub(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/2 - Beginner/2 - Utilities.py
================================================
"""
The transform ASS tag makes some nice animations possible, but that shouldn't be enough for us!
The alternative is to create one dialog line for every frame. But frame per frame animation can be
pretty stressful to set up, and here's where an utility provided by pyonfx come handy.
In romaji, syllables jitter by frame-per-frame reposition is calculated and
fscx/fscy increase is calculated using FrameUtility, provided in the utils module.
    (Docs: https://pyonfx.readthedocs.io/en/latest/reference/utils.html#pyonfx.utils.FrameUtility)
    (About the random uniform function: https://docs.python.org/3/library/random.html#random.uniform)
You will also see a pretty standard way to make a gradual leadin and leadout, the main idea is to
leave some little delay between syllables so that there is more time to develop an effect.

For subtitles, we create a vertical static gradient.
As exercise, you can try to transform it into an horizontal one :)
"""

import random

from pyonfx import *

io = Ass("in.ass")
meta, styles, lines = io.get_data()


def romaji(line, l):
    for syl in Utils.all_non_empty(line.syls):
        # Setting up a delay, which will be my time for the leadin and leadout effect
        delay = 200

        # Leadin Effect
        l.layer = 0

        l.start_time = line.start_time + 25 * syl.i - delay
        l.end_time = line.start_time + syl.start_time
        l.dur = l.end_time - l.start_time

        l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,0)}%s" % (
            syl.center,
            syl.middle,
            delay,
            syl.text,
        )

        io.write_line(l)

        # Main Effect
        # Let's create a FrameUtility object and set up a radius for the random positions
        FU = FrameUtility(
            line.start_time + syl.start_time,
            line.start_time + syl.end_time,
            meta.timestamps,
        )
        radius = 2

        # Starting to iterate over the frames
        for s, e, _, _ in FU:
            l.layer = 1

            l.start_time = s
            l.end_time = e

            # These lines of codes will reproduce
            # "\\t(0,%d,\\fscx140\\fscy140)\\t(%d,%d,\\fscx100\\fscy100)" % (syl.duration/3, syl.duration/3, syl.duration)
            fsc = 100
            fsc += FU.add(0, syl.duration / 3, 40)
            fsc += FU.add(syl.duration / 3, syl.duration, -40)

            l.text = "{\\an5\\pos(%.3f,%.3f)\\fscx%.3f\\fscy%.3f}%s" % (
                syl.center + random.uniform(-radius, radius),
                syl.middle + random.uniform(-radius, radius),
                fsc,
                fsc,
                syl.text,
            )

            io.write_line(l)

        # Leadout Effect
        l.layer = 0

        l.start_time = line.start_time + syl.end_time
        l.end_time = line.end_time - 25 * (len(line.syls) - syl.i) + delay
        l.dur = l.end_time - l.start_time

        l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(0,%d)}%s" % (
            syl.center,
            syl.middle,
            delay,
            syl.text,
        )

        io.write_line(l)


def sub(line, l):
    # Translation Effect
    l.start_time = line.start_time - line.leadin / 2
    l.end_time = line.end_time + line.leadout / 2
    l.dur = l.end_time - l.start_time

    # Writing border
    l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,%d)}%s" % (
        line.center,
        line.middle,
        line.leadin / 2,
        line.leadout / 2,
        line.text,
    )

    io.write_line(l)

    # We define precision, increasing it will result in a gain on preformance and decrease of fidelity (due to less lines produced)
    precision = 1
    n = int(line.height / precision)

    for i in range(n):
        clip = "%d, %d, %d, %d" % (
            line.left,
            line.top + (line.height) * (i / n),
            line.right,
            line.top + (line.height) * ((i + 1) / n),
        )

        color = Utils.interpolate(i / n, "&H00FFF7&", "&H0000FF&", 1.4)

        l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,%d)\\clip(%s)\\bord0\\1c%s}%s" % (
            line.center,
            line.middle,
            line.leadin / 2,
            line.leadout / 2,
            clip,
            color,
            line.text,
        )

        io.write_line(l)


for line in lines:
    # Generating lines
    if line.styleref.alignment >= 7:
        romaji(line, line.copy())
    elif line.styleref.alignment <= 3:
        sub(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/2 - Beginner/3 - Variants.py
================================================
"""
Inline effects is a method to define exclusive effects for syllables.
Fields "Actor" and "Effect" can also be used to define exclusive effects, but you will define them for the whole line.

In this example, romajis are looking for inline effects
"m1" and "m2" to choose a main effect to apply to syls' text.
Kanjis are looking for lines' field "Effect", to choose what kind of effect we want to apply.
In addition, for romaji there's a star jumping over syls by frame-per-frame positioning.

In this example we can also see in action another utility provided by PyonFX: ColorUtility.
It is used to extract color changes from some lines and interpolate them for each generated line without effort.
Colors will add a really nice touch to your KFXs, so it is important to have a comfy way to set up them and use them in your effects.
In the translation lines we will create some clipped text colorated as an example of the application.
You can also make some simpler usage, like just applying color changes to the whole line, which is what karaokers normally do.

It could look like much code for such a simple effect, but it's needed and an easy method with much potential for extensions.
"""

import math
import random

from pyonfx import *

io = Ass("in2.ass")
meta, styles, lines = io.get_data()

# Creating the star and extracting all the color changes from the input file
star = Shape.star(5, 4, 10)
CU = ColorUtility(lines)


def romaji(line, l):
    # Setting up a delay, we will use it as duration time of the leadin and leadout effects
    delay = 300
    # Setting up offset variables, we will use them for the \move in leadin and leadout effects
    off_x = 35
    off_y = 15

    # Leadin Effect
    for syl in Utils.all_non_empty(line.syls):
        l.layer = 0

        l.start_time = (
            line.start_time + 25 * syl.i - delay - 80
        )  # Remove 80 to start_time to let leadin finish a little bit earlier than the main effect of the first syllable
        l.end_time = line.start_time + syl.start_time
        l.dur = l.end_time - l.start_time

        l.text = (
            "{\\an5\\move(%.3f,%.3f,%.3f,%.3f,0,%d)\\blur2\\t(0,%d,\\blur0)\\fad(%d,0)}%s"
            % (
                syl.center + math.cos(syl.i / 2) * off_x,
                syl.middle + math.sin(syl.i / 4) * off_y,
                syl.center,
                syl.middle,
                delay,
                delay,
                delay,
                syl.text,
            )
        )

        io.write_line(l)

    # Main Effect
    for syl in Utils.all_non_empty(line.syls):
        l.layer = 1

        l.start_time = line.start_time + syl.start_time
        l.end_time = line.start_time + syl.end_time + 100
        l.dur = l.end_time - l.start_time

        c1 = "&H81F4FF&"
        c3 = "&H199AAA&"
        # Change color if inline_fx is m1
        if syl.inline_fx == "m1":
            c1 = "&H8282FF&"
            c3 = "&H191AAA&"

        on_inline_effect_2 = ""
        # Apply rotation if inline_fx is m2
        if syl.inline_fx == "m2":
            on_inline_effect_2 = "\\t(0,%d,\\frz%.3f)\\t(%d,%d,\\frz0)" % (
                l.dur / 4,
                random.uniform(-40, 40),
                l.dur / 4,
                l.dur,
            )

        l.text = (
            "{\\an5\\pos(%.3f,%.3f)%s\\t(0,80,\\fscx105\\fscy105\\1c%s\\3c%s)\\t(80,%d,\\fscx100\\fscy100\\1c%s\\3c%s)}%s"
            % (
                syl.center,
                syl.middle,
                on_inline_effect_2,
                c1,
                c3,
                l.dur - 80,
                line.styleref.color1,
                line.styleref.color3,
                syl.text,
            )
        )

        io.write_line(l)

        # Animating star shape that jumps over the syllables
        # Jump-in to the first syl
        jump_height = 18
        if syl.i == 0:
            FU = FrameUtility(
                int(line.start_time - line.leadin / 2),
                line.start_time,
                meta.timestamps,
            )
            for s, e, i, n in FU:
                l.start_time = s
                l.end_time = e
                frame_pct = i / n

                x = syl.center - syl.width * (1 - frame_pct)
                y = syl.top - math.sin(frame_pct * math.pi) * jump_height

                alpha = 255
                alpha += FU.add(0, syl.duration, -255)
                alpha = Convert.alpha_dec_to_ass(int(alpha))

                l.text = (
                    "{\\alpha%s\\pos(%.3f,%.3f)\\bord1\\blur1\\1c%s\\3c%s\\p1}%s"
                    % (alpha, x, y, c1, c3, star)
                )
                io.write_line(l)

        # Jump to the next syl or to the end of line
        jump_width = (
            line.syls[syl.i + 1].center - syl.center
            if syl.i != len(line.syls) - 1
            else syl.width
        )
        FU = FrameUtility(
            line.start_time + syl.start_time,
            line.start_time + syl.end_time,
            meta.timestamps,
        )
        for s, e, i, n in FU:
            l.start_time = s
            l.end_time = e
            frame_pct = i / n

            x = syl.center + frame_pct * jump_width
            y = syl.top - math.sin(frame_pct * math.pi) * jump_height

            alpha = 0
            # Last jump should fade-out
            if syl.i == len(line.syls) - 1:
                alpha += FU.add(0, syl.duration, 255)
            alpha = Convert.alpha_dec_to_ass(int(alpha))

            l.text = "{\\alpha%s\\pos(%.3f,%.3f)\\bord1\\blur1\\1c%s\\3c%s\\p1}%s" % (
                alpha,
                x,
                y,
                c1,
                c3,
                star,
            )
            io.write_line(l)

    # Leadout Effect
    for syl in Utils.all_non_empty(line.syls):
        l.layer = 0

        l.start_time = line.start_time + syl.end_time + 100
        l.end_time = line.end_time - 25 * (len(line.syls) - syl.i) + delay + 100
        l.dur = l.end_time - l.start_time

        l.text = (
            "{\\an5\\move(%.3f,%.3f,%.3f,%.3f,%d,%d)\\t(%d,%d,\\blur2)\\fad(0,%d)}%s"
            % (
                syl.center,
                syl.middle,
                syl.center + math.cos(syl.i / 2) * off_x,
                syl.middle + math.sin(syl.i / 4) * off_y,
                l.dur - delay,
                l.dur,
                l.dur - delay,
                l.dur,
                delay,
                syl.text,
            )
        )

        io.write_line(l)


def kanji(line, l):
    # Setting up a delay, we will use it as duration time of the leadin and leadout effects
    delay = 300
    # Setting up offset variables, we will use them for the \move in leadin and leadout effects
    off_x = 35
    off_y = 15

    # Leadin Effect
    for syl in Utils.all_non_empty(line.syls):
        l.layer = 0

        l.start_time = (
            line.start_time + 25 * syl.i - delay - 80
        )  # Remove 80 to start_time to let leadin finish a little bit earlier than the main effect of the first syllable
        l.end_time = line.start_time + syl.start_time
        l.dur = l.end_time - l.start_time

        l.text = (
            "{\\an5\\move(%.3f,%.3f,%.3f,%.3f,0,%d)\\blur2\\t(0,%d,\\blur0)\\fad(%d,0)}%s"
            % (
                syl.center + math.cos(syl.i / 2) * off_x,
                syl.middle + math.sin(syl.i / 4) * off_y,
                syl.center,
                syl.middle,
                delay,
                delay,
                delay,
                syl.text,
            )
        )

        io.write_line(l)

    # Main Effect
    for syl in Utils.all_non_empty(line.syls):
        l.layer = 1

        l.start_time = line.start_time + syl.start_time
        l.end_time = line.start_time + syl.end_time + 100
        l.dur = l.end_time - l.start_time

        c1 = "&H81F4FF&"
        c3 = "&H199AAA&"
        # Change color if effect field is m1
        if line.effect == "m1":
            c1 = "&H8282FF&"
            c3 = "&H191AAA&"

        on_inline_effect_2 = ""
        # Apply rotation if effect field is m2
        if line.effect == "m2":
            on_inline_effect_2 = "\\t(0,%d,\\frz%.3f)\\t(%d,%d,\\frz0)" % (
                l.dur / 4,
                random.uniform(-40, 40),
                l.dur / 4,
                l.dur,
            )

        l.text = (
            "{\\an5\\pos(%.3f,%.3f)%s\\t(0,80,\\fscx105\\fscy105\\1c%s\\3c%s)\\t(80,%d,\\fscx100\\fscy100\\1c%s\\3c%s)}%s"
            % (
                syl.center,
                syl.middle,
                on_inline_effect_2,
                c1,
                c3,
                l.dur - 80,
                line.styleref.color1,
                line.styleref.color3,
                syl.text,
            )
        )

        io.write_line(l)

    # Leadout Effect
    for syl in Utils.all_non_empty(line.syls):
        l.layer = 0

        l.start_time = line.start_time + syl.end_time + 100
        l.end_time = line.end_time - 25 * (len(line.syls) - syl.i) + delay + 100
        l.dur = l.end_time - l.start_time

        l.text = (
            "{\\an5\\move(%.3f,%.3f,%.3f,%.3f,%d,%d)\\t(%d,%d,\\blur2)\\fad(0,%d)}%s"
            % (
                syl.center,
                syl.middle,
                syl.center + math.cos(syl.i / 2) * off_x,
                syl.middle + math.sin(syl.i / 4) * off_y,
                l.dur - delay,
                l.dur,
                l.dur - delay,
                l.dur,
                delay,
                syl.text,
            )
        )

        io.write_line(l)


def sub(line, l):
    # Translation Effect
    l.layer = 0

    l.start_time = line.start_time - line.leadin / 2
    l.end_time = line.end_time + line.leadout / 2
    l.dur = l.end_time - l.start_time

    # Getting interpolated color changes (notice that we do that only after having set up all the times, that's important)
    colors = CU.get_color_change(l)

    # Base text
    l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,%d)}%s" % (
        line.center,
        line.middle,
        line.leadin / 2,
        line.leadout / 2,
        line.text,
    )
    io.write_line(l)

    # Random clipped text colorated
    l.layer = 1
    for i in range(1, int(line.width / 80)):
        x_clip = line.left + random.uniform(0, line.width)
        y_clip = line.top - 5

        clip = (
            x_clip,
            y_clip,
            x_clip + random.uniform(10, 30),
            y_clip + line.height + 10,
        )

        l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,%d)\\clip(%d,%d,%d,%d)%s}%s" % (
            line.center,
            line.middle,
            line.leadin / 2,
            line.leadout / 2,
            clip[0],
            clip[1],
            clip[2],
            clip[3],
            colors,
            line.text,
        )
        io.write_line(l)


for line in lines:
    # Generating lines
    if line.styleref.alignment >= 7:
        romaji(line, line.copy())
    elif line.styleref.alignment >= 4:
        kanji(line, line.copy())
    else:
        sub(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/2 - Beginner/4 - Accelerate Demo.py
================================================
from pyonfx import *
from pyonfx.utils import FrameUtility

# Parameters
HEIGHT = 700
DURATION_MS = 4000
DURATION_STILL_MS = 500
HORIZONTAL_DISTANCE = 300
CIRCLE_RADIUS = 15
MARGIN = 20

# All available accelerators
ACC_PRESETS = [
    1.0,  # Linear
    0.5,  # Power 0.5
    1.5,  # Power 1.5
    "in_back",
    "out_back",
    "in_out_back",
    "in_bounce",
    "out_bounce",
    "in_out_bounce",
    "in_circ",
    "out_circ",
    "in_out_circ",
    "in_cubic",
    "out_cubic",
    "in_out_cubic",
    "in_elastic",
    "out_elastic",
    "in_out_elastic",
    "in_expo",
    "out_expo",
    "in_out_expo",
    "in_quad",
    "out_quad",
    "in_out_quad",
    "in_quart",
    "out_quart",
    "in_out_quart",
    "in_quint",
    "out_quint",
    "in_out_quint",
    "in_sine",
    "out_sine",
    "in_out_sine",
]


def generate_color_palette(count):
    import colorsys

    colors = []
    for i in range(count):
        hue = i / count
        r, g, b = [int(x * 255) for x in colorsys.hsv_to_rgb(hue, 1, 1)]
        colors.append(f"&H{b:02X}{g:02X}{r:02X}&")
    return colors


COLORS = generate_color_palette(len(ACC_PRESETS))

io = Ass("in.ass")
meta, styles, lines = io.get_data()
template_line = lines[1].copy()
CIRCLE = Shape.ellipse(CIRCLE_RADIUS, CIRCLE_RADIUS)

# Single FrameUtility for all circles
FU = FrameUtility(0, DURATION_MS + DURATION_STILL_MS * 2, meta.timestamps)
VERTICAL_SPACING = HEIGHT / (len(ACC_PRESETS) + 1)

for start, end, i, n in FU:
    for idx, (acc, color) in enumerate(zip(ACC_PRESETS, COLORS)):
        l = template_line.copy()
        l.start_time = start
        l.end_time = end
        l.layer = 0

        # Calculate vertical position
        y = MARGIN + VERTICAL_SPACING * (idx)

        # Use FU.add with the right acceleration for this circle
        x_offset = FU.add(DURATION_STILL_MS, DURATION_MS, HORIZONTAL_DISTANCE, acc)
        x = MARGIN * 6 + x_offset

        l.text = (
            f"{{\\an7\\pos({x},{y})\\p1\\1c{color}\\bord0}}{CIRCLE}"
            f"{{\\r\\p0\\an7\\fs15}}{acc}"
        )
        io.write_line(l)

io.save()
io.open_aegisub()


================================================
FILE: examples/2 - Beginner/5 - Image to Pixels Demo.py
================================================
"""
Image to Pixels Demo - Demonstrating the image_to_pixels function

This demo shows how to use the Convert.image_to_pixels() function to convert
an image into pixel data that can be used for ASS effects. The astronaut image
will be displayed next to the first line with appropriate fade effects.

The demo creates individual pixel elements for each non-transparent pixel in the image,
positioning them relative to the first line and applying fade effects.
"""

import os

from pyonfx import *

io = Ass("in.ass", vertical_kanji=True)
meta, styles, lines = io.get_data()

# Pixel setup
io.add_style("p", Ass.PIXEL_STYLE)

# Prepare png file
image_path = "lighthouse.png"
lighthouse_pixels = Convert.image_to_pixels(
    image_path, skip_transparent=True, width=80, height=80
)
min_x = min(p.x for p in lighthouse_pixels)
min_y = min(p.y for p in lighthouse_pixels)
max_y = max(p.y for p in lighthouse_pixels)
image_height = max_y - min_y + 1


# Check if image exists
@io.track
def image_effect(line: Line, l: Line):
    """Display the lighthouse image next to the line."""

    # Position image to the right of the line
    image_start_x = line.right + 30
    image_start_y = line.top - (image_height - line.height) // 2

    # Create pixel elements
    for pixel in lighthouse_pixels:
        # Calculate final position
        final_x = image_start_x + (pixel.x - min_x)
        final_y = image_start_y + (pixel.y - min_y)

        # Create timing
        l.start_time = line.start_time
        l.end_time = line.end_time
        l.layer = 1
        l.style = "p"

        color_tag = f"\\1c{pixel.color}"
        alpha_tag = f"\\alpha{pixel.alpha if pixel.alpha != '&H00&' else ''}"
        tags = rf"\\p1\pos({final_x},{final_y}){color_tag}{alpha_tag}\fad(300,300)"
        l.text = f"{{{tags}}}{Shape.PIXEL}"

        io.write_line(l)


@io.track
def sub(line: Line, l: Line):
    """Test effect: Convert text to shape and apply texture from 'stock_texture.jpg'."""
    l.start_time = line.start_time - line.leadin // 2
    l.end_time = line.end_time + line.leadout // 2

    fad = rf"\fad({line.leadin // 2}, {line.leadout // 2})"
    l.text = f"{{{fad}}}{line.text}"

    io.write_line(l)

    # Apply texture using the image 'stock_texture.jpg' with 'stretch' mode
    l.style = "p"
    shape_pixels = Convert.text_to_pixels(line)
    textured_pixels = shape_pixels.apply_texture("stock_texture.jpg")

    # Output each textured pixel as a separate line with a pixel drawing command
    for pixel in textured_pixels:
        # Compute absolute position for the pixel based on the original line position
        x = int(line.left) + pixel.x
        y = int(line.top) + pixel.y

        # Create a simple drawing command using \p1. This draws a tiny rectangle representing a pixel.
        l.text = f"{{\\p1\\pos({x},{y})\\1c{pixel.color}\\alpha{pixel.alpha}{fad}}}{Shape.PIXEL}"
        io.write_line(l)


# Generate lines
for line in lines[1:2]:
    # Apply image effect only to the first line
    image_effect(line, line.copy())

    # Apply subtitle effect to all lines
    sub(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/2 - Beginner/in.ass
================================================
[Script Info]
; Script generated by Aegisub 8975-master-8d77da3
; http://www.aegisub.org/
PlayResX: 1280
PlayResY: 720

[Aegisub Project Garbage]
Last Style Storage: Default
Video File: ?dummy:23.976000:2250:1920:1080:11:135:226:c
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Active Line: 1
Video Position: 342

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1
Style: Romaji,Migu 1P,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1
Style: Translation,Migu 1P,46,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,2,25,25,25,1
Style: Kanji,Migu 1P,38,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.8,0,4,25,25,25,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Comment: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,Font used (Version 1P, Bold): https://www.freejapanesefont.com/migu-font-%E3%83%9F%E3%82%B0%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88/
Dialogue: 0,0:00:14.24,0:00:24.23,Romaji,,0,0,0,,{\k56}su{\k13}re{\k22}chi{\k36}ga{\k48}u{\k25} {\k34}ko{\k33}to{\k50}ba {\k15}no {\k17}u{\k34}ra {\k46}ni{\k33} {\k28}to{\k36}za{\k65}sa{\k33}{\k30}re{\k51}ta{\k16} {\k33}ko{\k33}ko{\k78}ro {\k15}no {\k24}ka{\k95}gi
Dialogue: 0,0:00:24.68,0:00:34.50,Romaji,,0,0,0,,{\k51}ki{\k13}mi {\k18}to {\k32}i{\k49}u{\k28} {\k37}fu{\k32}ra{\k46}gu {\k19}ka{\k13}i{\k35}jo {\k51}ga{\k30} {\k33}e{\k34}ga{\k32}o{\k30} {\k35}su{\k38}ku{\k59}e{\k64}ru {\k67}no{\k29} {\k19}na{\k88}ra
Dialogue: 0,0:00:35.06,0:00:40.09,Romaji,,0,0,0,,{\k16}na{\k18}tsu {\k15}no {\k30}ka{\k35}ze {\k12}i{\k40}za{\k14}na{\k50}u{\k32} {\k17}ha{\k12}ku{\k19}chu{\k31}u {\k25}no {\k23}su{\k33}ko{\k15}o{\k66}ru
Dialogue: 0,0:00:40.84,0:00:44.80,Romaji,,0,0,0,,{\k34}ha{\k37}cho{\k39}u{\k16} {\k76}shi{\k96}n{\k12}ku{\k86}ro
Dialogue: 0,0:00:45.41,0:00:50.30,Romaji,,0,0,0,,{\k21}ro{\k27}man{\k36}chi{\k33}kku {\k16}mi{\k36}ta{\k19}i {\k40}ni{\k35} {\k17}ki {\k16}no {\k16}ki{\k30}i{\k33}ta {\k18}ko{\k31}to{\k19}ba {\k46}mo
Dialogue: 0,0:00:50.58,0:00:59.34,Romaji,,0,0,0,,{\k37}mi{\k17}tsu{\k47}ka{\k19}ra{\k46}nai {\k19}ke{\k36}do{\k45} {\k32}a{\k28}o{\k33}zo{\k36}ra {\k29}o{\k31} {\k32}me{\k31}za{\k178}su{\k56} {\k14}ka{\k110}ra
Dialogue: 0,0:00:59.70,0:01:04.24,Romaji,,0,0,0,,{\k33}bo{\k19}ku{\k30}ta{\k33}chi {\k85}wa{\k27} {\k15}hi{\k16}to{\k36}tsu {\k17}ni {\k31}na{\k32}re{\k80}ru
Dialogue: 0,0:01:04.56,0:01:09.78,Romaji,,0,0,0,,{\k22}to{\k16}ma{\k30}do{\k16}i {\k35}no {\k30}na{\k111}mi{\k20}da {\k43}mo{\k34} {\k34}yu{\k31}me {\k100}mo
Dialogue: 0,0:01:10.00,0:01:15.96,Romaji,,0,0,0,,{\k46}shi{\k11}n{\k29}ji{\k33}tsu {\k84}ni{\k26} {\k24}hi{\k10}ki{\k29}yo{\k27}se{\k31}ra{\k30}re{\k115}ru {\k17}mo{\k84}no
Dialogue: 0,0:01:16.20,0:01:24.48,Romaji,,0,0,0,,{\k33}so{\k55}re {\k26}wa{\k24} {\k13}i{\k9}to{\k48}shi{\k62}su{\k19}gi{\k85}ru{\k108} {\k40}ma{\k36}ho{\k63}u {\k32}no {\k18}ki{\k32}i{\k101}waa{\k24}do
Comment: 0,0:01:24.39,0:01:26.39,Default,,0,0,0,,
Dialogue: 0,0:00:14.24,0:00:18.56,Kanji,,0,0,0,,{\k56}す{\k13}れ{\k58}違{\k48}う{\k25}{\k67}言{\k50}葉{\k15}の{\k51}裏{\k49}に
Dialogue: 0,0:00:18.86,0:00:24.23,Kanji,,0,0,0,,{\k28}閉{\k36}ざ{\k65}さ{\k33}{\k30}れ{\k51}た{\k16}{\k144}心{\k15}の{\k24}カ{\k95}ギ
Dialogue: 0,0:00:24.68,0:00:28.91,Kanji,,0,0,0,,{\k51}キ{\k13}ミ{\k18}と{\k32}い{\k49}う{\k28}{\k37}フ{\k32}ラ{\k46}グ{\k32}解{\k35}除{\k50}が
Dialogue: 0,0:00:29.22,0:00:34.50,Kanji,,0,0,0,,{\k33}笑{\k66}顔{\k30}{\k73}救{\k59}え{\k64}る{\k67}の{\k29}{\k19}な{\k88}ら
Dialogue: 0,0:00:35.06,0:00:40.09,Kanji,,0,0,0,,{\k34}夏{\k15}の{\k65}風{\k12}い{\k40}ざ{\k14}な{\k50}う{\k32}{\k29}白{\k50}昼{\k25}の{\k23}ス{\k33}コ{\k15}ー{\k66}ル
Dialogue: 0,0:00:40.84,0:00:44.80,Kanji,,0,0,0,,{\k34}波{\k76}長{\k16}{\k76}シ{\k96}ン{\k12}ク{\k86}ロ
Dialogue: 0,0:00:45.41,0:00:50.30,Kanji,,0,0,0,,{\k21}ロ{\k27}マン{\k36}チ{\k33}ック{\k16}み{\k36}た{\k19}い{\k40}に{\k35}{\k17}気{\k16}の{\k16}利{\k30}い{\k33}た{\k49}言{\k19}葉{\k46}も
Dialogue: 0,0:00:50.58,0:00:59.34,Kanji,,0,0,0,,{\k37}見{\k17}つ{\k47}か{\k19}ら{\k46}ない{\k19}け{\k36}ど{\k45}{\k60}青{\k69}空{\k29}を{\k31}{\k32}目{\k31}指{\k178}す{\k56}{\k14}か{\k110}ら
Dialogue: 0,0:00:59.70,0:01:04.24,Kanji,,0,0,0,,{\k52}僕{\k63}達{\k85}は{\k27}{\k31}一{\k36}つ{\k17}に{\k31}な{\k32}れ{\k80}る
Dialogue: 0,0:01:04.56,0:01:09.78,Kanji,,0,0,0,,{\k22}戸{\k46}惑{\k16}い{\k35}の{\k161}涙{\k43}も{\k34}{\k65}夢{\k100}も
Dialogue: 0,0:01:10.00,0:01:15.96,Kanji,,0,0,0,,{\k57}真{\k62}実{\k84}に{\k26}{\k24}引{\k10}き{\k29}寄{\k27}せ{\k31}ら{\k30}れ{\k115}る{\k17}も{\k84}の
Dialogue: 0,0:01:16.20,0:01:24.48,Kanji,,0,0,0,,{\k33}そ{\k55}れ{\k26}は{\k24}{\k22}愛{\k48}し{\k62}す{\k19}ぎ{\k85}る{\k108}{\k40}魔{\k99}法{\k32}の{\k18}キ{\k32}ー{\k101}ワー{\k24}ド
Comment: 0,0:00:13.82,0:00:14.22,Default,,0,0,0,,
Dialogue: 0,0:00:14.24,0:00:24.23,Translation,,0,0,0,,Guarda oltre le parole e cerca la chiave per il mio cuore,
Dialogue: 0,0:00:24.68,0:00:34.50,Translation,,0,0,0,,se credi che il mio affetto possa renderti felice.
Dialogue: 0,0:00:35.06,0:00:40.09,Translation,,0,0,0,,La brezza di tarda estate si trasforma in una tempesta tumultuosa,
Dialogue: 0,0:00:40.84,0:00:44.80,Translation,,0,0,0,,mentre entriamo in sintonia.
Dialogue: 0,0:00:45.41,0:00:50.30,Translation,,0,0,0,,Sono un pessimo romantico, mi mancano le parole giuste,
Dialogue: 0,0:00:50.58,0:00:59.34,Translation,,0,0,0,,eppure sto ancora puntando al cielo, così sconfinato...
Dialogue: 0,0:00:59.70,0:01:04.24,Translation,,0,0,0,,Possiamo diventare una cosa sola
Dialogue: 0,0:01:04.56,0:01:09.78,Translation,,0,0,0,,mentre i nostri sogni e le nostre lacrime confuse
Dialogue: 0,0:01:10.00,0:01:15.96,Translation,,0,0,0,,ci portano sempre più vicini alla verità,
Dialogue: 0,0:01:16.20,0:01:24.48,Translation,,0,0,0,,che ormai è divenuta la nostra amata parola chiave magica.


================================================
FILE: examples/2 - Beginner/in2.ass
================================================
[Script Info]
; Script generated by Aegisub 8975-master-8d77da3
; http://www.aegisub.org/
Title: Default Aegisub file
ScriptType: v4.00+
WrapStyle: 0
ScaledBorderAndShadow: yes
YCbCr Matrix: TV.601
PlayResX: 1600
PlayResY: 900

[Aegisub Project Garbage]
Last Style Storage: Default
Video File: ?dummy:23.976000:2250:1920:1080:11:135:226:c
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Video Position: 349

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1
Style: Romaji,Migu 1P,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1
Style: Translation,Migu 1P,46,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,2,25,25,25,1
Style: Kanji,Migu 1P,38,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.8,0,4,25,25,25,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 3,0:00:14.54,0:00:20.72,Romaji,,0,0,0,,{\k45\-m1}da{\k22}re{\k13}mo {\k75\-m1}ga {\k11}sa{\k11}ka{\k46}ra{\k21}e{\k8}zu {\k39}ni {\k22}mo{\k25}gu{\k19}t{\k66\-m1}te {\k15}i{\k180\-m1}ku
Dialogue: 3,0:00:21.60,0:00:26.95,Romaji,,0,0,0,,{\k50}so{\k21}no {\k13}me {\k73\-m2}o {\k10}to{\k10}mo{\k29}shi{\k24}bi {\k19}yo{\k11}ri {\k37}ka{\k22}ga{\k19}ya{\k23}ka{\k44\-m2}se{\k130\-m2}te
Dialogue: 3,0:00:28.52,0:00:31.60,Romaji,,0,0,0,,{\k13}me{\k12}za{\k29\-m1}su {\k35\-m2}sa{\k26}ki {\k32}wa {\k32\-m1}fu{\k23\-m2}ka{\k106\-m1}ku
Dialogue: 1,0:00:14.54,0:00:20.72,Kanji,,0,0,0,,{\k67}誰{\k13}も{\k75}が{\k22}逆{\k46}ら{\k21}え{\k8}ず{\k39}に{\k47}潜{\k19}っ{\k66}て{\k15}い{\k180}く
Dialogue: 0,0:00:21.60,0:00:26.95,Kanji,,0,0,0,m2,{\k50}そ{\k21}の{\k13}目{\k83}を{\k39}灯{\k24}火{\k19}よ{\k11}り{\k78}輝{\k23}か{\k44}せ{\k130}て
Dialogue: 0,0:00:28.52,0:00:31.60,Kanji,,0,0,0,m1,{\k13}目{\k12}指{\k29}す{\k61}先{\k32}は{\k55}深{\k106}く
Dialogue: 1,0:00:14.54,0:00:20.72,Translation,,0,0,0,,{\t(-2001,-2000,\1c&HC7FFB0&\3c&H1B5306&)\t(4062,4292,\1c&HA7B5DC&\3c&H0C1F57&)\t(4938,5300,\1c&HC7FFB0&\3c&H1B5306&)}Ciò che abbiamo sempre desiderato è sepolto in profondità.
Dialogue: 1,0:00:21.60,0:00:26.95,Translation,,0,0,0,,{\t(87,87,\1c&HA7B5DC&\3c&H0C1F57&)}Malgrado le nostre paure, i nostri occhi brillano più del fuoco.
Dialogue: 1,0:00:28.52,0:00:31.60,Translation,,0,0,0,,{\t(258,258,\1c&HFFC390&\3c&H672414&)}Il nostro obbiettivo è raggiungere quel luogo recondito,


================================================
FILE: examples/3 - Advanced/1 - WIP.py
================================================
import random

from pyonfx import *

io = Ass("in.ass")
meta, styles, lines = io.get_data()

circle = Shape.ellipse(20, 20)


def romaji(line, l):
    for syl in Utils.all_non_empty(line.syls):
        # Leadin Effect
        l.layer = 0

        l.start_time = line.start_time - line.leadin / 2
        l.end_time = line.start_time + syl.start_time
        l.dur = l.end_time - l.start_time

        l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,0)}%s" % (
            syl.center,
            syl.middle,
            line.leadin / 2,
            syl.text,
        )

        io.write_line(l)

        # Main Effect
        l.layer = 1

        FU = FrameUtility(
            line.start_time + syl.start_time,
            line.start_time + syl.end_time,
            meta.timestamps,
        )
        rand = random.uniform(-10, 10)

        # Starting to iterate over the frames
        for s, e, i, n in FU:
            l.layer = 1

            l.start_time = s
            l.end_time = e

            fsc = 100
            fsc += FU.add(0, syl.duration / 3, 20)
            fsc += FU.add(syl.duration / 3, syl.duration, -20)

            alpha = 0
            alpha += FU.add(syl.duration / 2, syl.duration, 255)
            alpha = Convert.alpha_dec_to_ass(int(alpha))

            l.text = "{\\an9\\pos(%.3f,%.3f)\\fscx%.3f\\fscy%.3f}%s" % (
                syl.right,
                syl.top,
                fsc,
                fsc,
                syl.text,
            )

            io.write_line(l)

            l.text = (
                "{\\an5\\pos(%.3f,%.3f)\\fscx%.3f\\fscy%.3f\\1c&H0000FF&\\bord0\\shad0\\blur2\\alpha%s\\clip(%s)\\p1}%s"
                % (
                    syl.center + rand,
                    syl.middle + rand,
                    fsc,
                    fsc,
                    alpha,
                    Convert.text_to_clip(syl, an=9, fscx=fsc, fscy=fsc),
                    circle,
                )
            )

            io.write_line(l)

        io.write_line(l)

        # Leadout Effect
        l.layer = 0

        l.start_time = line.start_time + syl.end_time
        l.end_time = line.end_time + line.leadout / 2
        l.dur = l.end_time - l.start_time

        l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(0,%d)}%s" % (
            syl.center,
            syl.middle,
            line.leadout / 2,
            syl.text,
        )

        io.write_line(l)


# Generating lines
for line in lines:
    if line.styleref.alignment >= 7:
        romaji(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/3 - Advanced/2 - Testing Pixels (WIP).py
================================================
"""
Just a test to show pixels in action, this file will be removed as soon as I prepare the new examples.
"""

import math
import random

from pyonfx import *

io = Ass("in.ass")
meta, styles, lines = io.get_data()
io.add_style("p", Ass.PIXEL_STYLE)


def romaji(line, l):
    off = 6

    for syl in Utils.all_non_empty(line.syls):
        # Leadin Effect
        l.layer = 0

        l.start_time = line.start_time - line.leadin / 2
        l.end_time = line.start_time + syl.start_time

        l.text = "{\\an5\\pos(%.3f,%.3f)\\bord0\\fad(%d,0)}%s" % (
            syl.center,
            syl.middle,
            line.leadin / 2,
            syl.text,
        )

        io.write_line(l)

    l.style = "p"
    for syl in Utils.all_non_empty(line.syls):
        # Main Effect
        l.layer = 1

        l.start_time = line.start_time + syl.start_time
        l.end_time = line.start_time + syl.end_time + 300
        dur = l.end_time - l.start_time

        for pixel in Convert.text_to_pixels(syl):
            x, y = math.floor(syl.left) + pixel.x, math.floor(syl.top) + pixel.y
            x2, y2 = x + random.uniform(-off, off), y + random.uniform(-off, off)
            alpha = f"\\1a{pixel.alpha}" if pixel.alpha != "&H00&" else ""

            l.text = "{\\p1\\move(%d,%d,%d,%d)%s\\fad(0,%d)}%s" % (
                x,
                y,
                x2,
                y2,
                alpha,
                dur / 4,
                Shape.PIXEL,
            )
            io.write_line(l)

    l.start_time = line.start_time
    l.end_time = line.end_time

    for pi, pixel in enumerate(Convert.shape_to_pixels(Shape.heart(50))):
        # Random shape heart to pixel effect just to show this function too
        x, y = (
            math.floor(line.left) - 60 + pixel.x,
            math.floor(line.top) + pixel.y,
        )
        x2, y2 = x + 10 * (-1) ** pi, y + 10 * (-1) ** pi
        alpha = f"\\1a{pixel.alpha}" if pixel.alpha != "&H00&" else ""

        l.text = "{\\p1\\move(%d,%d,%d,%d)%s\\fad(0,%d)}%s" % (
            x,
            y,
            x2,
            y2,
            alpha,
            line.duration / 4,
            Shape.PIXEL,
        )
        io.write_line(l)


for line in lines:
    # Generating lines
    if not line.comment and line.styleref.alignment >= 7 and line.i <= 3:
        romaji(line, line.copy())

io.save()
io.open_aegisub()


================================================
FILE: examples/3 - Advanced/3 - Morphing.py
================================================
"""
Morphing Example. To be refined before considering it complete.
"""

import random

from pyonfx import *

# Setup I/O
io = Ass("in.ass")
meta, styles, lines = io.get_data()

# A set of shapes we can randomly choose from
AVAILABLE_SHAPES = [
    Shape.ellipse(25, 25),  # Circle
    Shape.polygon(4, 25),  # Square
    Shape.polygon(3, 25),  # Triangle
    Shape.star(5, 10, 20),  # 5-point star
    Shape.star(6, 10, 20),  # 6-point star
    Shape.heart(28),  # Heart
    Shape.ring(17, 8),  # Ring
]

# Simple colour palette (cycling through)
PALETTE = [
    "&H0066FF&",
    "&HFF6600&",
    "&H00CC66&",
    "&HFF0066&",
    "&H6600FF&",
    "&HFFFF00&",
    "&H00FFFF&",
]


def romaji(line: Line, l: Line) -> None:
    """Create per-syllable lead-in: bouncing random shape → morph into text."""

    # Lead-in timing configuration (in ms)
    LEADIN_TOTAL = 1000  # Total lead-in duration per syllable before syl.start_time
    BOUNCE_PART = 0.6  # 60 % of lead-in spent on falling + bounce
    BOUNCE_HEIGHT = 80  # Pixels the shape drops from above the baseline

    # Lead-out timing configuration (in ms)
    LEADOUT_TOTAL = 800  # Total lead-out duration per syllable after syl.end_time
    LEADOUT_DROP = 50  # Pixels to drop down during leadout
    LEADOUT_ROTATION = -180  # Degrees to rotate during leadout (negative = left)

    for syl in Utils.all_non_empty(line.syls):
        # Choose random appearance
        random_shape = random.choice(AVAILABLE_SHAPES)
        colour = PALETTE[syl.i % len(PALETTE)]
        frz_start = random.randint(-50, 50)

        # Cache text shape
        text_shape = Convert.text_to_shape(syl)

        # Compute time segments
        # We slightly stagger syllables so they don't start simultaneously
        leadin_start = line.start_time + 100 * syl.i - LEADIN_TOTAL
        bounce_end = leadin_start + int(LEADIN_TOTAL * BOUNCE_PART)
        bounce_duration = bounce_end - leadin_start
        morph_end = leadin_start + LEADIN_TOTAL  # This equals syl absolute start

        # 1) Falling + bounce stage (random shape)
        FU_bounce = FrameUtility(leadin_start, bounce_end, meta.timestamps)
        for s, e, i, n in FU_bounce:
            l.layer = 0
            l.start_time = s
            l.end_time = e

            t = i / n  # 0 → 1 progress through bounce stage

            # Position – simple two-phase drop and bounce
            x = syl.left
            y = syl.top - BOUNCE_HEIGHT
            y += FU_bounce.add(0, bounce_duration * 0.85, BOUNCE_HEIGHT + 20)
            y -= FU_bounce.add(bounce_duration * 0.85, bounce_duration, 20)

            # Rotate the shape
            curr_frz = frz_start
            curr_frz -= FU_bounce.add(0, bounce_duration * 0.85, frz_start)

            l.text = (
                "{\\an7\\pos(%.3f,%.3f)\\p1\\1c%s\\bord2\\3c&H000000&\\frz%s}%s"
                % (x, y, colour, curr_frz, random_shape)
            )
            io.write_line(l)

        # 2) Morph stage – shape morphs to text
        if morph_end > bounce_end:
            FU_morph = FrameUtility(int(bounce_end), int(morph_end), meta.timestamps)
            for s, e, i, n in FU_morph:
                l.layer = 0
                l.start_time = s
                l.end_time = e

                t = i / n  # 0 → 1 progress through morph
                morphed_shape = random_shape.morph(text_shape, t)

                # Gradually change color to the original text color
                curr_colour = Utils.interpolate(t, colour, line.styleref.color1)

                l.text = "{\\an7\\pos(%.3f,%.3f)\\p1\\1c%s\\bord2\\3c&H000000&}%s" % (
                    syl.left,
                    syl.top,
                    curr_colour,
                    morphed_shape,
                )
                io.write_line(l)

        # Static syl waiting for beginning of main effect
        l.layer = 0
        l.start_time = l.end_time  # Last line end time
        l.end_time = line.start_time + syl.start_time
        l.text = "{\\an5\\pos(%.3f,%.3f)}%s" % (syl.center, syl.middle, syl.text)
        io.write_line(l)

        # Main Effect
        l.layer = 1

        l.start_time = line.start_time + syl.start_time
        l.end_time = line.end_time + 100 * syl.i
        l.duration = l.end_time - l.start_time

        l.text = (
            "{\\an5\\pos(%.3f,%.3f)"
            "\\t(0,%d,0.5,\\1c&HFFFFFF&\\3c&HABABAB&\\fscx125\\fscy125)"
            "\\t(%d,%d,1.5,\\fscx100\\fscy100\\1c%s\\3c%s)}%s"
            % (
                syl.center,
                syl.middle,
                syl.duration / 3,
                syl.duration / 3,
                syl.duration,
                line.styleref.color1,
                line.styleref.color3,
                syl.text,
            )
        )
        io.write_line(l)

        # 4) Leadout Effect - text morphs back to shape, moves down, rotates and fades
        leadout_start = line.end_time + 100 * syl.i
        leadout_end = leadout_start + LEADOUT_TOTAL

        # Split leadout into morph phase and movement/fade phase
        morph_back_duration = int(LEADOUT_TOTAL * 0.4)  # 40% for morphing back
        movement_duration = LEADOUT_TOTAL - morph_back_duration  # 60% for movement/fade

        morph_back_end = leadout_start + morph_back_duration

        # Phase 1: Text morphs back to shape while changing color
        FU_morph_back = FrameUtility(
            int(leadout_start), int(morph_back_end), meta.timestamps
        )
        for s, e, i, n in FU_morph_back:
            l.layer = 2
            l.start_time = s
            l.end_time = e

            t = i / n  # 0 → 1 progress through morph back
            morphed_shape = text_shape.morph(random_shape, t)

            # Change color from text color back to shape color
            curr_colour = Utils.interpolate(t, line.styleref.color1, colour)

            l.text = "{\\an7\\pos(%.3f,%.3f)\\p1\\1c%s\\bord2\\3c&H000000&}%s" % (
                syl.left,
                syl.top,
                curr_colour,
                morphed_shape,
            )
            io.write_line(l)

        # Phase 2: Shape moves down, rotates left and fades out
        if leadout_end > morph_back_end:
            FU_leadout = FrameUtility(
                int(morph_back_end), int(leadout_end), meta.timestamps
            )
            for s, e, i, n in FU_leadout:
                l.layer = 2
                l.start_time = s
                l.end_time = e

                t = i / n  # 0 → 1 progress through movement/fade

                # Position - move down gradually
                x = syl.left
                y = syl.top + FU_leadout.add(0, movement_duration, LEADOUT_DROP)

                # Rotation - rotate to the left
                rotation = FU_leadout.add(0, movement_duration, LEADOUT_ROTATION)

                # Fade out
                alpha_dec = int(FU_leadout.add(0, movement_duration, 255))
                alpha_ass = Convert.alpha_dec_to_ass(alpha_dec)

                l.text = (
                    "{\\alpha%s\\an7\\pos(%.3f,%.3f)\\frz%.1f\\p1\\1c%s\\bord2\\3c&H000000&}%s"
                    % (alpha_ass, x, y, rotation, colour, random_shape)
                )
                io.write_line(l)


# Subtitle effect - leadin/leadout morphing between lines
def sub(line: Line, l: Line, prev_line=None, next_line=None) -> None:
    """Create subtitle effect with text morphing leadout to next line."""

    # Base line
    l.layer = 0
    l.start_time = line.start_time - (int(line.leadin) if prev_line is None else 0)
    l.end_time = line.end_time + (int(line.leadout) if next_line is None else 0)
    l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,%d)}%s" % (
        line.center,
        line.middle,
        int(line.leadin) if prev_line is None else 0,
        int(line.leadout) if next_line is None else 0,
        line.text,
    )
    io.write_line(l)

    if next_line is not None:
        # Convert line text to shape for morphing
        line_text_shape = Convert.text_to_shape(line)

        l.layer = 0
        l.start_time = line.end_time
        l.end_time = next_line.start_time

        # Get next line's text as shape
        next_text_shape = Convert.text_to_shape(next_line)
        line_text_shape.move(line.left, line.top)
        next_text_shape.move(next_line.left, next_line.top)
        # print(line.left, next_line.left)

        FU_leadout = FrameUtility(l.start_time, l.end_time, meta.timestamps)

        for s, e, i, n in FU_leadout:
            l.start_time = s
            l.end_time = e

            t = i / n

            # Morph from current line text shape to next line text shape
            morphed_shape = line_text_shape.morph(next_text_shape, t)
            morphed_shape.move(-line.left, -line.top)

            l.text = "{\\an7\\pos(%.3f,%.3f)\\p1}%s" % (
                line.left,
                line.top,
                morphed_shape,
            )

            io.write_line(l)


# Separate romaji and subtitle lines for context-aware processing
romaji_lines = [
    line for line in Utils.all_non_empty(lines) if line.styleref.alignment >= 7
]
sub_lines = [
    line for line in Utils.all_non_empty(lines) if line.styleref.alignment <= 3
]

# Process romaji lines
for line in romaji_lines:
    romaji(line, line.copy())

# Process subtitle lines with previous/next line context
for i, line in enumerate(sub_lines):
    prev_line = sub_lines[i - 1] if i > 0 else None
    next_line = sub_lines[i + 1] if i < len(sub_lines) - 1 else None
    sub(line, line.copy(), prev_line, next_line)

# Save and open in Aegisub for preview
io.save()
io.open_aegisub()


================================================
FILE: examples/3 - Advanced/in.ass
================================================
[Script Info]
; Script generated by Aegisub 8975-master-8d77da3
; http://www.aegisub.org/
PlayResX: 1280
PlayResY: 720

[Aegisub Project Garbage]
Last Style Storage: Default
Video File: ?dummy:23.976000:2250:1920:1080:11:135:226:c
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Active Line: 1
Video Position: 342

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Arial,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1
Style: Romaji,Migu 1P,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1
Style: Translation,Migu 1P,46,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,0,2,25,25,25,1
Style: Kanji,Migu 1P,38,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1.8,0,4,25,25,25,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Comment: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,Font used (Version 1P, Bold): https://www.freejapanesefont.com/migu-font-%E3%83%9F%E3%82%B0%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88/
Dialogue: 0,0:00:14.24,0:00:24.23,Romaji,,0,0,0,,{\k56}su{\k13}re{\k22}chi{\k36}ga{\k48}u{\k25} {\k34}ko{\k33}to{\k50}ba {\k15}no {\k17}u{\k34}ra {\k46}ni{\k33} {\k28}to{\k36}za{\k65}sa{\k33}{\k30}re{\k51}ta{\k16} {\k33}ko{\k33}ko{\k78}ro {\k15}no {\k24}ka{\k95}gi
Dialogue: 0,0:00:24.68,0:00:34.50,Romaji,,0,0,0,,{\k51}ki{\k13}mi {\k18}to {\k32}i{\k49}u{\k28} {\k37}fu{\k32}ra{\k46}gu {\k19}ka{\k13}i{\k35}jo {\k51}ga{\k30} {\k33}e{\k34}ga{\k32}o{\k30} {\k35}su{\k38}ku{\k59}e{\k64}ru {\k67}no{\k29} {\k19}na{\k88}ra
Dialogue: 0,0:00:35.06,0:00:40.09,Romaji,,0,0,0,,{\k16}na{\k18}tsu {\k15}no {\k30}ka{\k35}ze {\k12}i{\k40}za{\k14}na{\k50}u{\k32} {\k17}ha{\k12}ku{\k19}chu{\k31}u {\k25}no {\k23}su{\k33}ko{\k15}o{\k66}ru
Dialogue: 0,0:00:40.84,0:00:44.80,Romaji,,0,0,0,,{\k34}ha{\k37}cho{\k39}u{\k16} {\k76}shi{\k96}n{\k12}ku{\k86}ro
Dialogue: 0,0:00:45.41,0:00:50.30,Romaji,,0,0,0,,{\k21}ro{\k27}man{\k36}chi{\k33}kku {\k16}mi{\k36}ta{\k19}i {\k40}ni{\k35} {\k17}ki {\k16}no {\k16}ki{\k30}i{\k33}ta {\k18}ko{\k31}to{\k19}ba {\k46}mo
Dialogue: 0,0:00:50.58,0:00:59.34,Romaji,,0,0,0,,{\k37}mi{\k17}tsu{\k47}ka{\k19}ra{\k46}nai {\k19}ke{\k36}do{\k45} {\k32}a{\k28}o{\k33}zo{\k36}ra {\k29}o{\k31} {\k32}me{\k31}za{\k178}su{\k56} {\k14}ka{\k110}ra
Dialogue: 0,0:00:59.70,0:01:04.24,Romaji,,0,0,0,,{\k33}bo{\k19}ku{\k30}ta{\k33}chi {\k85}wa{\k27} {\k15}hi{\k16}to{\k36}tsu {\k17}ni {\k31}na{\k32}re{\k80}ru
Dialogue: 0,0:01:04.56,0:01:09.78,Romaji,,0,0,0,,{\k22}to{\k16}ma{\k30}do{\k16}i {\k35}no {\k30}na{\k111}mi{\k20}da {\k43}mo{\k34} {\k34}yu{\k31}me {\k100}mo
Dialogue: 0,0:01:10.00,0:01:15.96,Romaji,,0,0,0,,{\k46}shi{\k11}n{\k29}ji{\k33}tsu {\k84}ni{\k26} {\k24}hi{\k10}ki{\k29}yo{\k27}se{\k31}ra{\k30}re{\k115}ru {\k17}mo{\k84}no
Dialogue: 0,0:01:16.20,0:01:24.48,Romaji,,0,0,0,,{\k33}so{\k55}re {\k26}wa{\k24} {\k13}i{\k9}to{\k48}shi{\k62}su{\k19}gi{\k85}ru{\k108} {\k40}ma{\k36}ho{\k63}u {\k32}no {\k18}ki{\k32}i{\k101}waa{\k24}do
Comment: 0,0:01:24.39,0:01:26.39,Default,,0,0,0,,
Dialogue: 0,0:00:14.24,0:00:18.56,Kanji,,0,0,0,,{\k56}す{\k13}れ{\k58}違{\k48}う{\k25}{\k67}言{\k50}葉{\k15}の{\k51}裏{\k49}に
Dialogue: 0,0:00:18.86,0:00:24.23,Kanji,,0,0,0,,{\k28}閉{\k36}ざ{\k65}さ{\k33}{\k30}れ{\k51}た{\k16}{\k144}心{\k15}の{\k24}カ{\k95}ギ
Dialogue: 0,0:00:24.68,0:00:28.91,Kanji,,0,0,0,,{\k51}キ{\k13}ミ{\k18}と{\k32}い{\k49}う{\k28}{\k37}フ{\k32}ラ{\k46}グ{\k32}解{\k35}除{\k50}が
Dialogue: 0,0:00:29.22,0:00:34.50,Kanji,,0,0,0,,{\k33}笑{\k66}顔{\k30}{\k73}救{\k59}え{\k64}る{\k67}の{\k29}{\k19}な{\k88}ら
Dialogue: 0,0:00:35.06,0:00:40.09,Kanji,,0,0,0,,{\k34}夏{\k15}の{\k65}風{\k12}い{\k40}ざ{\k14}な{\k50}う{\k32}{\k29}白{\k50}昼{\k25}の{\k23}ス{\k33}コ{\k15}ー{\k66}ル
Dialogue: 0,0:00:40.84,0:00:44.80,Kanji,,0,0,0,,{\k34}波{\k76}長{\k16}{\k76}シ{\k96}ン{\k12}ク{\k86}ロ
Dialogue: 0,0:00:45.41,0:00:50.30,Kanji,,0,0,0,,{\k21}ロ{\k27}マン{\k36}チ{\k33}ック{\k16}み{\k36}た{\k19}い{\k40}に{\k35}{\k17}気{\k16}の{\k16}利{\k30}い{\k33}た{\k49}言{\k19}葉{\k46}も
Dialogue: 0,0:00:50.58,0:00:59.34,Kanji,,0,0,0,,{\k37}見{\k17}つ{\k47}か{\k19}ら{\k46}ない{\k19}け{\k36}ど{\k45}{\k60}青{\k69}空{\k29}を{\k31}{\k32}目{\k31}指{\k178}す{\k56}{\k14}か{\k110}ら
Dialogue: 0,0:00:59.70,0:01:04.24,Kanji,,0,0,0,,{\k52}僕{\k63}達{\k85}は{\k27}{\k31}一{\k36}つ{\k17}に{\k31}な{\k32}れ{\k80}る
Dialogue: 0,0:01:04.56,0:01:09.78,Kanji,,0,0,0,,{\k22}戸{\k46}惑{\k16}い{\k35}の{\k161}涙{\k43}も{\k34}{\k65}夢{\k100}も
Dialogue: 0,0:01:10.00,0:01:15.96,Kanji,,0,0,0,,{\k57}真{\k62}実{\k84}に{\k26}{\k24}引{\k10}き{\k29}寄{\k27}せ{\k31}ら{\k30}れ{\k115}る{\k17}も{\k84}の
Dialogue: 0,0:01:16.20,0:01:24.48,Kanji,,0,0,0,,{\k33}そ{\k55}れ{\k26}は{\k24}{\k22}愛{\k48}し{\k62}す{\k19}ぎ{\k85}る{\k108}{\k40}魔{\k99}法{\k32}の{\k18}キ{\k32}ー{\k101}ワー{\k24}ド
Comment: 0,0:00:13.82,0:00:14.22,Default,,0,0,0,,
Dialogue: 0,0:00:14.24,0:00:24.23,Translation,,0,0,0,,Guarda oltre le parole e cerca la chiave per il mio cuore,
Dialogue: 0,0:00:24.68,0:00:34.50,Translation,,0,0,0,,se credi che il mio affetto possa renderti felice.
Dialogue: 0,0:00:35.06,0:00:40.09,Translation,,0,0,0,,La brezza di tarda estate si trasforma in una tempesta tumultuosa,
Dialogue: 0,0:00:40.84,0:00:44.80,Translation,,0,0,0,,mentre entriamo in sintonia.
Dialogue: 0,0:00:45.41,0:00:50.30,Translation,,0,0,0,,Sono un pessimo romantico, mi mancano le parole giuste,
Dialogue: 0,0:00:50.58,0:00:59.34,Translation,,0,0,0,,eppure sto ancora puntando al cielo, così sconfinato...
Dialogue: 0,0:00:59.70,0:01:04.24,Translation,,0,0,0,,Possiamo diventare una cosa sola
Dialogue: 0,0:01:04.56,0:01:09.78,Translation,,0,0,0,,mentre i nostri sogni e le nostre lacrime confuse
Dialogue: 0,0:01:10.00,0:01:15.96,Translation,,0,0,0,,ci portano sempre più vicini alla verità,
Dialogue: 0,0:01:16.20,0:01:24.48,Translation,,0,0,0,,che ormai è divenuta la nostra amata parola chiave magica.


================================================
FILE: examples/4 - Community/1 - Dangos/dango_config.py
================================================
"""
Shape & colour definitions for the "Dangos".

It provides:
- Vector paths for body, eyes and accessories.
- Helpers `_make_shape` / `_make_config` that normalise coordinates and
  build dictionaries understood by the renderer.
- Public constants:
    RENDER_ORDER         → order in which to render the shapes
    VARIANT_LOOKUP       → {variant: (shape_dict, style_dict)}
    VARIANT_BASE_CONFIGS → palettes for random “base” dangos
    DANGO_ALTERNATIVES   → extra shapes toggled during animation

Adding a new variant:
1) Draw your SVG path(s) with Aegisub/other tools and assign them to a constant at the top of this file;
2) Include the paths in `_SHAPE_SETS` and create a colour entry in `_COLOR_CONFIGS`;
3) That's it: the new shape will be assigned to syllables having the same `inline_fx` value!
"""

from pyonfx import Shape

# Base shape components (raw paths before transformation)
_DANGO_BODY = (
    "m 0 23 b 0 13 5.6 8 16 8 26.4 8 32 13 32 23 32 32 26.4 36 16 36 5.6 36 0 32 0 23"
)

_BASE_EYES = "m 12.48 15.3 b 12.88 15.3 13.28 15.8 13.28 16.3 l 13.28 23.3 b 13.28 23.8 12.88 24.3 12.48 24.3 12.08 24.3 11.68 23.8 11.68 23.3 l 11.68 16.3 b 11.68 15.8 12.08 15.3 12.48 15.3 m 20.48 15.3 b 20.88 15.3 21.28 15.8 21.28 16.3 l 21.28 23.3 b 21.28 23.8 20.88 24.3 20.48 24.3 20.08 24.3 19.68 23.8 19.68 23.3 l 19.68 16.3 b 19.68 15.8 20.08 15.3 20.48 15.3"
_CUTE_EYES = "m 13.04 14.8 b 13.24 14.8 13.44 15.2 13.44 15.6 l 13.44 21.2 b 13.44 21.6 13.24 22 13.04 22 12.84 22 12.64 21.6 12.64 21.2 l 12.64 15.6 b 12.64 15.2 12.84 14.8 13.04 14.8 m 18.64 14.8 b 18.84 14.8 19.04 15.2 19.04 15.6 l 19.04 21.2 b 19.04 21.6 18.84 22 18.64 22 18.44 22 18.24 21.6 18.24 21.2 l 18.24 15.6 b 18.24 15.2 18.44 14.8 18.64 14.8"
_ANGRY_EYES = "m 7.84 13.5 b 7.92 13.4 8.24 13.5 8.56 13.6 l 12.64 15.4 b 12.88 15.5 13.2 15.7 13.12 15.8 13.04 15.9 12.72 15.9 12.4 15.7 l 8.32 14 b 8 13.8 7.76 13.6 7.84 13.5 m 12.8 15.5 b 12.96 15.5 13.2 15.8 13.2 16.2 l 13.2 21.1 b 13.2 21.4 12.96 21.8 12.8 21.8 12.56 21.8 12.4 21.4 12.4 21.1 l 12.4 16.2 b 12.4 15.8 12.56 15.5 12.8 15.5 m 23.68 13.5 b 23.6 13.4 23.28 13.5 22.96 13.6 l 18.88 15.4 b 18.64 15.5 18.32 15.7 18.4 15.8 18.48 15.9 18.8 15.9 19.12 15.7 l 23.2 14 b 23.52 13.8 23.76 13.6 23.68 13.5 m 18.72 15.5 b 18.56 15.5 18.32 15.8 18.32 16.2 l 18.32 21.1 b 18.32 21.4 18.56 21.8 18.72 21.8 18.96 21.8 19.12 21.4 19.12 21.1 l 19.12 16.2 b 19.12 15.8 18.96 15.5 18.72 15.5"
_OLD_EYES = "m 4.115 18.618 b 4.083 18.446 4.461 18.204 4.871 18.135 l 10.607 17.162 b 11.016 17.093 11.458 17.196 11.489 17.368 11.521 17.54 11.143 17.782 10.733 17.852 l 4.997 18.824 b 4.588 18.894 4.147 18.791 4.115 18.618 m 27.565 18.618 b 27.597 18.446 27.219 18.204 26.809 18.135 l 21.073 17.162 b 20.664 17.093 20.222 17.196 20.191 17.368 20.159 17.54 20.537 17.782 20.947 17.852 l 26.683 18.824 b 27.092 18.894 27.533 18.791 27.565 18.619"
_XD_EYES = "m 7.6 16 b 7.6 15.6 7.92 15.4 8.24 15.6 l 12.08 18.6 8.24 21.6 b 7.92 21.8 7.6 21.6 7.6 21.2 l 10.8 18.6 7.6 16 m 23.6 16 b 23.6 15.6 23.28 15.4 22.96 15.6 l 19.12 18.6 22.96 21.6 b 23.28 21.8 23.6 21.6 23.6 21.2 l 20.4 18.6 23.6 16"
_SPIRAL_EYES = "m 12.712 19.26 b 12.656 18.44 12.336 17.7 11.8 17.19 11.456 16.85 11 16.62 10.552 16.54 10.312 16.5 10.032 16.5 9.8 16.54 9.504 16.6 9.208 16.73 8.968 16.91 8.552 17.22 8.272 17.64 8.144 18.14 8.096 18.32 8.072 18.47 8.064 18.66 8.024 19.57 8.536 20.35 9.272 20.5 9.488 20.55 9.712 20.54 9.896 20.47 10.072 20.42 10.248 20.3 10.368 20.17 10.552 19.97 10.672 19.66 10.664 19.42 10.656 19.3 10.632 19.2 10.568 19.03 10.496 18.83 10.408 18.71 10.28 18.63 10.168 18.56 10.096 18.54 9.96 18.55 9.864 18.55 9.816 18.56 9.736 18.6 9.568 18.67 9.448 18.79 9.4 18.97 9.392 19.01 9.384 19.03 9.384 19.11 9.384 19.19 9.392 19.21 9.4 19.25 9.416 19.31 9.44 19.36 9.472 19.42 l 9.504 19.46 9.48 19.46 b 9.416 19.45 9.336 19.43 9.304 19.41 9.256 19.39 9.216 19.36 9.16 19.29 9.04 19.14 8.984 18.96 8.992 18.73 9 18.47 9.096 18.2 9.248 18 9.408 17.8 9.648 17.64 9.904 17.59 10.024 17.56 10.232 17.55 10.36 17.57 11.008 17.66 11.528 18.18 11.712 18.91 11.808 19.28 11.8 19.7 11.704 20.09 11.504 20.87 10.976 21.48 10.256 21.73 9.928 21.85 9.552 21.89 9.176 21.83 8.864 21.79 8.552 21.67 8.264 21.5 7.992 21.33 7.784 21.15 7.576 20.9 7.264 20.53 7.056 20.08 6.936 19.58 6.728 18.66 6.888 17.63 7.376 16.8 7.544 16.52 7.768 16.24 8.008 16.02 8.44 15.61 8.984 15.33 9.568 15.21 9.768 15.17 9.92 15.15 10.144 15.15 10.512 15.14 10.736 15.17 11.176 15.28 11.296 15.32 11.408 15.3 11.52 15.24 11.656 15.15 11.736 15 11.752 14.83 11.768 14.6 11.656 14.39 11.464 14.3 11.36 14.26 11.064 14.18 10.832 14.15 10.648 14.12 10.512 14.11 10.312 14.1 9.616 14.08 8.92 14.25 8.296 14.58 8.2 14.63 7.976 14.77 7.888 14.83 7.304 15.24 6.84 15.76 6.496 16.41 6.232 16.91 6.056 17.46 5.968 18.03 5.936 18.24 5.92 18.44 5.92 18.67 5.904 19.44 6.072 20.18 6.4 20.84 6.536 21.1 6.672 21.32 6.872 21.56 7.04 21.78 7.176 21.92 7.376 22.08 8.16 22.74 9.152 23.02 10.096 22.85 10.904 22.7 11.632 22.21 12.104 21.49 12.504 20.9 12.72 20.16 12.712 19.44 12.712 19.38 12.712 19.3 12.712 19.26 m 18.176 19.97 b 18.552 20.64 19.12 21.08 19.792 21.19 20.224 21.26 20.712 21.18 21.128 20.97 21.36 20.85 21.6 20.68 21.784 20.5 22.016 20.26 22.224 19.97 22.36 19.66 22.592 19.13 22.664 18.6 22.584 18.08 22.552 17.89 22.512 17.75 22.44 17.58 22.112 16.77 21.36 16.41 20.656 16.74 20.456 16.84 20.264 16.98 20.128 17.16 20 17.31 19.896 17.52 19.84 17.71 19.76 18 19.784 18.34 19.888 18.54 19.944 18.65 20 18.72 20.12 18.82 20.264 18.95 20.392 19 20.536 18.99 20.656 18.98 20.72 18.95 20.84 18.86 20.928 18.8 20.96 18.76 21.016 18.68 21.136 18.51 21.184 18.33 21.16 18.15 21.152 18.1 21.144 18.09 21.112 18.01 21.08 17.95 21.072 17.93 21.048 17.9 21.008 17.86 20.968 17.83 20.912 17.8 l 20.872 17.78 20.896 17.77 b 20.952 17.74 21.032 17.71 21.064 17.7 21.12 17.69 21.168 17.69 21.24 17.72 21.408 17.77 21.52 17.89 21.608 18.1 21.704 18.33 21.728 18.62 21.672 18.89 21.624 19.17 21.472 19.45 21.28 19.66 21.184 19.76 21.008 19.89 20.888 19.96 20.288 20.28 19.632 20.16 19.184 19.64 18.952 19.38 18.792 19.02 18.72 18.61 18.576 17.81 18.792 16.96 19.312 16.29 19.552 15.98 19.856 15.72 20.208 15.53 20.496 15.37 20.816 15.28 21.128 15.25 21.432 15.22 21.688 15.24 21.968 15.33 22.384 15.46 22.744 15.71 23.048 16.08 23.6 16.74 23.864 17.73 23.776 18.76 23.744 19.11 23.664 19.49 23.552 19.83 23.328 20.46 22.976 21.04 22.52 21.5 22.36 21.67 22.24 21.78 22.048 21.92 21.728 22.16 21.528 22.27 21.096 22.44 20.976 22.49 20.888 22.58 20.816 22.7 20.736 22.86 20.72 23.04 20.776 23.2 20.856 23.41 21.04 23.52 21.24 23.48 21.344 23.45 21.632 23.33 21.848 23.22 22.016 23.13 22.144 23.05 22.32 22.93 22.928 22.51 23.464 21.94 23.872 21.26 23.936 21.15 24.072 20.89 24.128 20.78 24.464 20.07 24.656 19.33 24.696 18.55 24.728 17.95 24.664 17.36 24.504 16.82 24.448 16.61 24.384 16.43 24.296 16.23 24 15.56 23.56 15.02 23.008 14.65 22.784 14.51 22.576 14.41 22.312 14.32 22.08 14.24 21.904 14.2 21.672 14.18 20.728 14.1 19.752 14.48 19.008 15.22 18.368 15.85 17.936 16.74 17.808 17.65 17.704 18.41 17.808 19.19 18.104 19.81 18.128 19.86 18.16 19.93 18.176 19.97"

_CUTE_CHEEKS = "m 7.672 26.13 b 7.672 22.35 12.208 22.35 12.208 26.13 12.208 29.91 7.672 29.91 7.672 26.13 m 19.672 26.13 b 19.672 22.35 24.208 22.35 24.208 26.13 24.208 29.91 19.672 29.91 19.672 26.13"
_ANGRY_CHEEKS = "m 8.895 22.156 b 8.952 22.182 8.954 22.395 8.899 22.583 l 8.133 25.214 b 8.078 25.402 7.967 25.565 7.91 25.539 7.854 25.513 7.852 25.3 7.907 25.112 l 8.673 22.481 b 8.728 22.293 8.839 22.13 8.895 22.156 m 10.023 22.669 b 10.08 22.695 10.081 22.908 10.026 23.096 l 9.26 25.727 b 9.206 25.915 9.094 26.078 9.038 26.052 8.982 26.026 8.98 25.813 9.035 25.625 l 9.801 22.994 b 9.856 22.806 9.967 22.643 10.023 22.669 m 11.151 23.182 b 11.207 23.208 11.209 23.421 11.154 23.609 l 10.388 26.24 b 10.333 26.428 10.222 26.591 10.166 26.565 10.109 26.539 10.108 26.326 10.162 26.138 l 10.929 23.507 b 10.983 23.319 11.094 23.157 11.151 23.182 m 8.047 22.089 b 8.067 22.019 8.298 22.044 8.509 22.14 l 11.456 23.48 b 11.666 23.576 11.856 23.742 11.835 23.813 11.815 23.883 11.584 23.858 11.373 23.762 l 8.427 22.422 b 8.216 22.326 8.026 22.16 8.047 22.089 m 7.773 23.029 b 7.794 22.958 8.024 22.984 8.235 23.079 l 11.182 24.42 b 11.392 24.516 11.582 24.682 11.562 24.753 11.541 24.823 11.31 24.798 11.1 24.702 l 8.153 23.361 b 7.942 23.266 7.752 23.099 7.773 23.029 m 7.499 23.968 b 7.52 23.898 7.751 23.923 7.961 24.019 l 10.908 25.36 b 11.119 25.456 11.309 25.622 11.288 25.692 11.268 25.763 11.037 25.738 10.826 25.642 l 7.879 24.301 b 7.669 24.205 7.479 24.039 7.499 23.968 m 7.226 24.908 b 7.246 24.838 7.477 24.863 7.688 24.959 l 10.635 26.3 b 10.845 26.395 11.035 26.562 11.015 26.632 10.994 26.702 10.763 26.677 10.553 26.581 l 7.606 25.241 b 7.395 25.145 7.205 24.979 7.226 24.908 m 23.105 22.156 b 23.048 22.182 23.046 22.395 23.101 22.583 l 23.867 25.214 b 23.922 25.402 24.033 25.565 24.09 25.539 24.146 25.513 24.148 25.3 24.093 25.112 l 23.327 22.481 b 23.272 22.293 23.161 22.13 23.105 22.156 m 21.977 22.669 b 21.92 22.695 21.919 22.908 21.974 23.096 l 22.74 25.727 b 22.794 25.915 22.906 26.078 22.962 26.052 23.018 26.026 23.02 25.813 22.965 25.625 l 22.199 22.994 b 22.144 22.806 22.033 22.643 21.977 22.669 m 20.849 23.182 b 20.793 23.208 20.791 23.421 20.846 23.609 l 21.612 26.24 b 21.667 26.428 21.778 26.591 21.834 26.565 21.891 26.539 21.892 26.326 21.838 26.138 l 21.071 23.507 b 21.017 23.319 20.906 23.157 20.849 23.182 m 23.953 22.089 b 23.933 22.019 23.702 22.044 23.491 22.14 l 20.544 23.48 b 20.334 23.576 20.144 23.742 20.165 23.813 20.185 23.883 20.416 23.858 20.627 23.762 l 23.573 22.422 b 23.784 22.326 23.974 22.16 23.953 22.089 m 24.227 23.029 b 24.206 22.958 23.976 22.984 23.765 23.079 l 20.818 24.42 b 20.608 24.516 20.418 24.682 20.438 24.753 20.459 24.823 20.69 24.798 20.9 24.702 l 23.847 23.361 b 24.058 23.266 24.248 23.099 24.227 23.029 m 24.501 23.968 b 24.48 23.898 24.249 23.923 24.039 24.019 l 21.092 25.36 b 20.881 25.456 20.691 25.622 20.712 25.692 20.732 25.763 20.963 25.738 21.174 25.642 l 24.121 24.301 b 24.331 24.205 24.521 24.039 24.501 23.968 m 24.774 24.908 b 24.754 24.838 24.523 24.863 24.312 24.959 l 21.365 26.3 b 21.155 26.395 20.965 26.562 20.985 26.632 21.006 26.702 21.237 26.677 21.447 26.581 l 24.394 25.241 b 24.605 25.145 24.795 24.979 24.774 24.908"

_WRINKLES = "m 9.92 13.5 b 13.92 13 17.92 13 21.92 13.5 m 12.32 12 b 14.72 11.5 17.12 11.5 19.52 12"
_MOUSTACHE = "m 6.76 30.87 b 10 30.1 9.04 18.2 15.52 21.84 22 18.2 23.2 30.1 25.96 30.87 19.66 32.62 18.76 24.92 15.52 26.39 12.4 24.92 13.06 32.62 6.76 30.87"


# Helper functions
def _make_shape(path: str) -> Shape:
    return Shape(path).move(-10, 9).scale(75, 75)


def _make_shapes(**shapes) -> dict[str, Shape]:
    return {name: _make_shape(path) for name, path in shapes.items()}


def _make_config(
    body_colors: tuple[str, str], **extras
) -> dict[str, dict[str, str | float]]:
    """Create a dango configuration with body colors and optional extras."""
    config = {
        "body": {"1c": body_colors[0], "3c": body_colors[1], "bord": 2.0},
        "eyes": {"1c": "&H000000&", "bord": 0.0},
    }
    config.update(extras)
    return config


# Shape sets for each dango variant
_SHAPE_SETS = {
    "base": _make_shapes(body=_DANGO_BODY, eyes=_BASE_EYES),
    "cute": _make_shapes(body=_DANGO_BODY, eyes=_CUTE_EYES, cheeks=_CUTE_CHEEKS),
    "angry": _make_shapes(body=_DANGO_BODY, eyes=_ANGRY_EYES, cheeks=_ANGRY_CHEEKS),
    "grandpa": _make_shapes(
        body=_DANGO_BODY, eyes=_OLD_EYES, wrinkles=_WRINKLES, moustache=_MOUSTACHE
    ),
    "granny": _make_shapes(body=_DANGO_BODY, eyes=_OLD_EYES, wrinkles=_WRINKLES),
}

# Color configurations for each variant
_COLOR_CONFIGS = {
    "base": _make_config(("&HC7E6EE&", "&H9AC6E2&")),
    "cute": _make_config(
        ("&HE5DCFD&", "&H9A92D5&"), cheeks={"1c": "&HB0A8F5&", "bord": 0.0}
    ),
    "angry": _make_config(
        ("&HF7E4CC&", "&HE8BD9F&"), cheeks={"1c": "&H6A77A7&", "bord": 0.0}
    ),
    "grandpa": _make_config(
        ("&HDAE3EB&", "&H8F9CA7&"),
        wrinkles={"1c": "&H000000&", "bord": 0.0},
        moustache={"1c": "&HD4D5D8&", "3c": "&H8F9197&", "bord": 1.0},
    ),
    "granny": _make_config(
        ("&HDBDCEA&", "&H6B7191&"), wrinkles={"1c": "&H000000&", "bord": 0.0}
    ),
}

# Exported constants
RENDER_ORDER = ["body", "wrinkles", "cheeks", "moustache", "eyes"]

VARIANT_LOOKUP: dict[
    str, tuple[dict[str, Shape], dict[str, dict[str, str | float]]]
] = {name: (_SHAPE_SETS[name], _COLOR_CONFIGS[name]) for name in _SHAPE_SETS}

VARIANT_BASE_CONFIGS = [
    _make_config(colors)
    for colors in [
        ("&HC7E6EE&", "&H9AC6E2&"),  # Yellow
        ("&HE6E6F7&", "&HB3AFDF&"),  # Pink
        ("&HE6DAEA&", "&HAEA3B9&"),  # Purple
        ("&HB8C5D6&", "&H8389A2&"),  # Brown
        ("&HF5EEE2&", "&HD7C6A2&"),  # Blue
        ("&HCCEBD2&", "&HA8C6AF&"),  # Green
        ("&HCDC594&", "&HA59D77&"),  # Aqua
    ]
]

DANGO_ALTERNATIVES: dict[str, list[Shape]] = {
    "eyes": [
        _make_shape(_XD_EYES),
        _make_shape(_SPIRAL_EYES),
    ]
}


================================================
FILE: examples/4 - Community/1 - Dangos/in.ass
================================================
[Script Info]
; Script generated by Aegisub 3.4.2
; http://www.aegisub.org/
Title: Default Aegisub file
ScriptType: v4.00+
WrapStyle: 0
PlayResX: 1280
PlayResY: 720
ScaledBorderAndShadow: yes
Video Aspect Ratio: 0
Video Zoom: 8
YCbCr Matrix: TV.709

[Aegisub Project Garbage]
Last Style Storage: Default
Video File: ?dummy:23.976000:2500:1280:720:255:255:255:
Video AR Value: 1.777778

[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Default,Rencana,46,&H28EDEDED,&H000000FF,&H46464646,&H80000000,0,0,0,0,100,100,0,0,1,3,0,8,64,64,30,1
Style: ED Romaji,Rencana,46,&H28EDEDED,&H000000FF,&H46464646,&H80000000,0,0,0,0,100,100,0,0,1,3,0,8,64,64,30,1
Style: ED English,Rencana,44,&H28EDEDED,&H000000FF,&H46464646,&H80000000,0,0,0,0,100,100,0,0,1,3,0,2,64,64,30,1

[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:00.86,0:00:08.63,ED Romaji,,0,0,0,,{\k33}da{\k31}n{\k59}go {\k59}dan{\k59}go {\k58}dan{\k63}go {\k60}dan{\k60}go {\k30}da{\k29}n{\k60}go {\k59}dai{\k22}ka{\k37}zo{\k58}ku
Dialogue: 0,0:00:10.42,0:00:19.50,ED Romaji,,0,0,0,,{\k34}{\-angry}ya{\k30}n{\k58}cha{\k62}na {\k61}ya{\k57}ki {\k64}da{\k56}n{\k61}go {\k22}ya{\k29}sa{\k31}shi{\k24}i{\k16} {\k60}{\-cute}a{\k59}n {\k63}da{\k58}n{\k63}go
Dialogue: 0,0:00:20.01,0:00:27.70,ED Romaji,,0,0,0,,{\k35}mi{\k27}n{\k61}na {\k41}mi{\k19}n{\k60}na {\k60}a{\k52}wa{\k69}se{\k55}te {\k32}hya{\k35}ku {\k56}nin {\k62}{\-angry}ka{\k60}zo{\k45}{\-cute}ku
Dialogue: 0,0:00:29.60,0:00:37.50,ED Romaji,,0,0,0,,{\k33}a{\k32}ka{\k0}-{\k35}cha{\k57}n {\k34}da{\k27}n{\k28}go {\k62}wa {\k58}i{\k60}tsu{\k54}mo {\k37}shi{\k26}a{\k27}wa{\k66}se {\k32}no {\k28}na{\k30}ka {\k64}de
Dialogue: 0,0:00:39.23,0:00:46.55,ED Romaji,,0,0,0,,{\k29\-grandpa}to{\k35}shi{\k29}yo{\k63}ri {\k31}da{\k27}n{\k28}go {\k58}wa {\k63}me {\k61}wo {\k53}ho{\k95}so{\k32}me{\k89}te{\k39}{\-granny}ru
Dialogue: 0,0:00:47.61,0:00:56.65,ED Romaji,,0,0,0,piggyback,{\k32}na{\k31}ka{\k23}yo{\k40}shi {\k62}da{\k29}n{\k32}go {\k25}{\k32}te {\k26}wo {\k34}tsu{\k29}na{\k31}gi {\k58}o{\k30}o{\k31}ki{\k26}na {\k36}ma{\k60}ru{\k29}i {\k56}wa {\k28}ni {\k35}na{\k28}ru {\k61}yo
Dialogue: 0,0:00:57.23,0:01:06.51,ED Romaji,,0,0,0,piggyback,{\k36}ma{\k27}chi {\k28}wo {\k32}tsu{\k59}ku{\k32}ri {\k57}dan{\k30}go {\k25}bo{\k36}shi {\k33}no {\k30}u{\k55}e {\k49}mi{\k14}n{\k60}na {\k50}de {\k36}{\k31}wa{\k34}ra{\k31}i{\k31}a{\k56}u {\k56}yo
Dialogue: 0,0:01:06.88,0:01:15.90,ED Romaji,,0,0,0,piggyback,{\k16}u{\k40}sa{\k29}gi {\k34}mo {\k58}so{\k31}ra {\k35}de {\k26}{\k29}te {\k32}wo {\k30}fu{\k26}t{\k34}te{\k57}mi{\k34}te{\k28}ru {\k29}de{\k29}k{\k63}ka{\k28}i {\k58}o{\k31}tsu{\k25}ki{\k0}-{\k35}sa{\k65}ma
Dialogue: 0,0:01:16.47,0:01:26.29,ED Romaji,,0,0,0,piggyback,{\k27}u{\k23}re{\k38}shi{\k30}i {\k60}ko{\k31}to {\k59}ka{\k22}na{\k37}shi{\k33}i {\k30}ko{\k29}to {\k38}mo {\k21}{\k63}{\-no_echo}ze{\k61}{\-no_echo}n{\k62}{\-no_echo}bu {\k58}{\k60}{\-no_echo}ma{\k29}{\-no_echo}ru{\k59}{\-no_echo}me{\k112}{\-no_echo}te
Dialogue: 0,0:00:00.86,0:00:08.63,ED English,,0,0,0,,Dango, dango, dango, dango, a big dango family.
Dialogue: 0,0:00:10.42,0:00:19.50,ED English,,0,0,0,,A mischievous roasted dango, a kind sweet bean dango,
Dialogue: 0,0:00:20.01,0:00:27.70,ED English,,0,0,0,,gather them all up and it's a family of a hundred.
Dialogue: 0,0:00:29.60,0:00:37.50,ED English,,0,0,0,,A baby dango is always cradled in happiness.
Dialogue: 0,0:00:39.23,0:00:46.55,ED English,,0,0,0,,An old dango gazes with squinty eyes.
Dialogue: 0,0:00:47.61,0:00:56.65,ED English,,0,0,0,,The dango friends will all hold hands and form a big circle.
Dialogue: 0,0:00:57.23,0:01:06.51,ED English,,0,0,0,,They'll found a village on a dango planet and all smile together.
Dialogue: 0,0:01:06.88,0:01:15.90,ED English,,0,0,0,,The rabbits are waving their hands from the big moon.
Dialogue: 0,0:01:16.47,0:01:26.00,ED English,,0,0,0,,Roll up all the happy and sad things...


================================================
FILE: examples/4 - Community/1 - Dangos/main.py
================================================
"""
Community example - 'Dangos' karaoke effect.

This script animates a troupe of "dango" blobs that morph out of every
romanji syllable and leave the stage with a playful exit.

Forged by CoffeeStraw, yet destined to be shaped by the entire community:
together we will build the world's first open-source, collaborative KFX!
Leave your mark, now. (ง •̀_•́)ง

File structure:
1) Dango class: represents one dango. It has a state, representing its geometry/style (`self.shape_parts`, `self.style_config`),
   properties (`self.x`, `self.y`, `self.frz`, `self.fscx`, `self.fscy`, `self.alpha`),
   and a timeline cursor (`self.current_time`). It provides an animation API
   (e.g. `idle`, `move_to`, `morph_from_shapes` and all the exit animations),
   each updating state and the time cursor.
2) Per-line processing: `process_romaji_line` instantiates one Dango
   per visible character, drives its morphing from the glyph and picks
   an `exit_*` routine;
3) Basic leadin/main effect in `leadin_effect` and `main_effect` functions.

Adding a new exit animation:
1) Implement `def exit_your_idea(self, **timing) -> "Dango":` inside
   Dango. Iterate with `FrameUtility`, update `self.x/y/...`, call
   `self._render_frame` every frame, advance `self.current_time` and
   `return self`;
2) Register your method in the `exit_effects` map inside
   `process_romaji_line` so it can be picked for the appropriate
   variant.

Follow PEP-8, keep the method side-effect-free apart from tweaking
`self.*` fields, and have fun animating!
"""

import math
import random
from copy import deepcopy
from typing import Literal

from dango_config import (
    DANGO_ALTERNATIVES,
    RENDER_ORDER,
    VARIANT_BASE_CONFIGS,
    VARIANT_LOOKUP,
)

from pyonfx import *

# Load ASS file
io = Ass("in.ass")
meta, styles, lines = io.get_data()


class Dango:
    def __init__(
        self,
        name: str,
        x: float,
        y: float,
        current_time: int,
        shape_parts: dict[str, Shape],
        style_config: dict[str, dict[str, str | float]],
        line_template: Line,
    ):
        self.name = name
        self.x = x
        self.y = y
        self.frz = 0
        self.fscx = 100
        self.fscy = 100
        self.alpha = 0
        self.line_template = line_template

        # Dynamic shape parts and styles
        self.shape_parts: dict[str, Shape] = {}
        self.style_config: dict[str, dict[str, str | float]] = {}

        # Animation state
        self.current_time = current_time
        self.base_layer = 0

        # Load variant data
        self.load_variant(shape_parts, style_config)

    def load_variant(
        self,
        shape_parts: dict[str, Shape],
        style_config: dict[str, dict[str, str | float]],
    ) -> "Dango":
        """Load shapes and styles from a variant definition."""
        # Deep copy shapes and styles
        self.shape_parts = deepcopy(shape_parts)
        self.style_config = deepcopy(style_config)

        # Randomly shift eyes position
        if "eyes" in self.shape_parts and self.name == "base":
            self.shape_parts["eyes"].move(random.uniform(-4, 4), 0)
        return self

    def _create_style_tags_for_properties(self) -> str:
        """Create ASS style tags for internal properties."""
        tags = [f"\\pos({self.x:.3f},{self.y:.3f})"]

        if self.frz != 0:
            tags.append(f"\\frz{self.frz:.3f}")
        if self.fscx != 100:
            tags.append(f"\\fscx{self.fscx:.3f}")
        if self.fscy != 100:
            tags.append(f"\\fscy{self.fscy:.3f}")

        return "".join(tags)

    def _create_style_tags(self, part_name: str) -> str:
        """Create ASS style tags for a part, handling None values with defaults."""
        part_style = self.style_config[part_name]
        tags = [f"\\alpha{Convert.alpha_dec_to_ass(self.alpha)}"]

        # Handle part_style config
        for color_key in ["1c", "3c"]:
            if color_key in part_style and part_style[color_key] is not None:
                tags.append(f"\\{color_key}{part_style[color_key]}")
        bord = part_style.get("bord", 0)
        if bord is not None:
            tags.append(f"\\bord{bord:.3f}")

        # Handle internal properties
        tags.extend(self._create_style_tags_for_properties())

        return "".join(tags)

    def _create_interpolated_style_tags(
        self,
        progress: float,
        source_style: dict[str, str | float],
        target_style: dict[str, str | float],
    ) -> str:
        """Create ASS style tags for interpolated styling during morph."""
        tags = []

        # Handle primary/outline color
        for color_key in ["1c", "3c"]:
            if color_key not in target_style:
                continue
            if not source_style:
                tags.append(f"\\{color_key}{target_style[color_key]}")
                continue

            start_color = source_style[color_key]
            end_color = target_style[color_key]
            assert isinstance(start_color, str) and isinstance(end_color, str)

            interpolated_color = Utils.interpolate(progress, start_color, end_color)
            tags.append(f"\\{color_key}{interpolated_color}")

        # Handle 1a and 3a
        for alpha_key in ["1a", "3a"]:
            # Interpolate in-between source 1a/3a and 0.0
            start_alpha = 0.0 if not source_style else source_style[alpha_key]
            end_alpha = 0.0
            assert isinstance(start_alpha, float) and isinstance(end_alpha, float)
            interpolated_alpha = Utils.interpolate(progress, start_alpha, end_alpha)
            tags.append(f"\\{alpha_key}{Convert.alpha_dec_to_ass(interpolated_alpha)}")

        # Handle border thickness
        start_border = 0.0 if not source_style else source_style.get("bord", 0.0)
        end_border = target_style.get("bord", 0.0)
        assert isinstance(start_border, float) and isinstance(end_border, float)
        border_value = Utils.interpolate(progress, start_border, end_border)
        tags.append(f"\\bord{border_value:.3f}")

        # Add properties tags
        tags.append(self._create_style_tags_for_properties())

        return "".join(tags)

    def _render_frame(
        self, start_time: int, end_time: int, layer_offset: int = 0
    ) -> None:
        """Render current dango state to a single frame."""

        for part_name, shape in self.shape_parts.items():
            l = self.line_template.copy()
            l.layer = self.base_layer + layer_offset
            l.start_time = start_time
            l.end_time = end_time

            style_tags = self._create_style_tags(part_name)

            l.text = f"{{\\an7{style_tags}\\p1}}{shape}"
            io.write_line(l)

    @io.track
    def morph_from_shapes(
        self,
        source_shape_parts: dict[str, Shape],
        source_style_config: dict[str, dict[str, str | float]],
        duration: int,
        layer_offset: int = 0,
    ) -> "Dango":
        """Morph from source shapes to this dango over duration."""
        frame_util = FrameUtility(
            self.current_time, self.current_time + duration, meta.timestamps
        )
        for start, end, frame_idx, total_frames in frame_util:
            progress = frame_idx / total_frames

            # Morph source shape to dango shape
            morphed_chunks = Shape.morph_multi(
                source_shape_parts, self.shape_parts, progress, ensure_shell_pairs=False
            )
            # Sort morphed items based on target ID render order
            morphed_items = sorted(
                morphed_chunks.items(),
                key=lambda item: (
                    RENDER_ORDER.index(item[0][1])
                    if item[0][1] in RENDER_ORDER
                    else float("-inf")
                ),
            )

            # Render each morphed shape
            for (src_id, tgt_id), morphed_shape in morphed_items:
                l = self.line_template.copy()
                l.layer = self.base_layer + layer_offset
                l.start_time = start
                l.end_time = end

                # Create interpolated style tags
                style_tags = self._create_interpolated_style_tags(
                    progress,
                    source_style_config.get(src_id or "", {}),
                    self.style_config.get(tgt_id or "", {}),
                )

                l.text = f"{{\\an7\\pos({self.x:.3f},{self.y:.3f}){style_tags}\\p1}}{morphed_shape}"
                io.write_line(l)

        self.current_time += duration
        return self

    def idle(
        self,
        animation_type: Literal["static", "bounce", "angry_shake"],
        duration: int,
        settle_ms: int = 150,
        layer_offset: int = 0,
    ) -> "Dango":
        """Play idle animation for *duration* ms.

        The last ``settle_ms`` milliseconds will smoothly damp the motion so the
        dango returns to its neutral pose instead of stopping abruptly.
        """
        # Handle animated cases frame by frame
        settle_start = max(0, duration - settle_ms)
        frame_util = FrameUtility(
            self.current_time, self.current_time + duration, meta.timestamps
        )

        # Handle static case with single render
        if animation_type == "static":
            frame_util_frames = list(frame_util)
            start_time = frame_util_frames[0][0]
            end_time = frame_util_frames[-1][1]
            self._render_frame(start_time, end_time, layer_offset)
            self.current_time += end_time - start_time
            return self

        start_x, start_y = self.x, self.y

        for start, end, _, _ in frame_util:
            elapsed = start - self.current_time

            # 0→1 factor that linearly goes to 0 after settle_start
            damp = 1.0
            if elapsed >= settle_start:
                damp = 1 - (elapsed - settle_start) / settle_ms

            # Different idle animation types
            if animation_type == "bounce":
                bounce_amplitude = 4
                bounce_period = 400
                offset_y = (
                    bounce_amplitude
                    * damp
                    * math.sin(2 * math.pi * elapsed / bounce_period)
                )
                self.y = start_y + offset_y
                self._render_frame(start, end, layer_offset)

            elif animation_type == "angry_shake":
                shake_amplitude = 3
                shake_period = 150
                cur_amp = shake_amplitude * damp
                dx = random.uniform(-cur_amp, cur_amp)
                self.x = start_x + dx
                self.frz = shake_amplitude * math.sin(
                    2 * math.pi * elapsed / shake_period
                )
                self._render_frame(start, end, layer_offset)

        self.current_time += duration
        return self

    def move_to(
        self,
        new_x: float,
        new_y: float,
        duration: int,
        easing: Literal["in_back", "out_cubic"] | float = 1.0,
        layer_offset: int = 0,
        fade_duration: int | None = None,
    ) -> "Dango":
        """Animate movement to new position with optional fade-out."""
        frame_util = FrameUtility(
            self.current_time, self.current_time + duration, meta.timestamps
        )

        start_x, start_y = self.x, self.y
        start_alpha = self.alpha

        for start, end, frame_idx, total_frames in frame_util:
            progress = frame_idx / total_frames

            # Update position
            self.x = Utils.interpolate(progress, start_x, new_x, easing)
            self.y = Utils.interpolate(progress, start_y, new_y, easing)

            # Apply fade-out if requested
            if fade_duration is not None:
                elapsed = start - self.current_time
                fade_start = max(0, duration - fade_duration)
                if elapsed >= fade_start:
                    self.alpha = start_alpha
                    self.alpha += frame_util.add(
                        fade_start, duration, 255 - start_alpha
                    )
                else:
                    self.alpha = start_alpha

            self._render_frame(start, end, layer_offset)

        self.current_time += duration
        return self

    @io.track
    def exit_jump_down_fall(
        self,
        charge_duration: int = 100,
        jump_duration: int = 300,
        fall_duration: int = 650,
        fade_duration: int = 300,
        jump_height: int = 30,
        fall_distance: int = 60,
        charge_offset: float = 6.0,
        charge_fscy: float = 85,
        up_fscx: float = 70,
        up_fscy: float = 125,
        max_x_offset: int = 10,
    ) -> "Dango":
        """Exit jump sequence with squash-and-stretch charging animation."""
        start_x, start_y = self.x, self.y
        drift_x_final = random.uniform(-max_x_offset, max_x_offset)

        total_duration = charge_duration + jump_duration + fall_duration
        frame_util = FrameUtility(
            self.current_time, self.current_time + total_duration, meta.timestamps
        )

        for f_start, f_end, _, _ in frame_util:
            # Reset properties to defaults for each frame
            self.x, self.y = start_x, start_y
            self.fscx, self.fscy = 100, 100
            self.frz, self.alpha = 0, 0

            # 1. Crouch (downwards)
            self.y += frame_util.add(0, charge_duration, charge_offset)
            self.fscy += frame_util.add(0, charge_duration, charge_fscy - 100)

            # 2. Jump up (to peak)
            self.x += frame_util.add(charge_duration, total_duration, drift_x_final)
            self.y += frame_util.add(
                charge_duration, jump_duration, -jump_height - charge_offset, 0.8
            )
            self.fscx += frame_util.add(
                charge_duration,
                jump_duration,
                up_fscx - 100,
            )
            self.fscy += frame_util.add(
                charge_duration,
                jump_duration,
                up_fscy - charge_fscy,
            )

            # 3. Fall down past start
            self.y += frame_util.add(
                jump_duration,
                total_duration,
                jump_height + fall_distance,
                1.3,
            )
            self.fscy += frame_util.add(
                jump_duration,
                total_duration,
                100 - up_fscy,
                0.5,
            )
            self.fscx += frame_util.add(
                jump_duration,
                total_duration,
                100 - up_fscx,
                0.5,
            )

            # Fade-out
            self.alpha += frame_util.add(
                total_duration - fade_duration, total_duration, 255
            )

            # Render frame
            self._render_frame(f_start, f_end)

        # Reset scale for future animations
        self.fscx, self.fscy = 100, 100
        self.current_time += total_duration
        return self

    @io.track
    def exit_furious_dash(
        self,
        shake_duration: int = 2000,
        dash_duration: int = 800,
        fade_duration: int = 300,
        dash_distance: int = -200,
    ) -> "Dango":
        """Angry-specific exit: brief shake in place, then a fast left dash off-screen with fade-out."""
        # Phase 1: Shake in place using existing idle animation
        self.idle("angry_shake", shake_duration)

        # Phase 2: Dash movement with fade-out using enhanced move_to
        target_x = self.x + dash_distance
        self.move_to(
            target_x, self.y, dash_duration, "in_back", fade_duration=fade_duration
        )

        return self

    @io.track
    def exit_slow_steps(
        self,
        duration: int = 3000,
        steps: int = 3,
        fade_duration: int = 2000,
        drift_distance: int = 50,
    ) -> "Dango":
        """Old-specific: slow stepped movement using multiple move_to calls."""
        # Grandpa moves left, others move right
        dx_final = -drift_distance if self.name == "grandpa" else drift_distance

        # Calculate step parameters
        step_duration = duration // steps
        remaining_duration = duration - (
            step_duration * (steps - 1)
        )  # Last step gets any remainder
        dx_per_step = dx_final / steps

        # Execute stepped movement
        for step in range(steps):
            target_x = self.x + dx_per_step
            current_step_duration = (
                remaining_duration if step == steps - 1 else step_duration
            )

            # Only apply fade-out on the last step
            fade_for_step = fade_duration if step == steps - 1 else None

            self.move_to(
                target_x,
                self.y,
                current_step_duration,
                "out_cubic",
                fade_duration=fade_for_step,
            )

        return self

    @io.track
    def exit_heart_spiral(
        self,
        hold_duration: int = 1300,
        move_duration: int = 1500,
        fade_duration: int = 400,
        loops: int = 2,
        spiral_amplitude: int = 10,
        vertical_travel: int = -80,
        drift_x_total: int = -20,
    ) -> "Dango":
        """Cute-specific exit: ascend in corkscrew path leaving fading hearts."""

        # Phase 1: Gradual drift left
        drift_target_x = self.x + drift_x_total
        self.move_to(drift_target_x, self.y, hold_duration, "out_cubic")

        # Phase 2: Spiral motion phase (custom animation for complex movement)
        start_x, start_y = self.x, self.y
        frame_util = FrameUtility(
            self.current_time, self.current_time + move_duration, meta.timestamps
        )

        for f_start, f_end, frame_idx, _ in frame_util:
            elapsed = max(0, f_start - self.current_time)
            move_p = elapsed / move_duration

            # Reset position and properties each frame
            self.x, self.y = start_x, start_y
            self.frz = 0
            self.alpha = 0

            # Spiral motion
            angle = move_p * loops * 2 * math.pi
            spiral_x = spiral_amplitude * math.sin(angle) * (1 - move_p)

            self.x += spiral_x
            self.y += Utils.interpolate(move_p, 0, vertical_travel)
            self.frz = 15 * math.sin(angle)

            # Fade during final portion
            if elapsed >= move_duration - fade_duration:
                fade_p = (elapsed - (move_duration - fade_duration)) / fade_duration
                self.alpha = Utils.interpolate(fade_p, 0, 255)

            # Render
            self._render_frame(f_start, f_end)

            # Spawn hearts during spiral motion
            if frame_idx % 5 == 0:
                self._spawn_heart(f_start)

        self.current_time += move_duration
        return self

    @io.track
    def piggyback_onto(
        self,
        carrier: "Dango",
        *,
        climb_duration: int = 500,
        travel_duration: int = 1000,
        fade_duration: int = 400,
        fall_duration: int = 600,
        dx_travel: int = -80,
        fall_distance: int = 80,
        heavy_prob: float = 0.4,
        balancing_prob: float = 0.6,
        slip_prob: float = 0.35,
        xd_prob: float = 0.25,
    ) -> "Dango":
        """Ride *carrier* dango with optional wobble, balance and slip variations.

        Total duration is climb_duration + travel_duration, of which fade_duration is the last part spent fading out and fall_duration is the last part spent falling.
        """
        # Alternate eye shapes
        XD_EYES: Shape = DANGO_ALTERNATIVES["eyes"][0]
        SPIRAL_EYES: Shape = DANGO_ALTERNATIVES["eyes"][1]

        # Initial state / randomised variations
        is_heavy = random.random() < heavy_prob
        is_balancing = True if is_heavy else (random.random() < balancing_prob)
        is_slip = False if is_heavy else (random.random() < slip_prob)
        use_xd_upper = random.random() < xd_prob

        # Replace xD eyes permanently
        if use_xd_upper:
            self.shape_parts["eyes"] = XD_EYES

        # Idle to align both dangos
        if self.current_time < carrier.current_time:
            self.idle(
                "bounce", carrier.current_time - self.current_time, layer_offset=1
            )
        elif carrier.current_time < self.current_time:
            carrier.idle("bounce", self.current_time - carrier.current_time)

        # Climb phase
        carrier_x = carrier.x
        carrier_y = carrier.y
        self_x = carrier_x
        self_y = carrier_y - 18
        self.move_to(self_x, self_y, climb_duration, "out_cubic", layer_offset=1)
        carrier.idle("static", climb_duration)

        # Travel phase
        if is_heavy:
            carrier.shape_parts["eyes"] = SPIRAL_EYES

        start_time = self.current_time
        frame_util = FrameUtility(
            start_time, start_time + travel_duration, meta.timestamps
        )
        slip_trigger = travel_duration - fall_duration

        # Travel phase
        for f_start, f_end, _, _ in frame_util:
            elapsed = max(0, f_start - start_time)

            # Reset dynamic props for both dangos each frame
            self.x, self.y = self_x, self_y
            self.fscx, self.fscy = 100, 100
            self.frz, self.alpha = 0, 0
            carrier.x, carrier.y = carrier_x, carrier_y
            carrier.fscx, carrier.fscy = 100, 100
            carrier.frz, carrier.alpha = 0, 0

            # Travel (both)
            self.x += frame_util.add(0, travel_duration, dx_travel)
            carrier.x += frame_util.add(0, travel_duration, dx_travel)

            # Heavy wobble (lower)
            if is_heavy:
                wobble_period = 500
                wobble_ampl = 12
                wobble_phase = elapsed / wobble_period * 2 * math.pi
                carrier.fscy = 100 - wobble_ampl * abs(math.sin(wobble_phase))

            # Balancing (upper)
            if is_balancing and not (is_slip and elapsed >= fall_duration):
                bal_period = 600
                bal_ampl_rot = 8
                bal_phase = elapsed / bal_period * 2 * math.pi
                rot = bal_ampl_rot * math.sin(bal_phase)
                self.frz = rot
                self.fscy = 100 - 8 * abs(math.sin(bal_phase))

            # Slip / Fall (upper)
            if is_slip and elapsed >= slip_trigger:
                # change eyes to spiral while falling
                self.shape_parts["eyes"] = SPIRAL_EYES

                t_fall = elapsed - slip_trigger
                p = t_fall / fall_duration  # 0→1
                fall_offset = (p**2) * fall_distance  # ease-in
                horiz_factor = 1 - 0.7 * p  # 1 → 0.3

                self.y += fall_offset
                self.x = carrier.x * horiz_factor + (1 - horiz_factor) * self_x

            # Fades (both)
            carrier.alpha += frame_util.add(
                travel_duration - fade_duration, travel_duration, 255
            )
            self.alpha += frame_util.add(
                travel_duration - fade_duration, travel_duration, 255
            )

            # Render
            carrier._render_frame(f_start, f_end, 0)
            self._render_frame(f_start, f_end, 1)

        self.current_time = carrier.current_time = start_time + travel_duration
        return self

    @io.track
    def tug_away(
        self,
        target: "Dango",
        pre_duration: int = 400,
        drag_duration: int = 1800,
        fade_duration: int = 500,
        gap: int = 20,
        drag_distance: int = 300,
    ) -> "Dango":
        """Angry grabs cute and drags her away, leaving hearts."""
        # Sync timing - both start at the same time
        if self.current_time < target.current_time:
            self.idle("angry_shake", target.current_time - self.current_time)
        elif target.current_time < self.current_time:
            target.idle("static", self.current_time - target.current_time)

        # Phase 1: Angry approaches cute
        approach_target_x = target.x + gap
        self.move_to(
            approach_target_x, self.y, pre_duration, "out_cubic", layer_offset=1
        )
        target.idle("static", pre_duration, layer_offset=0)

        # Phase 2: Both move together in drag motion with fade-out
        start_x_angry, start_x_cute = self.x, target.x
        frame_util = FrameUtility(
            self.current_time, self.current_time + drag_duration, meta.timestamps
        )

        for f_start, f_end, _, _ in frame_util:
            # Reset dynamic properties for the current frame
            self.x, target.x = start_x_angry, start_x_cute
            self.frz = self.alpha = 0
            target.frz = target.alpha = 0

            # Horizontal drag motion
            move_offset = frame_util.add(0, drag_duration, drag_distance, 0.8)
            self.x += move_offset
            target.x += move_offset

            # Fade-out during the last part of the drag
            fade_alpha = frame_util.add(
                drag_duration - fade_duration, drag_duration, 255
            )
            self.alpha = fade_alpha
            target.alpha = fade_alpha

            # Render both dangos (angry above cute)
            self._render_frame(f_start, f_end, layer_offset=1)
            target._render_frame(f_start, f_end, layer_offset=0)

            # Spawn hearts during drag phase (25% chance per frame)
            if random.random() < 0.25:
                target._spawn_heart(f_start)

        # Update final state
        self.current_time = target.current_time = self.current_time + drag_duration
        return self

    def _spawn_heart(self, start_time: int) -> None:
        """Spawn a heart particle from dango's current position."""
        trail_dur = random.randint(450, 650)
        dx_rand = random.uniform(-15, 15)
        dy_fall = random.uniform(25, 50)
        scale_start = random.uniform(50, 80)
        scale_end = scale_start + random.uniform(40, 60)
        rot_h = random.uniform(-30, 30)

        h = self.line_template.copy()
        h.layer = max(0, self.base_layer - 1)
        h.start_time = start_time
        h.end_time = start_time + trail_dur

        heart_tags = (
            f"\\1c&HB0A8F5&\\3c&H7370DF&\\bord1"
            f"\\fscx{scale_start:.0f}\\fscy{scale_start:.0f}"
            f"\\move({self.x:.3f},{self.y:.3f},{self.x + dx_rand:.3f},{self.y + dy_fall:.3f})"
            f"\\t(0,{trail_dur // 2},\\fscx{scale_end:.0f}\\fscy{scale_end:.0f})"
            f"\\frz{rot_h:.1f}"
            f"\\fad(0,{trail_dur})"
        )

        h.text = f"{{\\an7{heart_tags}\\p1}}{Shape.heart(8)}"
        io.write_line(h)


@io.track
def leadin_effect(
    line: Line,
    char: Char,
    dango_style: dict[str, dict[str, str | float]],
):
    l = line.copy()
    l.layer = 5

    l.start_time = line.start_time - line.leadin // 2
    l.end_time = line.start_time + char.start_time

    accent_col = dango_style.get("body", {}).get("1c", "&HFFFFFF&")
    border_col = dango_style.get("body", {}).get("3c", "&H000000&")

    l.text = (
        "{\\an5\\pos(%.3f,%.3f)\\fad(%d,0)\\1c%s\\3c%s\\t(0,%d,\\1c%s\\3c%s)}%s"
        % (
            char.center,
            char.middle,
            line.leadin // 2,
            accent_col,
            border_col,
            line.leadin // 2,
            line.styleref.color1,
            line.styleref.color3,
            char.text,
        )
    )

    io.write_line(l)


@io.track
def main_effect(
    line: Line, char: Char, dango_style: dict[str, dict[str, str | float]]
) -> None:
    l = line.copy()
    l.layer = 6

    l.start_time = line.start_time + char.start_time
    l.end_time = line.start_time + char.end_time

    accent_col = dango_style.get("body", {}).get("1c", "&HFFFFFF&")
    border_col = dango_style.get("body", {}).get("3c", "&H000000&")

    l.text = (
        "{\\an5\\pos(%.3f,%.3f)"
        "\\t(0,%d,0.5,\\1c%s\\3c%s\\fscx125\\fscy125)"
        "\\t(%d,%d,1.5,\\fscx100\\fscy100\\1c%s\\3c%s)}%s"
        % (
            char.center,
            char.middle,
            char.duration // 3,
            accent_col,
            border_col,
            char.duration // 3,
            char.duration,
            line.styleref.color1,
            line.styleref.color3,
            char.text,
        )
    )

    io.write_line(l)


def process_romaji_line(line: Line, l: Line) -> None:
    # Character collection and context setup
    chars = Utils.all_non_empty(line.chars)
    contexts: list[
        tuple[tuple[dict[str, Shape], dict[str, dict[str, str | float]]], Dango]
    ] = []

    # Process each character
    for char in chars:
        fx_name = (getattr(char, "inline_fx", "") or "").strip().lower()

        # Determine variant and config
        if fx_name in VARIANT_LOOKUP and char.syl_char_i == 0:
            name = fx_name
            shape_parts, style_config = VARIANT_LOOKUP[fx_name]
        else:
            name = "base"
            shape_parts, style_config = VARIANT_LOOKUP["base"][0], random.choice(
                VARIANT_BASE_CONFIGS
            )

        # Create leadin and main effects
        leadin_effect(line, char, style_config)
        main_effect(line, char, style_config)

        # Create dango instance
        dango = Dango(
            name=name,
            x=char.left,
            y=char.top,
            current_time=line.start_time + char.end_time,
            shape_parts=shape_parts,
            style_config=style_config,
            line_template=l,
        )

        # Store context
        char_shape = Convert.text_to_shape(char)
        source_context = (
            {"char": char_shape},
            {
                "char": {
                    "1c": char.styleref.color1,
                    "3c": char.styleref.color3,
                    "1a": float(Convert.alpha_ass_to_dec(char.styleref.alpha1)),
                    "3a": float(Convert.alpha_ass_to_dec(char.styleref.alpha3)),
                    "bord": float(char.styleref.outline),
                }
            },
        )
        contexts.append((source_context, dango))

    # Leadout effect
    MORPH_DURATION = 400
    MORPH_EXTRA_TIME = 300

    # Pairing logic
    is_piggy_line = getattr(line, "effect", "").lower() == "piggyback"
    paired = set()

    for i, (curr_context, dango) in enumerate(contexts):
        if i in paired:
            continue

        # Angry-cute tug away pairing
        if dango.name == "angry":
            for j in range(i + 1, min(i + 6, len(contexts))):
                if j not in paired and contexts[j][1].name == "cute":
                    cute = contexts[j][1]
                    dango.morph_from_shapes(
                        *curr_context,
                        MORPH_DURATION + random.randint(0, MORPH_EXTRA_TIME),
                    )
                    cute.morph_from_shapes(
                        *contexts[j][0],
                        MORPH_DURATION + random.randint(0, MORPH_EXTRA_TIME),
                    )
                    dango.tug_away(cute)
                    paired.update([i, j])
                    break

        # Piggyback pairing
        elif is_piggy_line and i + 1 < len(contexts) and i + 1 not in paired:
            dango2 = contexts[i + 1][1]
            dango.morph_from_shapes(*curr_context, MORPH_DURATION, 1)
            dango2.morph_from_shapes(*contexts[i + 1][0], MORPH_DURATION)
            dango.piggyback_onto(dango2)
            paired.update([i, i + 1])

    # Handle solo dangos
    exit_effects = {
        "angry": "exit_furious_dash",
        "cute": "exit_heart_spiral",
        "grandpa": "exit_slow_steps",
        "granny": "exit_slow_steps",
    }
    for i, (curr_context, dango) in enumerate(contexts):
        if i not in paired:
            dango.morph_from_shapes(
                *curr_context, MORPH_DURATION + random.randint(0, MORPH_EXTRA_TIME)
            )
            exit_method = getattr(
                dango, exit_effects.get(dango.name, "exit_jump_down_fall")
            )
            exit_method()


def process_subtitle_line(line: Line, l: Line) -> None:
    l.start_time = line.start_time - line.leadin // 2
    l.end_time = line.end_time + line.leadout // 2

    l.text = "{\\fad(%d,%d)}%s" % (line.leadin // 2, line.leadout // 2, line.text)

    io.write_line(l)


# Main
for line in lines:
    if line.styleref.alignment >= 7:
        process_romaji_line(line, line.copy())
    else:
        process_subtitle_line(line, line.copy())

# Save and open in Aegisub
io.save()
io.open_aegisub()


================================================
FILE: pyonfx/__init__.py
================================================
# -*- coding: utf-8 -*-

from .ass_core import Ass, Char, Line, Meta, Style, Syllable, Word
from .convert import ColorModel, Convert
from .font import Font
from .pixel import Pixel, PixelCollection
from .shape import Shape, ShapeElement
from .utils import ColorUtility, FrameUtility, Utils


================================================
FILE: pyonfx/ass_core.py
================================================
# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation Alpha).
# Copyright (C) 2019-2025 Antonio Strippoli (CoffeeStraw/YellowFlash)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# PyonFX is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program. If not, see http://www.gnu.org/licenses/.

import copy
import itertools
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import time
from collections import defaultdict
from dataclasses import dataclass, fields
from fractions import Fraction
from pathlib import Path
from typing import Any, Callable

from tabulate import tabulate
from video_timestamps import (
    ABCTimestamps,
    FPSTimestamps,
    RoundingMethod,
    VideoTimestamps,
)

from .convert import Convert
from .font import Font


@dataclass(slots=True)
class Meta:
    """Meta object contains informations about the Ass.

    More info about each of them can be found on http://docs.aegisub.org/manual/Styles
    """

    wrap_style: int | None = None
    """Determines how line breaking is applied to the subtitle line."""

    scaled_border_and_shadow: bool | None = None
    """Determines if script resolution (True) or video resolution (False) should be used to scale border and shadow."""

    play_res_x: int | None = None
    """Video width resolution."""

    play_res_y: int | None = None
    """Video height resolution."""

    audio: str | None = None
    """Loaded audio file path (absolute)."""

    video: str | None = None
    """Loaded video file path (absolute)."""

    timestamps: ABCTimestamps | None = None
    """Timestamps associated to the video file."""

    def parse_line(self, line: str, ass_path: str) -> str:
        """Parses a single ASS line and update the relevant fields.

        Returns the updated line.
        """
        line = line.strip()

        if not line:
            pass
        elif match := re.match(r"WrapStyle:\s*(\d+)$", line):
            self.wrap_style = int(match.group(1))
        elif match := re.match(r"ScaledBorderAndShadow:\s*(.+)$", line):
            self.scaled_border_and_shadow = match.group(1).strip().lower() == "yes"
        elif match := re.match(r"PlayResX:\s*(\d+)$", line):
            self.play_res_x = int(match.group(1))
        elif match := re.match(r"PlayResY:\s*(\d+)$", line):
            self.play_res_y = int(match.group(1))
        elif match := re.match(r"Audio File:\s*(.*)$", line):
            self.audio = resolve_path(ass_path, match.group(1).strip())
            line = f"Audio File: {self.audio}"
        elif match := re.match(r"Video File:\s*(.*)$", line):
            # Parse video file path
            match_group = str(match.group(1)).strip()
            is_dummy = match_group.startswith("?dummy")
            self.video = (
                match_group if is_dummy else resolve_path(ass_path, match_group)
            )

            line = f"Video File: {self.video}"

            # Set up timestamps based on video file
            if os.path.isfile(self.video):
                self.timestamps = VideoTimestamps.from_video_file(Path(self.video))
            elif is_dummy:
                # Parse dummy video format: ?dummy:fps:duration
                parts = self.video.split(":")
                if len(parts) >= 2:
                    fps_str = parts[1]
                    fps = Fraction(fps_str)
                    self.timestamps = FPSTimestamps(
                        RoundingMethod.ROUND, Fraction(1000), fps, Fraction(0)  # type: ignore[attr-defined]
                    )

        return line + "\n"


@dataclass(slots=True)
class Style:
    """Style object contains a set of typographic formatting rules that is applied to dialogue lines."""

    name: str
    """Style name."""
    fontname: str
    """Font name."""
    fontsize: float
    """Font size in points."""
    color1: str
    """Primary color (fill)."""
    alpha1: str
    """Transparency of color1."""
    color2: str
    """Secondary color (for karaoke effect)."""
    alpha2: str
    """Transparency of color2."""
    color3: str
    """Outline (border) color."""
    alpha3: str
    """Transparency of color3."""
    color4: str
    """Shadow color."""
    alpha4: str
    """Transparency of color4."""
    bold: bool
    """Whether the font is bold."""
    italic: bool
    """Whether the font is italic."""
    underline: bool
    """Whether the font is underlined."""
    strikeout: bool
    """Whether the font is struck out."""
    scale_x: float
    """Horizontal text scaling (percentage)."""
    scale_y: float
    """Vertical text scaling (percentage)."""
    spacing: float
    """Horizontal spacing between letters."""
    angle: float
    """Text rotation angle (degrees)."""
    border_style: bool
    """True for opaque box, False for standard outline."""
    outline: float
    """Border thickness."""
    shadow: float
    """Shadow offset distance."""
    alignment: int
    """Text alignment (ASS alignment code)."""
    margin_l: int
    """Left margin (pixels)."""
    margin_r: int
    """Right margin (pixels)."""
    margin_v: int
    """Vertical margin (pixels)."""
    encoding: int
    """Font encoding/codepage."""

    @classmethod
    def from_ass_line(cls, line: str) -> "Style":
        """Parses a single ASS line and returns the corresponding Style object."""
        style_match = re.match(r"Style:\s*(.+)$", line)
        if not style_match:
            raise ValueError(f"Invalid style line: {line}")

        # Parse style fields
        #   Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour,
        #   Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle,
        #   BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
        style_fields = [field.strip() for field in style_match.group(1).split(",")]

        return cls(
            name=style_fields[0],
            fontname=style_fields[1],
            fontsize=float(style_fields[2]),
            color1=f"&H{style_fields[3][4:]}&",
            alpha1=f"{style_fields[3][:4]}&",
            color2=f"&H{style_fields[4][4:]}&",
            alpha2=f"{style_fields[4][:4]}&",
            color3=f"&H{style_fields[5][4:]}&",
            alpha3=f"{style_fields[5][:4]}&",
            color4=f"&H{style_fields[6][4:]}&",
            alpha4=f"{style_fields[6][:4]}&",
            bold=style_fields[7] == "-1",
            italic=style_fields[8] == "-1",
            underline=style_fields[9] == "-1",
            strikeout=style_fields[10] == "-1",
            scale_x=float(style_fields[11]),
            scale_y=float(style_fields[12]),
            spacing=float(style_fields[13]),
            angle=float(style_fields[14]),
            border_style=style_fields[15] == "3",
            outline=float(style_fields[16]),
            shadow=float(style_fields[17]),
            alignment=int(style_fields[18]),
            margin_l=int(style_fields[19]),
            margin_r=int(style_fields[20]),
            margin_v=int(style_fields[21]),
            encoding=int(style_fields[22]),
        )

    def serialize(self, style_name: str) -> str:
        """Serializes a Style object into an ASS style line."""
        bold = "-1" if self.bold else "0"
        italic = "-1" if self.italic else "0"
        underline = "-1" if self.underline else "0"
        strikeout = "-1" if self.strikeout else "0"
        border = "3" if self.border_style else "1"
        fontsize = (
            str(int(self.fontsize))
            if self.fontsize == int(self.fontsize)
            else str(self.fontsize)
        )
        scale_x = (
            str(int(self.scale_x))
            if self.scale_x == int(self.scale_x)
            else str(self.scale_x)
        )
        scale_y = (
            str(int(self.scale_y))
            if self.scale_y == int(self.scale_y)
            else str(self.scale_y)
        )
        spacing = (
            str(int(self.spacing))
            if self.spacing == int(self.spacing)
            else str(self.spacing)
        )
        angle = (
            str(int(self.angle)) if self.angle == int(self.angle) else str(self.angle)
        )
        outline_width = (
            str(int(self.outline))
            if self.outline == int(self.outline)
            else str(self.outline)
        )
        shadow = (
            str(int(self.shadow))
            if self.shadow == int(self.shadow)
            else str(self.shadow)
        )
        primary = f"&H{self.alpha1}{self.color1}"
        secondary = f"&H{self.alpha2}{self.color2}"
        outline_col = f"&H{self.alpha3}{self.color3}"
        back = f"&H{self.alpha4}{self.color4}"
        style_line = (
            f"Style: {style_name},{self.fontname},{fontsize},{primary},{secondary},"
            f"{outline_col},{back},{bold},{italic},{underline},{strikeout},"
            f"{scale_x},{scale_y},{spacing},{angle},{border},{outline_width},"
            f"{shadow},{self.alignment},{self.margin_l},{self.margin_r},"
            f"{self.margin_v},{self.encoding}\n"
        )
        return style_line


@dataclass(slots=True)
class Char:
    """Char object contains information about a single character in a line."""

    i: int
    """Character index in the line."""
    word_i: int
    """Index of the word this character belongs to."""
    syl_i: int
    """Index of the syllable this character belongs to."""
    syl_char_i: int
    """Index of the character within its syllable."""
    start_time: int
    """Start time (ms) of the character."""
    end_time: int
    """End time (ms) of the character."""
    duration: int
    """Duration (ms) of the character."""
    styleref: Style
    """Reference to the Style object for this character's line."""
    text: str
    """The character itself as a string."""
    inline_fx: str
    """Inline effect for the character (from \\\\-EFFECT tag)."""
    width: float
    """Width of the character (pixels)."""
    height: float
    """Height of the character (pixels)."""
    x: float
    """Horizontal position of the character (pixels)."""
    y: float
    """Vertical position of the character (pixels)."""
    left: float
    """Left position of the character (pixels)."""
    center: float
    """Center position of the character (pixels)."""
    right: float
    """Right position of the character (pixels)."""
    top: float
    """Top position of the character (pixels)."""
    middle: float
    """Middle position of the character (pixels)."""
    bottom: float
    """Bottom position of the character (pixels)."""

    def __repr__(self):
        return pretty_print(self)


@dataclass(slots=True)
class Syllable:
    """Syllable object contains information about a single syllable in a line."""

    i: int
    """Syllable index in the line."""
    word_i: int
    """Index of the word this syllable belongs to."""
    start_time: int
    """Start time (ms) of the syllable."""
    end_time: int
    """End time (ms) of the syllable."""
    duration: int
    """Duration (ms) of the syllable."""
    styleref: Style
    """Reference to the Style object for this syllable's line."""
    text: str
    """Text of the syllable."""
    tags: str
    """ASS override tags preceding the syllable text (excluding \\\\k tags)."""
    inline_fx: str
    """Inline effect for the syllable (from \\\\-EFFECT tag)."""
    prespace: int
    """Number of spaces before the syllable."""
    postspace: int
    """Number of spaces after the syllable."""
    width: float
    """Width of the syllable (pixels)."""
    height: float
    """Height of the syllable (pixels)."""
    x: float
    """Horizontal position of the syllable (pixels)."""
    y: float
    """Vertical position of the syllable (pixels)."""
    left: float
    """Left position of the syllable (pixels)."""
    center: float
    """Center position of the syllable (pixels)."""
    right: float
    """Right position of the syllable (pixels)."""
    top: float
    """Top position of the syllable (pixels)."""
    middle: float
    """Middle position of the syllable (pixels)."""
    bottom: float
    """Bottom position of the syllable (pixels)."""

    def __repr__(self):
        return pretty_print(self)


@dataclass(slots=True)
class Word:
    """Word object contains information about a single word in a line."""

    i: int
    """Word index in the line."""
    start_time: int
    """Start time (ms) of the word (same as line start)."""
    end_time: int
    """End time (ms) of the word (same as line end)."""
    duration: int
    """Duration (ms) of the word (same as line duration)."""
    styleref: Style
    """Reference to the Style object for this word's line."""
    text: str
    """Text of the word."""
    prespace: int
    """Number of spaces before the word."""
    postspace: int
    """Number of spaces after the word."""
    width: float
    """Width of the word (pixels)."""
    height: float
    """Height of the word (pixels)."""
    x: float
    """Horizontal position of the word (pixels)."""
    y: float
    """Vertical position of the word (pixels)."""
    left: float
    """Left position of the word (pixels)."""
    center: float
    """Center position of the word (pixels)."""
    right: float
    """Right position of the word (pixels)."""
    top: float
    """Top position of the word (pixels)."""
    middle: float
    """Middle position of the word (pixels)."""
    bottom: float
    """Bottom position of the word (pixels)."""

    def __repr__(self):
        return pretty_print(self)


@dataclass(slots=True)
class Line:
    """Line object contains information about a single subtitle line in the ASS file."""

    comment: bool
    """True if this line is a comment, False if it is a dialogue."""
    layer: int
    """Layer number for the line (higher layers are drawn above lower ones)."""
    start_time: int
    """Start time (ms) of the line."""
    end_time: int
    """End time (ms) of the line."""
    style: str
    """Style name used for this line. Could be None in case of non-existing style name."""
    styleref: Style
    """Reference to the Style object for this line."""
    actor: str
    """Actor field."""
    margin_l: int
    """Left margin for this line (pixels)."""
    margin_r: int
    """Right margin for this line (pixels)."""
    margin_v: int
    """Vertical margin for this line (pixels)."""
    effect: str
    """Effect field."""
    raw_text: str
    """Raw text of the line (including tags)."""
    text: str
    """Stripped text of the line (no tags)."""
    i: int
    """Line index in the file."""
    duration: int
    """Duration (ms) of the line."""
    leadin: int
    """Time (ms) between this line and the previous one."""
    leadout: int
    """Time (ms) between this line and the next one."""
    width: float
    """Width of the line (pixels)."""
    height: float
    """Height of the line (pixels)."""
    ascent: float
    """Font ascent for the line."""
    descent: float
    """Font descent for the line."""
    internal_leading: float
    """Font internal leading for the line."""
    external_leading: float
    """Font external leading for the line."""
    x: float
    """Horizontal position of the line (pixels)."""
    y: float
    """Vertical position of the line (pixels)."""
    left: float
    """Left position of the line (pixels)."""
    center: float
    """Center position of the line (pixels)."""
    right: float
    """Right position of the line (pixels)."""
    top: float
    """Top position of the line (pixels)."""
    middle: float
    """Middle position of the line (pixels)."""
    bottom: float
    """Bottom position of the line (pixels)."""
    words: list[Word]
    """List of Word objects in this line."""
    syls: list[Syllable]
    """List of Syllable objects in this line (if available)."""
    chars: list[Char]
    """List of Char objects in this line."""

    def __repr__(self):
        return pretty_print(self)

    def copy(self) -> "Line":
        """
        Returns:
            A deep copy of this object (line)
        """
        return copy.deepcopy(self)

    @classmethod
    def from_ass_line(
        cls, line: str, line_index: int, styles: dict[str, Style]
    ) -> "Line":
        """Parses a single ASS line and returns the corresponding Line object."""
        event_match = re.match(r"(Dialogue|Comment):\s*(.+)$", line)
        if not event_match:
            raise ValueError(
                f"Invalid event line. Line index: {line_index}, Line: {line}."
            )

        # Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
        event_type = event_match.group(1)
        event_data = event_match.group(2)

        # Split into fields, allowing the text field to contain commas
        event_fields = event_data.split(",", 9)
        if len(event_fields) < 10:
            raise ValueError(f"Incomplete event line at index {line_index}: {line}")

        # Convert time fields
        try:
            start_time = Convert.time(event_fields[1])
            end_time = Convert.time(event_fields[2])
        except Exception as e:
            raise ValueError(f"Invalid time fields at line {line_index}: {e}")

        # Resolve style reference
        style_name = event_fields[3]
        try:
            styleref = styles[style_name]
        except KeyError:
            raise ValueError(f"Unknown style '{style_name}' at line {line_index}")

        return cls(
            comment=(event_type == "Comment"),
            layer=int(event_fields[0]),
            start_time=start_time,
            end_time=end_time,
            style=style_name,
            styleref=styleref,
            actor=event_fields[4],
            margin_l=int(event_fields[5]),
            margin_r=int(event_fields[6]),
            margin_v=int(event_fields[7]),
            effect=event_fields[8],
            raw_text=event_fields[9],
            text="",
            i=line_index,
            duration=-1,
            leadin=-1,
            leadout=-1,
            width=float("nan"),
            height=float("nan"),
            ascent=float("nan"),
            descent=float("nan"),
            internal_leading=float("nan"),
            external_leading=float("nan"),
            x=float("nan"),
            y=float("nan"),
            left=float("nan"),
            center=float("nan"),
            right=float("nan"),
            top=float("nan"),
            middle=float("nan"),
            bottom=float("nan"),
            words=[],
            syls=[],
            chars=[],
        )

    def serialize(self) -> str:
        return (
            f"{'Comment' if self.comment else 'Dialogue'}: {self.layer},"
            f"{Convert.time(max(0, int(self.start_time)))},"
            f"{Convert.time(max(0, int(self.end_time)))},"
            f"{self.style},"
            f"{self.actor},"
            f"{self.margin_l:04d},"
            f"{self.margin_r:04d},"
            f"{self.margin_v:04d},"
            f"{self.effect},"
            f"{self.text}\n"
        )


class Ass:
    """Contains all the informations about a file in the ASS format and the methods to work with it for both input and output.

    | Usually you will create an Ass object and use it for input and output (see example_ section).
    | PyonFX set automatically an absolute path for all the info in the output, so that wherever you will
      put your generated file, it should always load correctly video and audio.

    Args:
        path_input (str): Path for the input file (either relative to your .py file or absolute).
        path_output (str): Path for the output file (either relative to your .py file or absolute) (DEFAULT: "Output.ass").
        keep_original (bool): If True, you will find all the lines of the input file commented before the new lines generated.
        extended (bool): Calculate more informations from lines (usually you will not have to touch this).
        vertical_kanji (bool): If True, line text with alignment 4, 5 or 6 will be positioned vertically. Additionally, ``line`` fields will be re-calculated based on the re-positioned ``line.chars``.
        progress (bool): If True, a progress bar will be displayed when iterating over the lines.

    Attributes:
        path_input (str): Path for input file (absolute).
        path_output (str): Path for output file (absolute).
        meta (:class:`Meta`): Contains informations about the ASS given.
        styles (list of :class:`Style`): Contains all the styles in the ASS given.
        lines (list of :class:`Line`): Contains all the lines (events) in the ASS given.
        PIXEL_STYLE (:class:`Style`): Constant lightweight style for pixels.

    .. _example:
    Example:
        ..  code-block:: python3

            io = Ass("in.ass")
            meta, styles, lines = io.get_data()
    """

    PIXEL_STYLE = Style(
        name="p",
        fontname="Arial",
        fontsize=20,
        color1="FFFFFF",
        alpha1="00",
        color2="FFFFFF",
        alpha2="00",
        color3="000000",
        alpha3="0000",
        color4="000000",
        alpha4="0000",
        bold=False,
        italic=False,
        underline=False,
        strikeout=False,
        scale_x=100,
        scale_y=100,
        spacing=0,
        angle=0,
        border_style=False,
        outline=0,
        shadow=0,
        alignment=7,
        margin_l=0,
        margin_r=0,
        margin_v=0,
        encoding=1,
    )
    """Lightweight style for pixels."""

    def __init__(
        self,
        path_input: str,
        path_output: str = "output.ass",
        keep_original: bool = True,
        extended: bool = True,
        vertical_kanji: bool = False,
    ):
        # Progress/statistics
        self._saved = False
        self._plines = 0  # Total produced lines
        self._ptime = time.time()  # Total processing time
        self._stats_by_effect: defaultdict[str, dict[str, float | int]] = defaultdict(
            lambda: {"lines": 0, "time": 0.0, "calls": 0}
        )

        # Output buffers
        self._output: list[str] = []
        self._output_extradata: list[str] = []

        # Public attributes
        self.meta: Meta = Meta()
        self.styles: dict[str, Style] = {}
        self.lines: list[Line] = []

        # Getting absolute sub file path
        self.path_input = resolve_path(sys.argv[0], path_input)
        if not os.path.isfile(self.path_input):
            raise FileNotFoundError(
                "Invalid path for the Subtitle file: %s" % self.path_input
            )
        self.path_output = resolve_path(sys.argv[0], path_output)

        # Parse the ASS file
        current_section = ""
        line_index = 0

        with open(self.path_input, encoding="utf-8-sig") as file:
            for line in file:
                # New section?
                section_match = re.match(r"^\[([^\]]*)\]", line)
                if section_match:
                    current_section = section_match.group(1)
                    if current_section != "Aegisub Extradata":
                        self._output.append(line)
                    continue

                if line.startswith("Format") or not line.strip():
                    self._output.append(line)
                # Sections parsers
                elif current_section in ("Script Info", "Aegisub Project Garbage"):
                    line = self.meta.parse_line(line, self.path_input)
                    self._output.append(line)
                elif current_section == "V4+ Styles":
                    style = Style.from_ass_line(line)
                    self.styles[style.name] = style
                    self._output.append(line)
                elif current_section == "Events":
                    self.lines.append(Line.from_ass_line(line, line_index, self.styles))
                    if keep_original:
                        self._output.append(
                            re.sub(r"^(Dialogue|Comment):", "Comment:", line, count=1)
                        )
                    line_index += 1
                elif current_section == "Aegisub Extradata":
                    self._output_extradata.append(line)
                elif (
                    current_section and line.strip()
                ):  # Non-empty line in unknown section
                    raise ValueError(
                        f"Unexpected section in the input file: [{current_section}]"
                    )

        # Add extended information to lines and meta?
        if extended:
            self._process_extended_line_data(vertical_kanji)

    def _process_extended_line_data(self, vertical_kanji: bool) -> None:
        """Process extended line data including positioning, words, syllables, and characters."""

        def _split_raw_segments(
            lines: list[Line],
        ) -> tuple[list[Line], list[int], list[int]]:
            """Split each raw_text at \\N and compute cumulative k-offsets."""
            output_lines: list[Line] = []
            new_lines_indices: list[int] = []
            new_lines_k_offsets: list[int] = []
            for line in lines:
                raw_segments = line.raw_text.split("\\N")
                text_segments = re.sub(r"\{.*?\}", "", line.raw_text).split("\\N")
                seg_k_durations = [
                    sum(int(m) * 10 for m in re.findall(r"\\[kK][of]?(\d+)", seg))
                    for seg in raw_segments
                ]
                cumulative_k_durations = [
                    sum(seg_k_durations[:i]) for i in range(len(seg_k_durations))
                ]

                for seg_idx, (raw_seg, text_seg) in enumerate(
                    zip(raw_segments, text_segments)
                ):
                    seg_line = line.copy()
                    seg_line.raw_text = raw_seg
                    seg_line.text = text_seg
                    output_lines.append(seg_line)
                    new_lines_indices.append(seg_idx)
                    new_lines_k_offsets.append(cumulative_k_durations[seg_idx])
            return output_lines, new_lines_indices, new_lines_k_offsets

        def _compute_line_fields(line: Line, font: Font, split_index: int):
            """Compute duration, text, font metrics, dimensions and positions for a line."""
            line.duration = line.end_time - line.start_time
            line.text = re.sub(r"\{.*?\}", "", line.raw_text)

            line.width, line.height = font.get_text_extents(line.text.strip())
            (
                line.ascent,
                line.descent,
                line.internal_leading,
                line.external_leading,
            ) = font.get_metrics()

            if (
                self.meta.play_res_x is None
                or self.meta.play_res_x <= 0
                or self.meta.play_res_y is None
                or self.meta.play_res_y <= 0
            ):
                return

            # Resolve margins
            margins = {
                "l": line.margin_l or line.styleref.margin_l,
                "r": line.margin_r or line.styleref.margin_r,
                "v": line.margin_v or line.styleref.margin_v,
            }

            # Horizontal position
            h_group = (line.styleref.alignment - 1) % 3
            positions = [
                margins["l"],
                (self.meta.play_res_x - line.width) / 2
                + (margins["l"] - margins["r"]) / 2,
                self.meta.play_res_x - margins["r"] - line.width,
            ]
            line.left = positions[h_group]
            line.center = line.left + line.width / 2
            line.right = line.left + line.width
            line.x = (line.left, line.center, line.right)[h_group]

            # Vertical position
            v_group = (line.styleref.alignment - 1) // 3
            positions = [
                self.meta.play_res_y - margins["v"] - line.height,  # bottom
                (self.meta.play_res_y - line.height) / 2,  # middle
                margins["v"],  # top
            ]
            line.top = positions[v_group]
            line.middle = line.top + line.height / 2
            line.bottom = line.top + line.height
            line.y = (line.bottom, line.middle, line.top)[v_group]

            # Apply vertical offset for split lines
            if split_index > 0:
                offset = split_index * line.height
                line.top += offset
                line.middle += offset
                line.bottom += offset
                line.y += offset

        def _build_words(line: Line, font: Font):
            """Build words for a line."""
            for wi, (prespace, word_text, postspace) in enumerate(
                re.findall(r"(\s*)([^\s]+)(\s*)", line.text)
            ):
                width, height = font.get_text_extents(word_text)
                line.words.append(
                    Word(
                        i=wi,
                        start_time=line.start_time,
                        end_time=line.end_time,
                        duration=line.duration,
                        styleref=line.styleref,
                        text=word_text,
                        prespace=len(prespace),
                        postspace=len(postspace),
                        width=width,
                        height=height,
                        x=float("nan"),
                        y=float("nan"),
                        left=float("nan"),
                        center=float("nan"),
                        right=float("nan"),
                        top=float("nan"),
                        middle=float("nan"),
                        bottom=float("nan"),
                    )
                )

            if (
                line.left == float("nan")
                or self.meta.play_res_x is None
                or self.meta.play_res_y is None
            ):
                return

            h_group = (line.styleref.alignment - 1) % 3
            v_group = (line.styleref.alignment - 1) // 3

            if not vertical_kanji or v_group in (0, 2):
                cur_x = line.left
                space_offset = space_width + style_spacing

                for i, word in enumerate(line.words):
                    # Add prespace offset for all words except the first one
                    if i > 0:
                        cur_x += word.prespace * space_offset

                    # Horizontal position
                    word.left = cur_x
                    word.center = word.left + word.width / 2
                    word.right = word.left + word.width
                    word.x = [word.left, word.center, word.right][h_group]

                    # Vertical position (copy from line)
                    word.top, word.middle, word.bottom, word.y = (
                        line.top,
                        line.middle,
                        line.bottom,
                        line.y,
                    )

                    # Update cur_x for next word
                    cur_x += word.width + word.postspace * space_offset + style_spacing
            else:
                max_width = max(word.width for word in line.words)
                sum_height = sum(word.height for word in line.words)

                cur_y = self.meta.play_res_y / 2 - sum_height / 2
                alignment = line.styleref.alignment

                for word in line.words:
                    # Horizontal position
                    x_fix = (max_width - word.width) / 2

                    if alignment == 4:
                        word.left = line.left + x_fix
                        word.x = word.left
                    elif alignment == 5:
                        word.left = self.meta.play_res_x / 2 - word.width / 2
                        word.x = word.left + word.width / 2
                    else:
                        word.left = line.right - word.width - x_fix
                        word.x = word.left + word.width

                    word.center = word.left + word.width / 2
                    word.right = word.left + word.width

                    # Vertical position
                    word.top = cur_y
                    word.middle = cur_y + word.height / 2
                    word.bottom = cur_y + word.height
                    word.y = word.middle
                    cur_y += word.height

        def _parse_syllables(line_raw_text: str) -> list[tuple[str, int, str]]:
            """Parse ASS karaoke line into syllable divisions.

            ASS karaoke works by having timing tags (like \\k50) that indicate syllable boundaries,
            with other styling tags that can appear before or after them. We group
            tags and text into logical divisions based on karaoke timing boundaries.
            """
            KARAOKE_PATTERN = re.compile(r"\\[kK][fot]?(\d+)?")
            TAG_BLOCK_PATTERN = re.compile(r"\{([^}]*)\}|([^{]+)")
            TAG_EXTRACT_PATTERN = re.compile(r"\\[^\\]*")

            result = []
            pending_tags = []
            current_tags = []
            current_text = ""
            current_duration = ""

            # Split line into tag blocks {...} and text segments
            for match in TAG_BLOCK_PATTERN.finditer(line_raw_text):
                tag_content, text_content = match.group(1), match.group(2)

                if tag_content is not None:
                    tags = TAG_EXTRACT_PATTERN.findall(tag_content)
                    karaoke_matches = {tag: KARAOKE_PATTERN.match(tag) for tag in tags}
                    has_karaoke = any(karaoke_matches.values())

                    if has_karaoke:
                        # Karaoke block: process tags
                        found_karaoke = False
                        for tag in tags:
                            k_match = karaoke_matches[tag]
                            if k_match:
                                if current_tags:
                                    result.append(
                                        (
                                            "".join(current_tags),
                                            current_duration,
                                            current_text,
                                        )
                                    )
                                    current_text = ""
                                current_tags = pending_tags + [tag]
                                current_duration = (
                                    int(k_match.group(1)) if k_match.group(1) else 0
                                )
                                pending_tags = []
                                found_karaoke = True
                            else:
                                if found_karaoke:
                                    current_tags.append(tag)
                                else:
                                    pending_tags.append(tag)
                    else:
                        # Non-karaoke block: decide where these tags belong
                        if current_tags and not current_text:
                            current_tags.extend(tags)
                        else:
                            pending_tags.extend(tags)
                else:
                    # Plain text: add to current division
                    current_text += text_content

            # Add the final division if it exists
            if current_tags:
                result.append(("".join(current_tags), current_duration, current_text))

            return result

        def _build_syllables(
            line: Line,
            syllable_data: list[tuple[str, int, str]],
            font: Font,
            split_k_offset: int,
        ):
            # Precompute word boundaries (start_idx, end_idx, word_i)
            word_boundaries: list[tuple[int, int, int]] = []
            idx = 0
            for w in line.words:
                start = idx + w.prespace
                end = start + len(w.text)
                word_boundaries.append((start, end, w.i))
                idx = end + w.postspace

            last_time = split_k_offset
            syl_char_idx = 0

            for syl_i, (tags, k_dur, raw_text) in enumerate(syllable_data):
                # Inline effect
                m = re.search(r"\\-([^\s\\}]+)", tags)
                inline_fx = m.group(1) if m else ""

                # Text and spacing
                text_stripped = raw_text.strip()
                prespace = len(raw_text) - len(raw_text.lstrip())
                postspace = (
                    len(raw_text) - len(raw_text.rstrip()) if text_stripped else 0
                )

                # Timing
                duration = k_dur * 10 if k_dur else 0
                start_time = last_time
                end_time = start_time + duration

                # Determine word index
                syl_start = syl_char_idx + prespace
                syl_word_i = next(
                    (w_i for s, e, w_i in word_boundaries if s <= syl_start < e), 0
                )

                # Font metrics
                width, height = font.get_text_extents(text_stripped)

                # Create and append syllable
                line.syls.append(
                    Syllable(
                        i=syl_i,
                        word_i=syl_word_i,
                        start_time=start_time,
                        end_time=end_time,
                        duration=duration,
                        styleref=line.styleref,
                        text=text_stripped,
                        tags=tags,
                        inline_fx=inline_fx,
                        prespace=prespace,
                        postspace=postspace,
                        width=width,
                        height=height,
                        x=float("nan"),
                        y=float("nan"),
                        left=float("nan"),
                        center=float("nan"),
                        right=float("nan"),
                        top=float("nan"),
                        middle=float("nan"),
                        bottom=float("nan"),
                    )
                )

                # Update for next iteration
                last_time = end_time
                syl_char_idx += prespace + len(text_stripped) + postspace

            if (
                line.left == float("nan")
                or self.meta.play_res_x is None
                or self.meta.play_res_y is None
            ):
                return

            h_group = (line.styleref.alignment - 1) % 3
            v_group = (line.styleref.alignment - 1) // 3

            if not vertical_kanji or v_group in (0, 2):
                cur_x = line.left
                found_first_text_syl = False
                space_offset = space_width + style_spacing

                for syl in line.syls:
                    # Add prespace offset only after the first syllable with text
                    if found_first_text_syl:
                        cur_x += syl.prespace * space_offset
                    elif syl.text:
                        found_first_text_syl = True

                    # Horizontal position
                    syl.left = cur_x
                    syl.center = syl.left + syl.width / 2
                    syl.right = syl.left + syl.width
                    syl.x = [syl.left, syl.center, syl.right][h_group]

                    # Vertical position
                    syl.top, syl.middle, syl.bottom, syl.y = (
                        line.top,
                        line.middle,
                        line.bottom,
                        line.y,
                    )

                    # Update cur_x for next syllable
                    cur_x += syl.width + syl.postspace * space_offset + style_spacing
            else:
                max_width = max(syl.width for syl in line.syls)
                sum_height = sum(syl.height for syl in line.syls)

                cur_y = self.meta.play_res_y / 2 - sum_height / 2
                alignment = line.styleref.alignment

                for syl in line.syls:
                    x_fix = (max_width - syl.width) / 2

                    # Horizontal position
                    if alignment == 4:
                        syl.left = line.left + x_fix
                    elif alignment == 5:
Download .txt
gitextract_9dsir29s/

├── .flake8
├── .github/
│   ├── scripts/
│   │   └── install-fonts.ps1
│   └── workflows/
│       └── ci.yml
├── .gitignore
├── .readthedocs.yaml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── docs/
│   ├── Makefile
│   ├── make.bat
│   └── source/
│       ├── conf.py
│       ├── index.rst
│       ├── quick start.rst
│       └── reference/
│           ├── ass core.rst
│           ├── convert.rst
│           ├── index.rst
│           ├── shape.rst
│           └── utils.rst
├── examples/
│   ├── 1 - Basics/
│   │   ├── 1 - Look into ASS values.py
│   │   ├── 2 - Create the First Output.py
│   │   ├── 3 - More lines.py
│   │   ├── 4 - Organizing the code.py
│   │   └── in.ass
│   ├── 2 - Beginner/
│   │   ├── 1 - First Simple Effect.py
│   │   ├── 2 - Utilities.py
│   │   ├── 3 - Variants.py
│   │   ├── 4 - Accelerate Demo.py
│   │   ├── 5 - Image to Pixels Demo.py
│   │   ├── in.ass
│   │   └── in2.ass
│   ├── 3 - Advanced/
│   │   ├── 1 - WIP.py
│   │   ├── 2 - Testing Pixels (WIP).py
│   │   ├── 3 - Morphing.py
│   │   └── in.ass
│   └── 4 - Community/
│       └── 1 - Dangos/
│           ├── dango_config.py
│           ├── in.ass
│           └── main.py
├── pyonfx/
│   ├── __init__.py
│   ├── ass_core.py
│   ├── convert.py
│   ├── font.py
│   ├── pixel.py
│   ├── shape.py
│   └── utils.py
├── pyproject.toml
├── requirements.txt
└── tests/
    ├── Ass/
    │   ├── ass_core.ass
    │   ├── in.ass
    │   └── in_with_spacing.ass
    ├── __init__.py
    ├── shape/
    │   ├── __init__.py
    │   ├── fixtures.py
    │   ├── test_elements.py
    │   ├── test_generation.py
    │   └── test_operations.py
    ├── test_ass.py
    ├── test_convert.py
    └── test_utils.py
Download .txt
SYMBOL INDEX (202 symbols across 23 files)

FILE: examples/1 - Basics/4 - Organizing the code.py
  function romaji (line 30) | def romaji(line, l):
  function kanji (line 35) | def kanji(line, l):
  function sub (line 40) | def sub(line, l):

FILE: examples/2 - Beginner/1 - First Simple Effect.py
  function leadin_effect (line 32) | def leadin_effect(line: Line, obj: Syllable | Char, l: Line):
  function main_effect (line 44) | def main_effect(line: Line, obj: Syllable | Char, l: Line):
  function leadout_effect (line 72) | def leadout_effect(line: Line, obj: Syllable | Char, l: Line):
  function romaji (line 84) | def romaji(line: Line, l: Line):
  function kanji (line 92) | def kanji(line: Line, l: Line):
  function sub (line 100) | def sub(line: Line, l: Line):

FILE: examples/2 - Beginner/2 - Utilities.py
  function romaji (line 24) | def romaji(line, l):
  function sub (line 94) | def sub(line, l):

FILE: examples/2 - Beginner/3 - Variants.py
  function romaji (line 32) | def romaji(line, l):
  function kanji (line 196) | def kanji(line, l):
  function sub (line 298) | def sub(line, l):

FILE: examples/2 - Beginner/4 - Accelerate Demo.py
  function generate_color_palette (line 50) | def generate_color_palette(count):

FILE: examples/2 - Beginner/5 - Image to Pixels Demo.py
  function image_effect (line 35) | def image_effect(line: Line, l: Line):
  function sub (line 63) | def sub(line: Line, l: Line):

FILE: examples/3 - Advanced/1 - WIP.py
  function romaji (line 11) | def romaji(line, l):

FILE: examples/3 - Advanced/2 - Testing Pixels (WIP).py
  function romaji (line 15) | def romaji(line, l):

FILE: examples/3 - Advanced/3 - Morphing.py
  function romaji (line 36) | def romaji(line: Line, l: Line) -> None:
  function sub (line 207) | def sub(line: Line, l: Line, prev_line=None, next_line=None) -> None:

FILE: examples/4 - Community/1 - Dangos/dango_config.py
  function _make_shape (line 42) | def _make_shape(path: str) -> Shape:
  function _make_shapes (line 46) | def _make_shapes(**shapes) -> dict[str, Shape]:
  function _make_config (line 50) | def _make_config(

FILE: examples/4 - Community/1 - Dangos/main.py
  class Dango (line 54) | class Dango:
    method __init__ (line 55) | def __init__(
    method load_variant (line 85) | def load_variant(
    method _create_style_tags_for_properties (line 100) | def _create_style_tags_for_properties(self) -> str:
    method _create_style_tags (line 113) | def _create_style_tags(self, part_name: str) -> str:
    method _create_interpolated_style_tags (line 131) | def _create_interpolated_style_tags(
    method _render_frame (line 176) | def _render_frame(
    method morph_from_shapes (line 193) | def morph_from_shapes(
    method idle (line 241) | def idle(
    method move_to (line 304) | def move_to(
    method exit_jump_down_fall (line 346) | def exit_jump_down_fall(
    method exit_furious_dash (line 429) | def exit_furious_dash(
    method exit_slow_steps (line 449) | def exit_slow_steps(
    method exit_heart_spiral (line 488) | def exit_heart_spiral(
    method piggyback_onto (line 543) | def piggyback_onto(
    method tug_away (line 663) | def tug_away(
    method _spawn_heart (line 722) | def _spawn_heart(self, start_time: int) -> None:
  function leadin_effect (line 750) | def leadin_effect(
  function main_effect (line 783) | def main_effect(
  function process_romaji_line (line 816) | def process_romaji_line(line: Line, l: Line) -> None:
  function process_subtitle_line (line 923) | def process_subtitle_line(line: Line, l: Line) -> None:

FILE: pyonfx/ass_core.py
  class Meta (line 46) | class Meta:
    method parse_line (line 73) | def parse_line(self, line: str, ass_path: str) -> str:
  class Style (line 120) | class Style:
    method from_ass_line (line 179) | def from_ass_line(cls, line: str) -> "Style":
    method serialize (line 221) | def serialize(self, style_name: str) -> str:
  class Char (line 276) | class Char:
    method __repr__ (line 320) | def __repr__(self):
  class Syllable (line 325) | class Syllable:
    method __repr__ (line 371) | def __repr__(self):
  class Word (line 376) | class Word:
    method __repr__ (line 416) | def __repr__(self):
  class Line (line 421) | class Line:
    method __repr__ (line 493) | def __repr__(self):
    method copy (line 496) | def copy(self) -> "Line":
    method from_ass_line (line 504) | def from_ass_line(
    method serialize (line 574) | def serialize(self) -> str:
  class Ass (line 589) | class Ass:
    method __init__ (line 651) | def __init__(
    method _process_extended_line_data (line 728) | def _process_extended_line_data(self, vertical_kanji: bool) -> None:
    method get_data (line 1313) | def get_data(self) -> tuple[Meta, dict[str, Style], list[Line]]:
    method add_style (line 1321) | def add_style(self, style_name: str, style: Style) -> None:
    method write_line (line 1346) | def write_line(self, line: Line) -> None:
    method save (line 1358) | def save(self, quiet: bool = False) -> None:
    method open_aegisub (line 1423) | def open_aegisub(self) -> bool:
    method open_mpv (line 1448) | def open_mpv(
    method track (line 1555) | def track(self, func: Callable[..., Any]) -> Callable[..., Any]:
  function resolve_path (line 1585) | def resolve_path(base_path: str, input_path: str) -> str:
  function pretty_print (line 1598) | def pretty_print(obj):

FILE: pyonfx/convert.py
  class ColorModel (line 39) | class ColorModel(Enum):
  class Convert (line 49) | class Convert:
    method time (line 57) | def time(ass_ms: int) -> str: ...
    method time (line 61) | def time(ass_ms: str) -> int: ...
    method time (line 64) | def time(ass_ms: int | str) -> int | str:
    method alpha_ass_to_dec (line 99) | def alpha_ass_to_dec(alpha_ass: str) -> int:
    method alpha_dec_to_ass (line 123) | def alpha_dec_to_ass(alpha_dec: int | float) -> str:
    method color (line 153) | def color(
    method color_ass_to_rgb (line 277) | def color_ass_to_rgb(
    method color_ass_to_hsv (line 307) | def color_ass_to_hsv(
    method color_rgb_to_ass (line 333) | def color_rgb_to_ass(
    method color_rgb_to_hsv (line 359) | def color_rgb_to_hsv(
    method color_hsv_to_ass (line 391) | def color_hsv_to_ass(
    method color_hsv_to_rgb (line 413) | def color_hsv_to_rgb(
    method text_to_shape (line 452) | def text_to_shape(
    method text_to_clip (line 503) | def text_to_clip(
    method text_to_pixels (line 577) | def text_to_pixels(
    method shape_to_pixels (line 618) | def shape_to_pixels(
    method image_to_pixels (line 708) | def image_to_pixels(

FILE: pyonfx/font.py
  class Font (line 45) | class Font:
    method __init__ (line 50) | def __init__(self, style: "Style"):
    method __del__ (line 131) | def __del__(self):
    method get_metrics (line 136) | def get_metrics(self) -> tuple[float, float, float, float]:
    method get_text_extents (line 158) | def get_text_extents(self, text: str) -> tuple[float, float]:
    method text_to_shape (line 197) | def text_to_shape(self, text: str) -> Shape:

FILE: pyonfx/pixel.py
  class Pixel (line 23) | class Pixel:
    method with_color (line 29) | def with_color(self, color: str | tuple[int, int, int]) -> "Pixel":
    method with_alpha (line 32) | def with_alpha(self, alpha: str | int) -> "Pixel":
    method with_position (line 35) | def with_position(self, x: int, y: int) -> "Pixel":
  class PixelCollection (line 39) | class PixelCollection:
    method __init__ (line 40) | def __init__(self, pixels: Iterable[Pixel]):
    method __iter__ (line 43) | def __iter__(self) -> Iterator[Pixel]:
    method __len__ (line 46) | def __len__(self) -> int:
    method __getitem__ (line 49) | def __getitem__(self, index: int | slice) -> "Pixel | PixelCollection":
    method __bool__ (line 54) | def __bool__(self) -> bool:
    method __repr__ (line 57) | def __repr__(self) -> str:
    method bounds (line 62) | def bounds(self) -> tuple[int, int, int, int]:
    method width (line 71) | def width(self) -> int:
    method height (line 76) | def height(self) -> int:
    method is_empty (line 80) | def is_empty(self) -> bool:
    method filter (line 84) | def filter(self, predicate: Callable[[Pixel], bool]) -> "PixelCollecti...
    method filter_by_region (line 87) | def filter_by_region(self, x1: int, y1: int, x2: int, y2: int) -> "Pix...
    method filter_by_color (line 90) | def filter_by_color(self, color: str | tuple[int, int, int]) -> "Pixel...
    method at_position (line 93) | def at_position(self, x: int, y: int) -> list[Pixel]:
    method map (line 98) | def map(self, transform: Callable[[Pixel], Pixel]) -> "PixelCollection":
    method translate (line 101) | def translate(self, dx: int, dy: int) -> "PixelCollection":
    method apply_texture (line 105) | def apply_texture(

FILE: pyonfx/shape.py
  class ShapeElement (line 38) | class ShapeElement:
    method __init__ (line 46) | def __init__(self, command: str, coordinates: list[Point]):
    method __repr__ (line 52) | def __repr__(self):
    method __eq__ (line 56) | def __eq__(self, other):
    method from_ass_drawing_cmd (line 64) | def from_ass_drawing_cmd(cls, command: str, *args: str) -> list["Shape...
  class Shape (line 129) | class Shape:
    method __init__ (line 165) | def __init__(self, drawing_cmds: str = "", elements: list[ShapeElement...
    method __repr__ (line 174) | def __repr__(self):
    method __eq__ (line 178) | def __eq__(self, other: "Shape"):
    method __iter__ (line 181) | def __iter__(self):
    method drawing_cmds (line 185) | def drawing_cmds(self) -> str:
    method _cmds_to_elements (line 190) | def _cmds_to_elements(drawing_cmds: str) -> list[ShapeElement]:
    method _elements_to_cmds (line 220) | def _elements_to_cmds(elements: list[ShapeElement]) -> str:
    method format_value (line 256) | def format_value(x: float, prec: int = 3) -> str:
    method to_multipolygon (line 261) | def to_multipolygon(self, tolerance: float = 1.0) -> MultiPolygon:
    method from_multipolygon (line 335) | def from_multipolygon(
    method bounding (line 397) | def bounding(self, exact: bool = False) -> tuple[float, float, float, ...
    method boolean (line 521) | def boolean(
    method map (line 577) | def map(
    method move (line 636) | def move(self, x: float, y: float) -> "Shape":
    method align (line 660) | def align(self, an: int = 5, anchor: int | None = None) -> "Shape":
    method scale (line 731) | def scale(
    method rotate (line 769) | def rotate(
    method shear (line 828) | def shear(
    method flatten (line 864) | def flatten(self, tolerance: float = 1.0) -> "Shape":
    method split (line 994) | def split(self, max_len: float = 16, tolerance: float = 1.0) -> "Shape":
    method buffer (line 1108) | def buffer(
    method _prepare_morph (line 1197) | def _prepare_morph(
    method morph (line 1516) | def morph(
    method morph_multi (line 1595) | def morph_multi(
    method polygon (line 1846) | def polygon(edges: int, side_length: float) -> "Shape":
    method ellipse (line 1880) | def ellipse(w: float, h: float) -> "Shape":
    method ring (line 1927) | def ring(out_r: float, in_r: float) -> "Shape":
    method heart (line 2012) | def heart(size: float, offset: float = 0) -> "Shape":
    method _glance_or_star (line 2051) | def _glance_or_star(
    method star (line 2099) | def star(edges: int, inner_size: float, outer_size: float) -> "Shape":
    method glance (line 2115) | def glance(edges: int, inner_size: float, outer_size: float) -> "Shape":

FILE: pyonfx/utils.py
  class Utils (line 28) | class Utils:
    method progress_bar (line 36) | def progress_bar(
    method all_non_empty (line 84) | def all_non_empty(
    method accelerate (line 137) | def accelerate(
    method interpolate (line 208) | def interpolate(
  class FrameUtility (line 337) | class FrameUtility:
    method __init__ (line 390) | def __init__(
    method __iter__ (line 424) | def __iter__(self):
    method reset (line 443) | def reset(self):
    method add (line 451) | def add(
  class ColorUtility (line 543) | class ColorUtility:
    method __init__ (line 591) | def __init__(self, lines: list[Line], offset: int = 0):
    method get_color_change (line 693) | def get_color_change(
    method get_fr_color_change (line 784) | def get_fr_color_change(

FILE: tests/shape/test_elements.py
  function test_iter (line 7) | def test_iter():
  function test_iter_error_handling (line 149) | def test_iter_error_handling():
  function test_iter_roundtrip_with_from_elements (line 173) | def test_iter_roundtrip_with_from_elements():
  function test_shape_element_equality_and_repr (line 213) | def test_shape_element_equality_and_repr():
  function test_shape_element_validation (line 242) | def test_shape_element_validation():

FILE: tests/shape/test_generation.py
  function test_polygon_invalid_edges (line 8) | def test_polygon_invalid_edges():
  function test_polygon_side_length_negative (line 13) | def test_polygon_side_length_negative():
  function test_polygon_basic_properties (line 18) | def test_polygon_basic_properties():
  function test_ellipse_bounding (line 25) | def test_ellipse_bounding():
  function test_ring_invalid_radii (line 33) | def test_ring_invalid_radii():
  function test_ring_bounding (line 38) | def test_ring_bounding():

FILE: tests/shape/test_operations.py
  function test_map (line 11) | def test_map():
  function test_bounding (line 37) | def test_bounding():
  function test_move (line 69) | def test_move():
  function test_align (line 76) | def test_align():
  function test_scale (line 167) | def test_scale():
  function test_flatten (line 215) | def test_flatten():
  function test_split (line 234) | def test_split():
  function test_rotate (line 248) | def test_rotate():
  function test_shear (line 279) | def test_shear():
  function test_boolean_basic_areas (line 296) | def test_boolean_basic_areas():
  function test_boolean_invalid_input (line 313) | def test_boolean_invalid_input():

FILE: tests/test_ass.py
  function test_meta_values (line 23) | def test_meta_values():
  function test_line_values (line 37) | def test_line_values():
  function test_syllable_values (line 150) | def test_syllable_values():
  function test_ass_values (line 285) | def test_ass_values():

FILE: tests/test_convert.py
  function test_coloralpha (line 22) | def test_coloralpha():
  function test_text_to_shape (line 184) | def test_text_to_shape():
  function test_text_to_shape_with_spacing (line 209) | def test_text_to_shape_with_spacing():

FILE: tests/test_utils.py
  function test_interpolation (line 20) | def test_interpolation():
  function test_frame_utility (line 25) | def test_frame_utility():
Condensed preview — 59 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (641K chars).
[
  {
    "path": ".flake8",
    "chars": 57,
    "preview": "[flake8]\nmax-line-length = 127\nextend-ignore = E203, W503"
  },
  {
    "path": ".github/scripts/install-fonts.ps1",
    "chars": 1051,
    "preview": "$clnt = new-object System.Net.WebClient\n$clnt.DownloadFile($args[0], \"tmp.zip\")\n\nAdd-Type -AssemblyName System.IO.Compre"
  },
  {
    "path": ".github/workflows/ci.yml",
    "chars": 4883,
    "preview": "# Workflow label\nname: CI\n\n# Workflow trigger\non: [push, pull_request]\n\n# Cancel previous runs on new push\nconcurrency:\n"
  },
  {
    "path": ".gitignore",
    "chars": 2030,
    "preview": "# Created by https://www.gitignore.io/api/python,virtualenv\n# Edit at https://www.gitignore.io/?templates=python,virtual"
  },
  {
    "path": ".readthedocs.yaml",
    "chars": 382,
    "preview": "version: \"2\"\n\nbuild:\n  os: \"ubuntu-24.04\"\n  apt_packages:\n    - libgirepository-2.0-dev\n    - gobject-introspection\n    "
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "chars": 3060,
    "preview": "# Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, we as contributors and"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 4215,
    "preview": "# Contributing to PyonFX\nWelcome to **PyonFX**!\n\nIf you intend to contribute to this project, please read following text"
  },
  {
    "path": "LICENSE",
    "chars": 7652,
    "preview": "                   GNU LESSER GENERAL PUBLIC LICENSE\n                       Version 3, 29 June 2007\n\n Copyright (C) 2007"
  },
  {
    "path": "README.md",
    "chars": 2469,
    "preview": "<h1 align=\"center\"><img src=\"https://github.com/CoffeeStraw/PyonFX/blob/master/docs/source/_static/PyonFX%20Logo.png?raw"
  },
  {
    "path": "docs/Makefile",
    "chars": 584,
    "preview": "# Minimal makefile for Sphinx documentation\n#\n\n# You can set these variables from the command line.\nSPHINXOPTS    =\nSPHI"
  },
  {
    "path": "docs/make.bat",
    "chars": 756,
    "preview": "@ECHO OFF\n\npushd %~dp0\n\nREM Command file for Sphinx documentation\n\nif \"%SPHINXBUILD%\" == \"\" (\n\tset SPHINXBUILD=sphinx-bu"
  },
  {
    "path": "docs/source/conf.py",
    "chars": 4932,
    "preview": "# -*- coding: utf-8 -*-\n#\n# Configuration file for the Sphinx documentation builder.\n\nimport os\nimport sys\n\nimport sphin"
  },
  {
    "path": "docs/source/index.rst",
    "chars": 1105,
    "preview": "The PyonFX Library Documentation\n********************************\n\n\t\"PyonFX is an easy way to create KFX (Karaoke Effect"
  },
  {
    "path": "docs/source/quick start.rst",
    "chars": 7684,
    "preview": ".. _quick-start:\n\nQuick Start Guide\n-----------------\n\nFirst things first, you must have a good idea of how to create yo"
  },
  {
    "path": "docs/source/reference/ass core.rst",
    "chars": 80,
    "preview": ".. _ass-core-ref:\n\nAss Core\n========\n\n.. automodule:: pyonfx.ass_core\n\t:members:"
  },
  {
    "path": "docs/source/reference/convert.rst",
    "chars": 96,
    "preview": ".. _convert-ref:\n\nConvert Functions\n=================\n\n.. automodule:: pyonfx.convert\n\t:members:"
  },
  {
    "path": "docs/source/reference/index.rst",
    "chars": 807,
    "preview": ".. _reference-index:\n\n#################################\n  The PyonFX Library Reference\n#################################"
  },
  {
    "path": "docs/source/reference/shape.rst",
    "chars": 90,
    "preview": ".. _shape-ref:\n\nShape Functions\n=================\n\n.. automodule:: pyonfx.shape\n\t:members:"
  },
  {
    "path": "docs/source/reference/utils.rst",
    "chars": 68,
    "preview": ".. _utils-ref:\n\nUtils\n=====\n\n.. automodule:: pyonfx.utils\n\t:members:"
  },
  {
    "path": "examples/1 - Basics/1 - Look into ASS values.py",
    "chars": 2146,
    "preview": "\"\"\"\nThis script visualizes which ASS values you got from input ASS file.\n\nFirst of all you need to create an Ass object,"
  },
  {
    "path": "examples/1 - Basics/2 - Create the First Output.py",
    "chars": 1830,
    "preview": "\"\"\"\nThis script creates your first dialog line in \"Output.ass\", which is\nthe default name for the output that will conta"
  },
  {
    "path": "examples/1 - Basics/3 - More lines.py",
    "chars": 599,
    "preview": "\"\"\"\nLet's go a bit further.\n\nIn this script we will iterate through all the lines of our .ass,\ncreate a copy for each of"
  },
  {
    "path": "examples/1 - Basics/4 - Organizing the code.py",
    "chars": 1705,
    "preview": "\"\"\"\nTime to manage the effect creation process.\nIf we want to take things further, it is better to structure our code.\n\n"
  },
  {
    "path": "examples/1 - Basics/in.ass",
    "chars": 1117,
    "preview": "[Script Info]\n; Script generated by Aegisub 8975-master-8d77da3\n; http://www.aegisub.org/\nTitle: New subtitles\nScriptTy"
  },
  {
    "path": "examples/2 - Beginner/1 - First Simple Effect.py",
    "chars": 3920,
    "preview": "\"\"\"\nAnd here we are with our first complete effect.\nAs you can see, we have now filled our romaji, kanji and sub functio"
  },
  {
    "path": "examples/2 - Beginner/2 - Utilities.py",
    "chars": 4476,
    "preview": "\"\"\"\nThe transform ASS tag makes some nice animations possible, but that shouldn't be enough for us!\nThe alternative is t"
  },
  {
    "path": "examples/2 - Beginner/3 - Variants.py",
    "chars": 11130,
    "preview": "\"\"\"\nInline effects is a method to define exclusive effects for syllables.\nFields \"Actor\" and \"Effect\" can also be used t"
  },
  {
    "path": "examples/2 - Beginner/4 - Accelerate Demo.py",
    "chars": 2113,
    "preview": "from pyonfx import *\nfrom pyonfx.utils import FrameUtility\n\n# Parameters\nHEIGHT = 700\nDURATION_MS = 4000\nDURATION_STILL_"
  },
  {
    "path": "examples/2 - Beginner/5 - Image to Pixels Demo.py",
    "chars": 3139,
    "preview": "\"\"\"\nImage to Pixels Demo - Demonstrating the image_to_pixels function\n\nThis demo shows how to use the Convert.image_to_p"
  },
  {
    "path": "examples/2 - Beginner/in.ass",
    "chars": 5986,
    "preview": "[Script Info]\n; Script generated by Aegisub 8975-master-8d77da3\n; http://www.aegisub.org/\nPlayResX: 1280\nPlayResY: 720\n"
  },
  {
    "path": "examples/2 - Beginner/in2.ass",
    "chars": 2646,
    "preview": "[Script Info]\n; Script generated by Aegisub 8975-master-8d77da3\n; http://www.aegisub.org/\nTitle: Default Aegisub file\nS"
  },
  {
    "path": "examples/3 - Advanced/1 - WIP.py",
    "chars": 2548,
    "preview": "import random\n\nfrom pyonfx import *\n\nio = Ass(\"in.ass\")\nmeta, styles, lines = io.get_data()\n\ncircle = Shape.ellipse(20, "
  },
  {
    "path": "examples/3 - Advanced/2 - Testing Pixels (WIP).py",
    "chars": 2398,
    "preview": "\"\"\"\nJust a test to show pixels in action, this file will be removed as soon as I prepare the new examples.\n\"\"\"\n\nimport m"
  },
  {
    "path": "examples/3 - Advanced/3 - Morphing.py",
    "chars": 9637,
    "preview": "\"\"\"\nMorphing Example. To be refined before considering it complete.\n\"\"\"\n\nimport random\n\nfrom pyonfx import *\n\n# Setup I/"
  },
  {
    "path": "examples/3 - Advanced/in.ass",
    "chars": 5986,
    "preview": "[Script Info]\n; Script generated by Aegisub 8975-master-8d77da3\n; http://www.aegisub.org/\nPlayResX: 1280\nPlayResY: 720\n"
  },
  {
    "path": "examples/4 - Community/1 - Dangos/dango_config.py",
    "chars": 13140,
    "preview": "\"\"\"\nShape & colour definitions for the \"Dangos\".\n\nIt provides:\n- Vector paths for body, eyes and accessories.\n- Helpers "
  },
  {
    "path": "examples/4 - Community/1 - Dangos/in.ass",
    "chars": 4132,
    "preview": "[Script Info]\n; Script generated by Aegisub 3.4.2\n; http://www.aegisub.org/\nTitle: Default Aegisub file\nScriptType: v4."
  },
  {
    "path": "examples/4 - Community/1 - Dangos/main.py",
    "chars": 32637,
    "preview": "\"\"\"\nCommunity example - 'Dangos' karaoke effect.\n\nThis script animates a troupe of \"dango\" blobs that morph out of every"
  },
  {
    "path": "pyonfx/__init__.py",
    "chars": 290,
    "preview": "# -*- coding: utf-8 -*-\n\nfrom .ass_core import Ass, Char, Line, Meta, Style, Syllable, Word\nfrom .convert import ColorMo"
  },
  {
    "path": "pyonfx/ass_core.py",
    "chars": 61463,
    "preview": "# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation "
  },
  {
    "path": "pyonfx/convert.py",
    "chars": 31444,
    "preview": "# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation "
  },
  {
    "path": "pyonfx/font.py",
    "chars": 12817,
    "preview": "# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation "
  },
  {
    "path": "pyonfx/pixel.py",
    "chars": 12808,
    "preview": "# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation "
  },
  {
    "path": "pyonfx/shape.py",
    "chars": 84508,
    "preview": "# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation "
  },
  {
    "path": "pyonfx/utils.py",
    "chars": 36112,
    "preview": "# PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation "
  },
  {
    "path": "pyproject.toml",
    "chars": 1719,
    "preview": "[project]\nname = \"pyonfx\"\nversion = \"0.11.0\"\ndescription = \"An easy way to create KFX (Karaoke Effects) and complex type"
  },
  {
    "path": "requirements.txt",
    "chars": 1,
    "preview": "."
  },
  {
    "path": "tests/Ass/ass_core.ass",
    "chars": 3927,
    "preview": "[Script Info]\n; Script generated by Aegisub 3.4.2\n; http://www.aegisub.org/\nPlayResX: 1280\nPlayResY: 720\n\n[Aegisub Proj"
  },
  {
    "path": "tests/Ass/in.ass",
    "chars": 5984,
    "preview": "[Script Info]\n; Script generated by Aegisub 8975-master-8d77da3\n; http://www.aegisub.org/\nPlayResX: 1280\nPlayResY: 720\n"
  },
  {
    "path": "tests/Ass/in_with_spacing.ass",
    "chars": 1666,
    "preview": "[Script Info]\n; Script generated by Aegisub 9841-feature-2d190fef7\n; http://www.aegisub.org/\nPlayResX: 1280\nPlayResY: 7"
  },
  {
    "path": "tests/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/shape/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "tests/shape/fixtures.py",
    "chars": 112120,
    "preview": "FLATTEN_CIRCLE_ORIGINAL = \"m 50 0 b 22 0 0 22 0 50 b 0 78 22 100 50 100 b 78 100 100 78 100 50 b 100 22 78 0 50 0\"\nFLATT"
  },
  {
    "path": "tests/shape/test_elements.py",
    "chars": 8086,
    "preview": "import pytest\nfrom shapely import Point\n\nfrom pyonfx.shape import Shape, ShapeElement\n\n\ndef test_iter():\n    \"\"\"Basic it"
  },
  {
    "path": "tests/shape/test_generation.py",
    "chars": 1088,
    "preview": "import math\n\nimport pytest\n\nfrom pyonfx.shape import Shape\n\n\ndef test_polygon_invalid_edges():\n    with pytest.raises(Va"
  },
  {
    "path": "tests/shape/test_operations.py",
    "chars": 12434,
    "preview": "import math\nfrom copy import copy\n\nimport pytest\n\nfrom pyonfx.shape import Shape\n\nfrom .fixtures import *\n\n\ndef test_map"
  },
  {
    "path": "tests/test_ass.py",
    "chars": 11274,
    "preview": "import os\nimport sys\nfrom fractions import Fraction\n\nimport pytest_check as check\nfrom video_timestamps import FPSTimest"
  },
  {
    "path": "tests/test_convert.py",
    "chars": 64757,
    "preview": "import os\nimport sys\nfrom fractions import Fraction\n\nimport pytest_check as check\n\nfrom pyonfx import *\n\n# Get ass path\n"
  },
  {
    "path": "tests/test_utils.py",
    "chars": 1714,
    "preview": "import os\nfrom fractions import Fraction\n\nfrom video_timestamps import FPSTimestamps, RoundingMethod\n\nfrom pyonfx import"
  }
]

About this extraction

This page contains the full source code of the CoffeeStraw/PyonFX GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 59 files (603.0 KB), approximately 211.9k tokens, and a symbol index with 202 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!