diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4b9927a..74af6e8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,11 +4,36 @@ on: push: tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 - release: - types: - - [published] + release: + types: + - published jobs: + build_sdist: + name: Build source distribution + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + with: + submodules: recursive + + - uses: actions/setup-python@v4 + with: + python-version: 3.8 + + - name: Build sdist + run: pipx run build --sdist + + - name: Test sdist + run: | + python -m pip install dist/apngasm_python-*.tar.gz + pip install pytest numpy Pillow + pytest + + - uses: actions/upload-artifact@v3 + with: + path: dist/apngasm_python-*.tar.gz + build_wheels: name: Build wheels for ${{ matrix.os }} ${{ matrix.cibw_archs }} ${{ matrix.cibw_build }} runs-on: ${{ matrix.os }} @@ -18,71 +43,66 @@ jobs: include: - os: windows-2019 cibw_archs: AMD64 - cibw_build: "*" + cibw_build: "cp38* pp*" cibw_environment: APNGASM_COMPILE_TARGET=x86_64 - os: windows-2019 cibw_archs: x86 - cibw_build: "*" + cibw_build: "cp38*" cibw_environment: APNGASM_COMPILE_TARGET=x86 - os: windows-2019 cibw_archs: ARM64 - cibw_build: "*" + cibw_build: "cp39*" cibw_environment: APNGASM_COMPILE_TARGET=armv8 - - os: macos-11 + - os: macos-12 cibw_archs: x86_64 - cibw_build: "*" - cibw_environment: > - APNGASM_COMPILE_TARGET=x86_64 - _PYTHON_HOST_PLATFORM=macosx-10.15-x86_64 - MACOSX_DEPLOYMENT_TARGET=10.15 - - os: macos-11 + cibw_build: "cp38* pp*" + cibw_environment: APNGASM_COMPILE_TARGET=x86_64 + - os: macos-14 cibw_archs: arm64 - cibw_build: "*" - cibw_environment: > - APNGASM_COMPILE_TARGET=armv8 - _PYTHON_HOST_PLATFORM=macosx-11.0-arm64 - MACOSX_DEPLOYMENT_TARGET=11.0 - - os: macos-11 + cibw_build: "cp38* pp*" + cibw_environment: APNGASM_COMPILE_TARGET=armv8 + - os: macos-14 cibw_archs: universal2 - cibw_build: "*" - cibw_environment: > - APNGASM_COMPILE_TARGET=universal2 - _PYTHON_HOST_PLATFORM=macosx-11.0-universal2 - MACOSX_DEPLOYMENT_TARGET=11.0 + cibw_build: "cp38* pp*" + cibw_environment: APNGASM_COMPILE_TARGET=universal2 - os: ubuntu-20.04 cibw_archs: x86_64 - cibw_build: "*" + cibw_build: "cp38* pp*" cibw_environment: APNGASM_COMPILE_TARGET=x86_64 - os: ubuntu-20.04 cibw_archs: i686 - cibw_build: "*" + cibw_build: "cp38* pp*" cibw_environment: APNGASM_COMPILE_TARGET=x86 - os: ubuntu-20.04 cibw_archs: aarch64 - cibw_build: "*-musllinux_*" + cibw_build: "cp38*-musllinux_* pp*-musllinux_*" cibw_environment: APNGASM_COMPILE_TARGET=armv8 - os: ubuntu-20.04 cibw_archs: aarch64 - cibw_build: "*-manylinux_*" + cibw_build: "cp38*-manylinux_* pp*-manylinux_*" cibw_environment: APNGASM_COMPILE_TARGET=armv8 - os: ubuntu-20.04 cibw_archs: ppc64le - cibw_build: "*-musllinux_*" + cibw_build: "cp38*-musllinux_*" cibw_environment: APNGASM_COMPILE_TARGET=ppc64le - os: ubuntu-20.04 cibw_archs: ppc64le - cibw_build: "*-manylinux_*" + cibw_build: "cp38*-manylinux_*" cibw_environment: APNGASM_COMPILE_TARGET=ppc64le - - os: ubuntu-20.04 - cibw_archs: s390x - cibw_build: "*-musllinux_*" - cibw_environment: APNGASM_COMPILE_TARGET=s390x - - os: ubuntu-20.04 - cibw_archs: s390x - cibw_build: "*-manylinux_*" - cibw_environment: APNGASM_COMPILE_TARGET=s390x + # - os: ubuntu-20.04 + # cibw_archs: s390x + # cibw_build: "*-musllinux_*" + # cibw_environment: APNGASM_COMPILE_TARGET=s390x + # - os: ubuntu-20.04 + # cibw_archs: s390x + # cibw_build: "*-manylinux_*" + # cibw_environment: APNGASM_COMPILE_TARGET=s390x steps: + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + - uses: actions/checkout@v3 with: submodules: true @@ -94,37 +114,32 @@ jobs: platforms: all - name: Build wheels for ${{ matrix.os }} ${{ matrix.cibw_archs }} ${{ matrix.cibw_build }} - uses: pypa/cibuildwheel@v2.16.5 + uses: pypa/cibuildwheel@v2.17.0 env: CIBW_BUILD_FRONTEND: build CIBW_ARCHS: ${{ matrix.cibw_archs }} - CIBW_BEFORE_ALL: ${{ matrix.cibw_before_all }} # CIBW_ENVIRONMENT: PY_BUILD_CMAKE_VERBOSE=1 ${{ matrix.cibw_environment }} CIBW_ENVIRONMENT: ${{ matrix.cibw_environment }} CIBW_BUILD: ${{ matrix.cibw_build }} + CIBW_TEST_REQUIRES: pytest + CIBW_BEFORE_TEST: pip install --only-binary ":all:" Pillow numpy; true + CIBW_BEFORE_TEST_WINDOWS: pip install --only-binary ":all:" Pillow numpy || VER>NUL + CIBW_TEST_COMMAND: pytest {package}/tests + CIBW_TEST_SKIP: pp* + + - name: abi3audit + run: | + pip install abi3audit + abi3audit $(ls ./wheelhouse/*.whl) --debug --verbose - uses: actions/upload-artifact@v3 with: path: ./wheelhouse/*.whl - - build_sdist: - name: Build source distribution - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Build sdist - run: pipx run build --sdist - - - uses: actions/upload-artifact@v3 - with: - path: dist/*.tar.gz # upload_pypi_test: # needs: [build_wheels, build_sdist] # runs-on: ubuntu-latest + # if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') # steps: # - uses: actions/download-artifact@v3 # with: @@ -142,6 +157,7 @@ jobs: upload_pypi: needs: [build_wheels, build_sdist] runs-on: ubuntu-latest + if: github.event_name == 'release' && github.event.action == 'published' steps: - uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/check_and_fix.yml b/.github/workflows/check_and_fix.yml new file mode 100644 index 0000000..ba0b30e --- /dev/null +++ b/.github/workflows/check_and_fix.yml @@ -0,0 +1,49 @@ +name: Check, update stub and formatting + +on: + push: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check_and_fix: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@master + with: + fetch-depth: 0 # otherwise, you will failed to push refs to dest repo + submodules: recursive + - name: Extract branch name + shell: bash + run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT + id: extract_branch + - uses: actions/setup-python@v5 + with: + python-version: '3.9' + - name: Install test + run: pip install .[full,test,lint] + - name: Ruff check + run: ruff check + - name: Ruff format + run: ruff format + - name: mypy + run: mypy + - name: isort + run: isort . + - name: nanobind stubgen + run: | + pip install nanobind + python -m nanobind.stubgen \ + -m apngasm_python._apngasm_python \ + -o src-python/apngasm_python/_apngasm_python.pyi \ + -M src-python/apngasm_python/py.typed + - name: Pytest + run: pytest + - name: Commit & Push changes + uses: actions-js/push@master + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + message: Update stub and formatting + branch: ${{ steps.extract_branch.outputs.branch }} \ No newline at end of file diff --git a/.github/workflows/update_stub.yml b/.github/workflows/update_stub.yml deleted file mode 100644 index 43ec3f5..0000000 --- a/.github/workflows/update_stub.yml +++ /dev/null @@ -1,27 +0,0 @@ -name: Update stub - -on: - push: - -jobs: - stubgen: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@master - with: - fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - submodules: recursive - - name: Extract branch name - shell: bash - run: echo "branch=${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}" >> $GITHUB_OUTPUT - id: extract_branch - - name: Auto stub generation - run: | - pip install build - python scripts/update_stub.py - - name: Commit & Push changes - uses: actions-js/push@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - message: Auto stub generation - branch: ${{ steps.extract_branch.outputs.branch }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 186ca55..ff532c6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,7 @@ dist/ .py-build-cmake_cache/ .vscode/ conan_output/ -example/output/ \ No newline at end of file +samples/output/ +venv/ +docs/_build/ +CMakeUserPresets.json \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 72f472c..875dd42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,32 +22,35 @@ execute_process( ) message(STATUS "Finished get_deps.py") -if (WIN32) +if (MSVC) set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") set(CMAKE_CXX_FLAGS_RELEASE "${CMAKE_CXX_FLAGS_RELEASE} /MT") set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} /MTd") + set(CMAKE_CXX_FLAGS_RELWITHDEBINFO "${CMAKE_CXX_FLAGS_RELWITHDEBINFO} /Zi /Ob0 /Od /RTC1") elseif (LINUX) set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -fPIC") elseif (APPLE) - if(${APNGASM_COMPILE_TARGET} STREQUAL "x86_64") + if(${APNGASM_COMPILE_TARGET} STREQUAL "armv8") set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0") else() - set(CMAKE_OSX_DEPLOYMENT_TARGET "10.15") + set(CMAKE_OSX_DEPLOYMENT_TARGET "10.9") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++0x -stdlib=libc++ -fno-aligned-allocation") endif() endif() set(ZLIB_USE_STATIC_LIBS ON) set(PNG_USE_STATIC_LIBS ON) set(Boost_USE_STATIC_LIBS ON) -if (WIN32) +if (MSVC) set(Boost_USE_MULTITHREADED ON) set(Boost_USE_STATIC_RUNTIME ON) endif() # Add conan packages -if ("${APNGASM_COMPILE_TARGET}" STREQUAL "universal2") - # x86_64 Contians static libraries that are universal2 +if ("${APNGASM_COMPILE_TARGET}" STREQUAL "universal2_x86_64") set(CONAN_TOOLCHAIN ${CMAKE_SOURCE_DIR}/conan_output/x86_64/conan_toolchain.cmake) +elseif ("${APNGASM_COMPILE_TARGET}" STREQUAL "universal2_armv8") + set(CONAN_TOOLCHAIN ${CMAKE_SOURCE_DIR}/conan_output/armv8/conan_toolchain.cmake) else() set(CONAN_TOOLCHAIN ${CMAKE_SOURCE_DIR}/conan_output/${APNGASM_COMPILE_TARGET}/conan_toolchain.cmake) endif() @@ -58,7 +61,7 @@ else() message(FATAL_ERROR "The conan_toolchain file could not be found: ${CONAN_TOOLCHAIN}") endif() -project(apngasm-python VERSION 1.2.3) +project(apngasm-python VERSION 1.3.0) set(PY_VERSION_SUFFIX "") set(PY_FULL_VERSION ${PROJECT_VERSION}${PY_VERSION_SUFFIX}) @@ -76,7 +79,7 @@ find_nanobind_python_first() # Compile the Python module nanobind_add_module(_apngasm_python "src/apngasm_python.cpp" NB_STATIC STABLE_ABI) -if (WIN32) +if (MSVC) nanobind_compile_options(_apngasm_python "/MT /MP /bigobj") endif() target_compile_definitions(_apngasm_python PRIVATE _apngasm_python_EXPORTS) @@ -101,7 +104,7 @@ set_target_properties(_apngasm_python PROPERTIES CXX_VISIBILITY_PRESET "hidden" VISIBILITY_INLINES_HIDDEN true POSITION_INDEPENDENT_CODE true) -if (WIN32) +if (MSVC) set_target_properties(_apngasm_python PROPERTIES LINK_FALGS_RELEASE "/WHOLEARCHIVE:MNN") elseif (LINUX) target_link_options(_apngasm_python PRIVATE "LINKER:--exclude-libs,ALL") @@ -114,14 +117,13 @@ install(TARGETS _apngasm_python COMPONENT python_modules) # Generate stubs for the Python module -option(WITH_PY_STUBS - "Generate Python stub files (.pyi) for the Python module." On) -if (WITH_PY_STUBS AND NOT CMAKE_CROSSCOMPILING) - include(cmake/NanobindStubgen.cmake) - nanobind_stubgen(_apngasm_python) - add_custom_command(TARGET _apngasm_python POST_BUILD - COMMAND ${PYTHON_EXECUTABLE} patch_stub.py $/_apngasm_python.pyi - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/scripts - USES_TERMINAL) - nanobind_stubgen_install(_apngasm_python ${PY_BUILD_CMAKE_MODULE_NAME}) -endif() \ No newline at end of file +nanobind_add_stub( + apngasm_python_stub + INSTALL_TIME + MODULE apngasm_python._apngasm_python + OUTPUT "${PY_BUILD_CMAKE_MODULE_NAME}/_apngasm_python.pyi" + MARKER_FILE "${PY_BUILD_CMAKE_MODULE_NAME}/py.typed" + COMPONENT python_modules + EXCLUDE_FROM_ALL + VERBOSE +) \ No newline at end of file diff --git a/PreLoad.cmake b/PreLoad.cmake new file mode 100644 index 0000000..88876c0 --- /dev/null +++ b/PreLoad.cmake @@ -0,0 +1,14 @@ +# https://stackoverflow.com/a/45247784 +# Need python headers and libraries, but msvc not able to find them +# If inside cygwin or msys. + +if (WIN32 AND NOT MSVC) + execute_process(COMMAND uname OUTPUT_VARIABLE uname) + if (uname MATCHES "^MINGW") + set (CMAKE_GENERATOR "MinGW Makefiles" CACHE INTERNAL "" FORCE) + elseif (uname MATCHES "^MSYS") + set (CMAKE_GENERATOR "MSYS Makefiles" CACHE INTERNAL "" FORCE) + else () + set (CMAKE_GENERATOR "Ninja" CACHE INTERNAL "" FORCE) + endif() +endif() \ No newline at end of file diff --git a/README.md b/README.md index f6fc37d..7749b5c 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,39 @@ # apngasm-python -A nanobind API for [apngasm](https://github.com/apngasm/apngasm), a tool/library for APNG assembly/disassembly. +A nanobind API for [apngasm](https://github.com/apngasm/apngasm), which is a +tool/library for APNG assembly & disassembly with compression support. -apngasm is originally a CLI program for quickly assembling PNG images into animated PNG (APNG). It also supports creating compressed APNG. +apngasm is originally a CLI program for quickly assembling PNG images into +animated PNG (APNG). It also supports creating compressed APNG. -apngasm-python is a binding for apngasm using nanobind, allowing you to use apngasm without calling it using commands. +apngasm-python is a binding for apngasm using nanobind, allowing you to use apngasm +without calling it using commands. -With this module, you can even create APNG using images inside memory (No need to write them out as file and call apngasm! This is about 2 times faster from testing.) +With this module, you can even create APNG using images inside memory (No need to write +them out as file and call apngasm! This is about 2 times faster from testing.) -A similar python module is https://github.com/eight04/pyAPNG , which handles APNG files with python natively and does not support compression. +A similar python module is https://github.com/eight04/pyAPNG , which handles APNG files +with python natively and does not support compression. -For convenience, prebuilt library is packaged with this module, so you need not download apngasm. +For convenience, prebuilt library is packaged with this module, so you need not +download apngasm. Documentations: https://apngasm-python.readthedocs.io/en/latest/ ## Install -``` +```bash pip install apngasm-python ``` -Optionally, you can also install `Pillow` and `numpy` -``` -pip install Pillow numpy +`Pillow` and `numpy` are optional dependencies. Without them, +some functions are not usable. To also install them: +```bash +pip install apngasm-python[full] ``` ## Example usage -The recommended usage is to `from apngasm_python.apngasm import APNGAsmBinder`, see [example/example_binder.py](example/example_binder.py) +The recommended usage is to `from apngasm_python.apngasm import APNGAsmBinder`, see +[example/example_binder.py](example/example_binder.py) ```python from apngasm_python.apngasm import APNGAsmBinder import numpy as np @@ -35,36 +43,37 @@ import os apngasm = APNGAsmBinder() # From file -for file_name in sorted(os.listdir('frames')): +for file_name in sorted(os.listdir("samples/frames")): # To adjust frame duration, set delay_num and delay_den # The frame duration will be (delay_num / delay_den) seconds - apngasm.add_frame_from_file(file_path=os.path.join('frames', file_name), delay_num=100, delay_den=1000) + apngasm.add_frame_from_file(file_path=os.path.join("samples/frames", file_name), delay_num=100, delay_den=1000) # Default value of loops is 0, which is infinite looping of APNG animation # This sets the APNG animation to loop for 3 times before stopping apngasm.set_loops(3) -apng.assemble('result-from-file.apng') +apng.assemble("samples/result-from-file.apng") apngasm.reset() # From Pillow -for file_name in sorted(os.listdir('frames')): - image = Image.open(os.path.join('frames', file_name)).convert('RGBA') +for file_name in sorted(os.listdir("samples/frames")): + image = Image.open(os.path.join("samples/frames", file_name)).convert("RGBA") frame = apngasm.add_frame_from_pillow(image, delay_num=50, delay_den=1000) -apngasm.assemble('result-from-pillow.apng') +apngasm.assemble("result-from-pillow.apng") apngasm.reset() # Disassemble and get pillow image of one frame # You can use with statement to avoid calling reset() with APNGAsmBinder() as apng: - frames = apng.disassemble_as_pillow('input/ball.apng') + frames = apng.disassemble_as_pillow("samples/input/ball.apng") frame = frames[0] - frame.save('output/ball0.png') + frame.save("samples/output/ball0.png") # Disassemble all APNG into PNGs -apngasm.save_pngs('output') +apngasm.save_pngs("samples/output") ``` -Alternatively, you can reduce overhead and do advanced tasks by calling methods directly, see [example/example_direct.py](example/example_direct.py) +Alternatively, you can reduce overhead and do advanced tasks by calling methods +directly, see [example/example_direct.py](example/example_direct.py) ```python from apngasm_python._apngasm_python import APNGAsm, APNGFrame, create_frame_from_rgb, create_frame_from_rgba import numpy as np @@ -74,38 +83,39 @@ import os apngasm = APNGAsm() # From file -for file_name in sorted(os.listdir('frames')): +for file_name in sorted(os.listdir("samples/frames")): # To adjust frame duration, set delay_num and delay_den # The frame duration will be (delay_num / delay_den) seconds - apngasm.add_frame_from_file(file_path=os.path.join('frames', file_name), delay_num=100, delay_den=1000) + apngasm.add_frame_from_file(file_path=os.path.join("samples/frames", file_name), delay_num=100, delay_den=1000) # Default value of loops is 0, which is infinite looping of APNG animation # This sets the APNG animation to loop for 3 times before stopping apngasm.set_loops(3) -apng.assemble('result-from-file.apng') +apng.assemble("samples/result-from-file.apng") # From Pillow apngasm.reset() -for file_name in sorted(os.listdir('frames')): - image = Image.open(os.path.join('frames', file_name)).convert('RGBA') +for file_name in sorted(os.listdir("samples/frames")): + image = Image.open(os.path.join("samples/frames", file_name)).convert("RGBA") frame = create_frame_from_rgba(np.array(image), image.width, image.height) frame.delay_num = 50 frame.delay_den = 1000 apngasm.add_frame(frame) -apngasm.assemble('result-from-pillow.apng') +apngasm.assemble("samples/result-from-pillow.apng") # Disassemble and get pillow image of one frame apngasm.reset() -frames = apngasm.disassemble('input/ball.apng') +frames = apngasm.disassemble("samples/input/ball.apng") frame = frames[0] im = Image.frombytes(mode, (frame.width, frame.height), frame.pixels) -im.save('output/ball0.png') +im.save("samples/output/ball0.png") # Disassemble all APNG into PNGs -apngasm.save_pngs('output') +apngasm.save_pngs("samples/output") ``` -The methods are based on [apngasm.h](https://github.com/apngasm/apngasm/blob/master/lib/src/apngasm.h) and [apngframe.h](https://github.com/apngasm/apngasm/blob/master/lib/src/apngframe.h) +The methods are based on [apngasm.h](https://github.com/apngasm/apngasm/blob/master/lib/src/apngasm.h) +and [apngframe.h](https://github.com/apngasm/apngasm/blob/master/lib/src/apngframe.h) You can get more info about the binding from [src/apngasm_python.cpp](src/apngasm_python.cpp), or by... @@ -116,9 +126,8 @@ help(_apngasm_python) ## Building from source ```bash -git clone https://github.com/laggykiller/apngasm-python.git +git clone --recursive https://github.com/laggykiller/apngasm-python.git cd apngasm-python -git submodule update --init --recursive # To build wheel python3 -m build . @@ -144,6 +153,22 @@ export APNGASM_COMPILE_TARGET=ppc64le export APNGASM_COMPILE_TARGET=s390x ``` +## Development +To run tests: +```bash +pip install pytest +pytest +``` + +To lint: +```bash +pip install ruff mypy isort +mypy +isort . +ruff check +ruff format +``` + ## Credits - apngasm: https://github.com/apngasm/apngasm - Packaging: https://github.com/tttapa/py-build-cmake diff --git a/apngasm b/apngasm index c983a42..060bfc1 160000 --- a/apngasm +++ b/apngasm @@ -1 +1 @@ -Subproject commit c983a42c17b1625b6c6d7fb6e36c756757e499a8 +Subproject commit 060bfc19c135a53eb1d3c4294cc89402ccd698aa diff --git a/cmake/NanobindStubgen.cmake b/cmake/NanobindStubgen.cmake deleted file mode 100644 index 9e49004..0000000 --- a/cmake/NanobindStubgen.cmake +++ /dev/null @@ -1,23 +0,0 @@ -function(nanobind_stubgen target) - - find_package(Python REQUIRED COMPONENTS Interpreter) - - add_custom_command(TARGET ${target} POST_BUILD - COMMAND ${Python_EXECUTABLE} -m nanobind_stubgen - --out $ - $ - WORKING_DIRECTORY $ - USES_TERMINAL) - -endfunction() - -function(nanobind_stubgen_install target destination) - - install(FILES - $/$.pyi - RENAME __init__.pyi - EXCLUDE_FROM_ALL - COMPONENT python_modules - DESTINATION ${destination}/$) - -endfunction() \ No newline at end of file diff --git a/conanfile.py b/conanfile.py index 50c5f64..c54f345 100644 --- a/conanfile.py +++ b/conanfile.py @@ -1,34 +1,36 @@ -from conan import ConanFile +#!/usr/bin/env python3 +# type: ignore import shutil -from scripts.get_arch import get_arch -from conan.tools.cmake import CMake, CMakeToolchain, CMakeDeps, cmake_layout + +from conan import ConanFile from conan.tools.apple import is_apple_os +from conan.tools.cmake import CMakeDeps, CMakeToolchain + +from scripts.get_arch import get_arch class ApngasmRecipe(ConanFile): settings = "os", "compiler", "build_type", "arch" def requirements(self): - self.requires("zlib/1.2.13") # type: ignore - self.requires("libpng/1.6.40") # type: ignore - self.requires( - "boost/1.75.0" # type: ignore - ) # https://github.com/conan-io/conan-center-index/issues/19704 + self.requires("zlib/1.3.1") + self.requires("libpng/1.6.42") + self.requires("boost/1.84.0") def build_requirements(self): - self.build_requires("b2/4.10.1") # type: ignore + self.build_requires("b2/4.10.1") if not shutil.which("cmake"): - self.tool_requires("cmake/[>=3.27]") # type: ignore + self.tool_requires("cmake/[>=3.27]") def build(self): - build_type = "Release" + build_type = "Release" # noqa: F841 def generate(self): tc = CMakeToolchain(self) cmake = CMakeDeps(self) if is_apple_os(self) and get_arch() == "universal2": - tc.blocks["apple_system"].values[ - "cmake_osx_architectures" - ] = "x86_64; arm64" + tc.blocks["apple_system"].values["cmake_osx_architectures"] = ( + "x86_64; arm64" + ) tc.generate() cmake.generate() diff --git a/docs/conf.py b/docs/conf.py index 4609efd..a424a8b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -33,9 +33,9 @@ autoapi_dirs = [ os.path.abspath("../src-python/apngasm_python"), - os.path.abspath("../src-python/apngasm_python/_apngasm_python"), ] autoapi_python_use_implicit_namespaces = True +autoapi_file_patterns = ["*.pyi", "*.py"] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output @@ -44,7 +44,7 @@ html_static_path = ["_static"] # material theme options (see theme.conf for more information) html_theme_options = { - "repo_name": "rlottie-python", + "repo_name": "apngasm-python", "globaltoc_collapse": True, "features": [ "navigation.expand", diff --git a/example/example_binder.py b/example/example_binder.py index 03070a6..a27243f 100755 --- a/example/example_binder.py +++ b/example/example_binder.py @@ -1,13 +1,23 @@ #!/usr/bin/env python3 -from apngasm_python.apngasm import APNGAsmBinder import os import shutil -from PIL import Image + import numpy as np +from apngasm_python.apngasm import APNGAsmBinder +from PIL import Image + +file_dir = os.path.split(__file__)[0] +samples_dir = os.path.join(file_dir, "../samples") +frames_dir = os.path.join(samples_dir, "frames") +input_dir = os.path.join(samples_dir, "input") +output_dir = os.path.join(samples_dir, "output") +ball_apng_path = os.path.join(input_dir, "ball.apng") +grey_png_path = os.path.join(input_dir, "grey.png") +palette_png_path = os.path.join(input_dir, "palette.png") # Cleanup -shutil.rmtree("output", ignore_errors=True) -os.mkdir("output") +shutil.rmtree(output_dir, ignore_errors=True) +os.mkdir(output_dir) # Initialize apngasm = APNGAsmBinder() @@ -16,18 +26,20 @@ print(f"{apngasm.version() = }") # Load png from one directory -for file_name in sorted(os.listdir("frames")): - apngasm.add_frame_from_file(os.path.join("frames", file_name), 100, 1000) +for file_name in sorted(os.listdir(frames_dir)): + apngasm.add_frame_from_file(os.path.join(frames_dir, file_name), 100, 1000) # Getting information about one frame -frame = apngasm.get_frames()[0] +apng_frame = apngasm.get_frames()[0] # Saving one frame as file -frame.save("output/elephant-frame.png") +out = os.path.join(output_dir, "elephant-frame.png") +apng_frame.save(out) # Getting one frame as Pillow Image im = apngasm.frame_pixels_as_pillow(0) -im.save("output/elephant-frame-pillow.png") # type: ignore +out = os.path.join(output_dir, "elephant-frame-pillow.png") +im.save(out) # type: ignore # Get inforamtion about whole animation print(f"{apngasm.get_loops() = }") @@ -35,53 +47,58 @@ print(f"{apngasm.frame_count() = }") # Assemble -success = apngasm.assemble("output/elephant.apng") +out = os.path.join(output_dir, "elephant.apng") +success = apngasm.assemble(out) print(f"{success = }") # Clear images loaded in apngasm object apngasm.reset() # Disassemble and get pillow image of one frame -frames = apngasm.disassemble_as_pillow("input/ball.apng") +frames = apngasm.disassemble_as_pillow(ball_apng_path) frame = frames[0] -frame.save("output/ball0.png") +out = os.path.join(output_dir, "ball0.png") +frame.save(out) # Disassemble all APNG into PNGs -apngasm.save_pngs("output") +apngasm.save_pngs(output_dir) # Assemble from pillow images # Just for fun, let's also make it spin apngasm.reset() -angle = 0 -angle_step = 360 / len(os.listdir("frames")) -for file_name in sorted(os.listdir("frames")): - image = Image.open(os.path.join("frames", file_name)) +angle = 0.0 +angle_step = 360 / len(os.listdir(frames_dir)) +for file_name in sorted(os.listdir(frames_dir)): + image = Image.open(os.path.join(frames_dir, file_name)) image = image.rotate(angle) apngasm.add_frame_from_pillow(image) angle += angle_step -success = apngasm.assemble("output/elephant-spinning-pillow.apng") +out = os.path.join(output_dir, "elephant-spinning-pillow.apng") +success = apngasm.assemble(out) print(f"{success = }") apngasm.reset() # Assemble palette and grey PNGs # You can use with statement to avoid calling reset() with APNGAsmBinder() as apng: - apng.add_frame_from_file("input/palette.png", delay_num=1, delay_den=1) - apng.add_frame_from_file("input/grey.png", delay_num=1, delay_den=1) - success = apng.assemble("output/birds.apng") + apng.add_frame_from_file(palette_png_path, delay_num=1, delay_den=1) + apng.add_frame_from_file(grey_png_path, delay_num=1, delay_den=1) + out = os.path.join(output_dir, "birds.apng") + success = apng.assemble(out) print(f"{success = }") # Assemble palette and grey PNGs, but with Pillow and numpy -image0 = Image.open("input/grey.png") +image0 = Image.open(grey_png_path) frame0 = apngasm.add_frame_from_pillow(image0, delay_num=1, delay_den=1) -image1 = Image.open("input/grey.png").convert("RGB") +image1 = Image.open(grey_png_path).convert("RGB") frame1 = apngasm.add_frame_from_numpy( np.array(image1), trns_color=np.array([255, 255, 255]), delay_num=1, delay_den=1 ) -image2 = Image.open("input/palette.png") +image2 = Image.open(palette_png_path) apngasm.add_frame_from_pillow(image2, delay_num=1, delay_den=1) -success = apngasm.assemble("output/birds-pillow.apng") +out = os.path.join(output_dir, "birds-pillow.apng") +success = apngasm.assemble(out) print(f"{success = }") diff --git a/example/example_direct.py b/example/example_direct.py index 7618e6d..6bef88a 100755 --- a/example/example_direct.py +++ b/example/example_direct.py @@ -1,18 +1,25 @@ #!/usr/bin/env python3 -from apngasm_python._apngasm_python import ( - APNGAsm, - APNGFrame, - create_frame_from_rgb, - create_frame_from_rgb_trns, - create_frame_from_rgba, -) import os import shutil -from PIL import Image + import numpy as np +from apngasm_python._apngasm_python import (APNGAsm, APNGFrame, + create_frame_from_rgb, + create_frame_from_rgb_trns, + create_frame_from_rgba) +from PIL import Image + +file_dir = os.path.split(__file__)[0] +samples_dir = os.path.join(file_dir, "../samples") +frames_dir = os.path.join(samples_dir, "frames") +input_dir = os.path.join(samples_dir, "input") +output_dir = os.path.join(samples_dir, "output") +ball_apng_path = os.path.join(input_dir, "ball.apng") +grey_png_path = os.path.join(input_dir, "grey.png") +palette_png_path = os.path.join(input_dir, "palette.png") -def frame_info(frame): +def frame_info(frame: APNGFrame): print(f"{frame.pixels = }") print(f"{frame.width = }") print(f"{frame.height = }") @@ -28,11 +35,11 @@ def frame_info(frame): # https://www.w3.org/TR/PNG-Chunks.html color_type_dict = {0: "L", 2: "RGB", 3: "P", 4: "LA", 6: "RGBA"} -color_type_dict.update(dict((v, k) for k, v in color_type_dict.items())) # type: ignore +color_type_dict.update(dict((v, k) for k, v in color_type_dict.items())) # type: ignore # Cleanup -shutil.rmtree("output", ignore_errors=True) -os.mkdir("output") +shutil.rmtree(output_dir, ignore_errors=True) +os.mkdir(output_dir) # Initialize apngasm = APNGAsm() @@ -41,20 +48,22 @@ def frame_info(frame): print(f"{apngasm.version() = }") # Load png from one directory -for file_name in sorted(os.listdir("frames")): - apngasm.add_frame_from_file(os.path.join("frames", file_name), 100, 1000) +for file_name in sorted(os.listdir(frames_dir)): + apngasm.add_frame_from_file(os.path.join(frames_dir, file_name), 100, 1000) # Getting information about one frame frame = apngasm.get_frames()[0] frame_info(frame) # Saving one frame as file -frame.save("output/elephant-frame.png") +out = os.path.join(output_dir, "elephant-frame.png") +frame.save(out) # Getting one frame as Pillow Image mode = color_type_dict[frame.color_type] -im = Image.frombytes(mode, (frame.width, frame.height), frame.pixels) -im.save("output/elephant-frame-pillow.png") +im = Image.frombytes(mode, (frame.width, frame.height), frame.pixels) # type: ignore +out = os.path.join(output_dir, "elephant-frame-pillow.png") +im.save(out) # Get inforamtion about whole animation print(f"{apngasm.get_loops() = }") @@ -62,44 +71,47 @@ def frame_info(frame): print(f"{apngasm.frame_count() = }") # Assemble -success = apngasm.assemble("output/elephant.apng") +out = os.path.join(output_dir, "elephant.png") +success = apngasm.assemble(out) print(f"{success = }") # Clear images loaded in apngasm object apngasm.reset() # Disassemble and get pillow image of one frame -frames = apngasm.disassemble("input/ball.apng") +frames = apngasm.disassemble(ball_apng_path) print(f"{len(frames) = }") frame = frames[0] frame_info(frame) mode = color_type_dict[frame.color_type] -im = Image.frombytes(mode, (frame.width, frame.height), frame.pixels) -im.save("output/ball0.png") +im = Image.frombytes(mode, (frame.width, frame.height), frame.pixels) # type: ignore +out = os.path.join(output_dir, "ball0.png") +im.save(out) # Disassemble all APNG into PNGs -apngasm.save_pngs("output") +apngasm.save_pngs(output_dir) # Assemble from pillow images # Just for fun, let's also make it spin apngasm.reset() -angle = 0 -angle_step = 360 / len(os.listdir("frames")) -for file_name in sorted(os.listdir("frames")): - image = Image.open(os.path.join("frames", file_name)) +angle = 0.0 +angle_step = 360 / len(os.listdir(frames_dir)) +for file_name in sorted(os.listdir(frames_dir)): + image = Image.open(os.path.join(frames_dir, file_name)) image = image.rotate(angle) frame = create_frame_from_rgba(np.array(image), image.width, image.height) apngasm.add_frame(frame) angle += angle_step -success = apngasm.assemble("output/elephant-spinning-pillow.apng") +out = os.path.join(output_dir, "elephant-spinning-pillow.apng") +success = apngasm.assemble(out) print(f"{success = }") # Assemble palette and grey PNGs apngasm.reset() -apngasm.add_frame_from_file("input/palette.png", 100, 1000) -apngasm.add_frame_from_file("input/grey.png", 100, 1000) +apngasm.add_frame_from_file(palette_png_path, 100, 1000) +apngasm.add_frame_from_file(grey_png_path, 100, 1000) frame0 = apngasm.get_frames()[0] frame_info(frame0) @@ -107,35 +119,37 @@ def frame_info(frame): frame1 = apngasm.get_frames()[1] frame_info(frame1) -success = apngasm.assemble("output/birds.apng") +out = os.path.join(output_dir, "birds.apng") +success = apngasm.assemble(out) print(f"{success = }") del apngasm # Assemble palette and grey PNGs, but with Pillow -image0 = Image.open("input/grey.png").convert("RGB") +image0 = Image.open(grey_png_path).convert("RGB") frame0 = create_frame_from_rgb(np.array(image0), image0.width, image0.height, 1, 1) frame_info(frame0) -image1 = Image.open("input/grey.png").convert("RGB") +image1 = Image.open(grey_png_path).convert("RGB") frame1 = create_frame_from_rgb_trns( np.array(image1), image0.width, image0.height, np.array([255, 255, 255]), 1, 1 ) frame_info(frame1) # You may even set the variables manually -image2 = Image.open("input/palette.png").convert("RGBA") +image2 = Image.open(palette_png_path).convert("RGBA") frame2 = APNGFrame() frame2.delay_num = 1 frame2.delay_den = 1 -frame2.color_type = color_type_dict[image2.mode] # type: ignore +frame2.color_type = color_type_dict[image2.mode] # type: ignore frame2.width = image2.width frame2.height = image2.height frame2.pixels = np.array(image2) frame_info(frame2) # Another way of creating APNGAsm object -apngasm = APNGAsm([frame0, frame1, frame2]) # type: ignore +apngasm = APNGAsm([frame0, frame1, frame2]) # type: ignore -success = apngasm.assemble("output/birds-pillow.apng") +out = os.path.join(output_dir, "birds-pillow.apng") +success = apngasm.assemble(out) print(f"{success = }") diff --git a/pyproject.toml b/pyproject.toml index aef0e92..57f0196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] # Project metadata name = "apngasm-python" -description = "A nanobind API for apngasm, a tool/library for APNG assembly/disassembly." +description = "A nanobind python API for apngasm, a tool/library for APNG assembly & disassembly with compression support." requires-python = ">=3.8" readme = "README.md" license = { "file" = "LICENSE" } @@ -15,13 +15,28 @@ Repository = "https://github.com/laggykiller/apngasm-python" Documentation = "https://apngasm-python.readthedocs.io/en/latest/" Tracker = "https://github.com/laggykiller/apngasm-python/issues" +[project.optional-dependencies] +full = [ + "Pillow", + "numpy", +] + +test = [ + "pytest", +] + +lint = [ + "ruff", + "mypy", + "isort", + "types-Pillow", +] + [build-system] # How pip and other frontends should build this project requires = [ - "py-build-cmake==0.2.0a7", - "nanobind~=1.8.0", - "nanobind-stubgen==0.1.3", - "conan>=2.0", - "wheel" + "py-build-cmake==0.2.0a12", + "nanobind>=2.0.0", + "conan>=2.0" ] build-backend = "py_build_cmake.build" @@ -32,18 +47,19 @@ directory = "src-python" [tool.py-build-cmake.sdist] # What to include in source distributions include = [ "CMakeLists.txt", + "PreLoad.cmake", "cmake/*.cmake", "src/*", "src-python/*", "scripts/*", "conanfile.py", - "apngasm/*" + "apngasm/*", + "lipo-dir-merge/*.py" ] [tool.py-build-cmake.cmake] # How to build the CMake project build_type = "Release" source_path = "." -options = { "WITH_PY_STUBS:BOOL" = "On" } args = ["-Wdev"] find_python3 = false find_python = true @@ -56,22 +72,29 @@ python_abi = 'abi3' abi3_minimum_cpython_version = 312 [tool.py-build-cmake.linux.cmake] # Linux-specific options -generator = "Ninja Multi-Config" config = "Release" env = { "CMAKE_PREFIX_PATH" = "${HOME}/.local" } [tool.py-build-cmake.mac.cmake] # macOS-specific options -generator = "Ninja Multi-Config" config = "Release" [tool.py-build-cmake.windows.cmake] # Windows-specific options config = "Release" -[tool.py-build-cmake.windows.cmake.options] -CMAKE_CXX_FLAGS_RELWITHDEBINFO = "/Zi /Ob0 /Od /RTC1" - -[tool.py-build-cmake.stubgen] -args = ["-v"] [tool.cibuildwheel] build-verbosity = 1 -environment = { PY_BUILD_CMAKE_VERBOSE="1" } \ No newline at end of file +environment = { PY_BUILD_CMAKE_VERBOSE="1" } + +[tool.pyright] +include = ["src-python", "scripts", "tests", "example"] +strict = ["*"] + +[tool.mypy] +python_version = "3.9" +files = ["src-python", "scripts", "tests", "example"] + +[tool.isort] +extend_skip = ["lipo-dir-merge"] + +[tool.ruff] +exclude = ["lipo-dir-merge"] \ No newline at end of file diff --git a/example/frames/elephant-001.png b/samples/frames/elephant-001.png similarity index 100% rename from example/frames/elephant-001.png rename to samples/frames/elephant-001.png diff --git a/example/frames/elephant-002.png b/samples/frames/elephant-002.png similarity index 100% rename from example/frames/elephant-002.png rename to samples/frames/elephant-002.png diff --git a/example/frames/elephant-003.png b/samples/frames/elephant-003.png similarity index 100% rename from example/frames/elephant-003.png rename to samples/frames/elephant-003.png diff --git a/example/frames/elephant-004.png b/samples/frames/elephant-004.png similarity index 100% rename from example/frames/elephant-004.png rename to samples/frames/elephant-004.png diff --git a/example/frames/elephant-005.png b/samples/frames/elephant-005.png similarity index 100% rename from example/frames/elephant-005.png rename to samples/frames/elephant-005.png diff --git a/example/frames/elephant-006.png b/samples/frames/elephant-006.png similarity index 100% rename from example/frames/elephant-006.png rename to samples/frames/elephant-006.png diff --git a/example/frames/elephant-007.png b/samples/frames/elephant-007.png similarity index 100% rename from example/frames/elephant-007.png rename to samples/frames/elephant-007.png diff --git a/example/frames/elephant-008.png b/samples/frames/elephant-008.png similarity index 100% rename from example/frames/elephant-008.png rename to samples/frames/elephant-008.png diff --git a/example/frames/elephant-009.png b/samples/frames/elephant-009.png similarity index 100% rename from example/frames/elephant-009.png rename to samples/frames/elephant-009.png diff --git a/example/frames/elephant-010.png b/samples/frames/elephant-010.png similarity index 100% rename from example/frames/elephant-010.png rename to samples/frames/elephant-010.png diff --git a/example/frames/elephant-011.png b/samples/frames/elephant-011.png similarity index 100% rename from example/frames/elephant-011.png rename to samples/frames/elephant-011.png diff --git a/example/frames/elephant-012.png b/samples/frames/elephant-012.png similarity index 100% rename from example/frames/elephant-012.png rename to samples/frames/elephant-012.png diff --git a/example/frames/elephant-013.png b/samples/frames/elephant-013.png similarity index 100% rename from example/frames/elephant-013.png rename to samples/frames/elephant-013.png diff --git a/example/frames/elephant-014.png b/samples/frames/elephant-014.png similarity index 100% rename from example/frames/elephant-014.png rename to samples/frames/elephant-014.png diff --git a/example/frames/elephant-015.png b/samples/frames/elephant-015.png similarity index 100% rename from example/frames/elephant-015.png rename to samples/frames/elephant-015.png diff --git a/example/frames/elephant-016.png b/samples/frames/elephant-016.png similarity index 100% rename from example/frames/elephant-016.png rename to samples/frames/elephant-016.png diff --git a/example/frames/elephant-017.png b/samples/frames/elephant-017.png similarity index 100% rename from example/frames/elephant-017.png rename to samples/frames/elephant-017.png diff --git a/example/frames/elephant-018.png b/samples/frames/elephant-018.png similarity index 100% rename from example/frames/elephant-018.png rename to samples/frames/elephant-018.png diff --git a/example/frames/elephant-019.png b/samples/frames/elephant-019.png similarity index 100% rename from example/frames/elephant-019.png rename to samples/frames/elephant-019.png diff --git a/example/frames/elephant-020.png b/samples/frames/elephant-020.png similarity index 100% rename from example/frames/elephant-020.png rename to samples/frames/elephant-020.png diff --git a/example/frames/elephant-021.png b/samples/frames/elephant-021.png similarity index 100% rename from example/frames/elephant-021.png rename to samples/frames/elephant-021.png diff --git a/example/frames/elephant-022.png b/samples/frames/elephant-022.png similarity index 100% rename from example/frames/elephant-022.png rename to samples/frames/elephant-022.png diff --git a/example/frames/elephant-023.png b/samples/frames/elephant-023.png similarity index 100% rename from example/frames/elephant-023.png rename to samples/frames/elephant-023.png diff --git a/example/frames/elephant-024.png b/samples/frames/elephant-024.png similarity index 100% rename from example/frames/elephant-024.png rename to samples/frames/elephant-024.png diff --git a/example/frames/elephant-025.png b/samples/frames/elephant-025.png similarity index 100% rename from example/frames/elephant-025.png rename to samples/frames/elephant-025.png diff --git a/example/frames/elephant-026.png b/samples/frames/elephant-026.png similarity index 100% rename from example/frames/elephant-026.png rename to samples/frames/elephant-026.png diff --git a/example/frames/elephant-027.png b/samples/frames/elephant-027.png similarity index 100% rename from example/frames/elephant-027.png rename to samples/frames/elephant-027.png diff --git a/example/frames/elephant-028.png b/samples/frames/elephant-028.png similarity index 100% rename from example/frames/elephant-028.png rename to samples/frames/elephant-028.png diff --git a/example/frames/elephant-029.png b/samples/frames/elephant-029.png similarity index 100% rename from example/frames/elephant-029.png rename to samples/frames/elephant-029.png diff --git a/example/frames/elephant-030.png b/samples/frames/elephant-030.png similarity index 100% rename from example/frames/elephant-030.png rename to samples/frames/elephant-030.png diff --git a/example/frames/elephant-031.png b/samples/frames/elephant-031.png similarity index 100% rename from example/frames/elephant-031.png rename to samples/frames/elephant-031.png diff --git a/example/frames/elephant-032.png b/samples/frames/elephant-032.png similarity index 100% rename from example/frames/elephant-032.png rename to samples/frames/elephant-032.png diff --git a/example/frames/elephant-033.png b/samples/frames/elephant-033.png similarity index 100% rename from example/frames/elephant-033.png rename to samples/frames/elephant-033.png diff --git a/example/frames/elephant-034.png b/samples/frames/elephant-034.png similarity index 100% rename from example/frames/elephant-034.png rename to samples/frames/elephant-034.png diff --git a/example/input/grey.png b/samples/input/0.png similarity index 100% rename from example/input/grey.png rename to samples/input/0.png diff --git a/example/input/palette.png b/samples/input/1.png similarity index 100% rename from example/input/palette.png rename to samples/input/1.png diff --git a/samples/input/animation_spec.json b/samples/input/animation_spec.json new file mode 100644 index 0000000..a5bdb93 --- /dev/null +++ b/samples/input/animation_spec.json @@ -0,0 +1,12 @@ +{ + "loops": "0", + "skip_first": "false", + "frames": [ + { + "0.png": "100\/1000" + }, + { + "1.png": "100\/1000" + } + ] +} diff --git a/samples/input/animation_spec.xml b/samples/input/animation_spec.xml new file mode 100644 index 0000000..79621a9 --- /dev/null +++ b/samples/input/animation_spec.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/example/input/ball.apng b/samples/input/ball.apng similarity index 100% rename from example/input/ball.apng rename to samples/input/ball.apng diff --git a/samples/input/grey.png b/samples/input/grey.png new file mode 100644 index 0000000..212cea1 Binary files /dev/null and b/samples/input/grey.png differ diff --git a/samples/input/palette.png b/samples/input/palette.png new file mode 100644 index 0000000..b6555f6 Binary files /dev/null and b/samples/input/palette.png differ diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/get_arch.py b/scripts/get_arch.py index 0121c7c..2438678 100755 --- a/scripts/get_arch.py +++ b/scripts/get_arch.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import os -import sys import platform +import sys conan_archs = { "x86_64": ["amd64", "x86_64", "x64"], @@ -12,29 +12,33 @@ } -def get_arch(): - arch = None - if os.getenv("APNGASM_COMPILE_TARGET"): - arch = os.getenv("APNGASM_COMPILE_TARGET") +def get_native_arch() -> str: + for k, v in conan_archs.items(): + if platform.machine().lower() in v: + return k + + # Failover + return platform.machine().lower() + + +def get_arch() -> str: + arch_env = os.getenv("APNGASM_COMPILE_TARGET") + if isinstance(arch_env, str): + arch = arch_env else: - for k, v in conan_archs.items(): - if platform.machine().lower() in v: - arch = k - break + arch = get_native_arch() + + if arch == "universal2": + arch = "universal2_" + get_native_arch() - if arch == None: - arch = platform.machine().lower() + assert arch in ("universal2_x86_64", "universal2_armv8") return arch def main(): arch = get_arch() - compile_target = os.getenv("APNGASM_COMPILE_TARGET") - if compile_target: - sys.stdout.write(compile_target) - else: - sys.stdout.write(arch) + sys.stdout.write(arch) if __name__ == "__main__": diff --git a/scripts/get_deps.py b/scripts/get_deps.py index a5000f5..ebd31f7 100755 --- a/scripts/get_deps.py +++ b/scripts/get_deps.py @@ -1,30 +1,35 @@ #!/usr/bin/env python3 -import platform import os -import sys -import subprocess import platform import shutil +import subprocess +import sys -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -sys.path.append(os.path.dirname(SCRIPT_DIR)) - -from scripts.get_arch import conan_archs, get_arch +from get_arch import conan_archs, get_arch # type: ignore -def install_deps(arch): +def install_deps(arch: str): # Use Conan to install dependencies - settings = [] + settings: list[str] = [] + build: list[str] = [] + options: list[str] = [] if platform.system() == "Windows": settings.append("os=Windows") - settings.append("compiler.runtime=static") + if sys.platform.startswith(("cygwin", "msys")): + # Need python headers and libraries, but msvc not able to find them + # If inside cygwin or msys. + settings.append("compiler=gcc") + settings.append("compiler.version=10") + settings.append("compiler.libcxx=libstdc++") + else: + settings.append("compiler.runtime=static") elif platform.system() == "Darwin": settings.append("os=Macos") - if arch == "x86_64": - settings.append("os.version=10.15") - else: + if arch == "armv8": settings.append("os.version=11.0") + else: + settings.append("os.version=10.9") settings.append("compiler=apple-clang") settings.append("compiler.libcxx=libc++") elif platform.system() == "Linux": @@ -35,7 +40,40 @@ def install_deps(arch): if arch: settings.append("arch=" + arch) - build = [] + options.append("boost/*:without_atomic=False") # Depedency for filesystem + options.append("boost/*:without_chrono=True") + options.append("boost/*:without_cobalt=True") + options.append("boost/*:without_container=True") + options.append("boost/*:without_context=True") + options.append("boost/*:without_contract=True") + options.append("boost/*:without_coroutine=True") + options.append("boost/*:without_date_time=True") + options.append("boost/*:without_exception=True") + options.append("boost/*:without_fiber=True") + options.append("boost/*:without_filesystem=False") # Required by osx 10.9 fork + options.append("boost/*:without_graph=True") + options.append("boost/*:without_graph_parallel=True") + options.append("boost/*:without_iostreams=True") + options.append("boost/*:without_json=True") + options.append("boost/*:without_locale=True") + options.append("boost/*:without_log=True") + options.append("boost/*:without_math=True") + options.append("boost/*:without_mpi=True") + options.append("boost/*:without_nowide=True") + options.append("boost/*:without_program_options=False") + options.append("boost/*:without_python=True") + options.append("boost/*:without_random=True") + options.append("boost/*:without_regex=False") + options.append("boost/*:without_serialization=True") + options.append("boost/*:without_stacktrace=True") + options.append("boost/*:without_system=False") + options.append("boost/*:without_test=True") + options.append("boost/*:without_thread=True") + options.append("boost/*:without_timer=True") + options.append("boost/*:without_type_erasure=True") + options.append("boost/*:without_url=True") + options.append("boost/*:without_wave=True") + if platform.system() == "Linux": # Need to compile dependencies if Linux build.append("*") @@ -51,8 +89,9 @@ def install_deps(arch): print("conan cli settings:") print("settings: " + str(settings)) print("build: " + str(build)) + print("options: " + str(options)) - subprocess.run(["conan", "profile", "detect"]) + subprocess.run(["conan", "profile", "detect", "-f"]) conan_output = os.path.join("conan_output", arch) @@ -62,6 +101,7 @@ def install_deps(arch): "install", *[x for s in settings for x in ("-s", s)], *[x for b in build for x in ("-b", b)], + *[x for o in options for x in ("-o", o)], "-of", conan_output, "--deployer=direct_deploy", @@ -72,37 +112,57 @@ def install_deps(arch): return conan_output +def patch_conan_toolchain_universal2(lipo_dir_merge_src: str): + conan_toolchain_path = os.path.join(lipo_dir_merge_src, "conan_toolchain.cmake") + + result = "" + with open(conan_toolchain_path) as f: + for line in f: + if line.startswith("set(CMAKE_OSX_ARCHITECTURES"): + result += "# " + line + else: + result += line + + with open(conan_toolchain_path, "w+") as f: + f.write(result) + + def main(): arch = get_arch() - if arch == "universal2": - conan_output = "conan_output/x86_64" - else: - conan_output = "conan_output/" + arch - if os.path.isdir(conan_output): - print("Dependencies found at:" + conan_output) - print("Skip conan install...") - return - - if arch != "universal2": - conan_output = install_deps(arch) + + if not arch.startswith("universal2"): + install_deps(arch) else: # Repeat to install the other architecture version of libwebp conan_output_x64 = install_deps("x86_64") conan_output_arm = install_deps("armv8") - conan_output_universal2 = conan_output_arm.replace("armv8", "universal2") - shutil.rmtree(conan_output_universal2, ignore_errors=True) + + if arch.endswith("x86_64"): + lipo_dir_merge_src = conan_output_x64 + lipo_dir_merge_dst = conan_output_arm + elif arch.endswith("armv8"): + lipo_dir_merge_src = conan_output_arm + lipo_dir_merge_dst = conan_output_x64 + else: + raise RuntimeError("Invalid arch: " + arch) + + lipo_dir_merge_result = conan_output_arm.replace("armv8", "universal2") + shutil.rmtree(lipo_dir_merge_result, ignore_errors=True) + subprocess.run( [ "python3", "lipo-dir-merge/lipo-dir-merge.py", - conan_output_x64, - conan_output_arm, - conan_output_universal2, + lipo_dir_merge_src, + lipo_dir_merge_dst, + lipo_dir_merge_result, ] ) - shutil.rmtree(conan_output_x64) - shutil.move(conan_output_universal2, conan_output_x64) + shutil.rmtree(lipo_dir_merge_src) + shutil.move(lipo_dir_merge_result, lipo_dir_merge_src) + + patch_conan_toolchain_universal2(lipo_dir_merge_src) if __name__ == "__main__": diff --git a/scripts/patch_stub.py b/scripts/patch_stub.py deleted file mode 100755 index be93867..0000000 --- a/scripts/patch_stub.py +++ /dev/null @@ -1,24 +0,0 @@ -#!/usr/bin/env python3 -import sys - - -def main(): - init_pyi_path = sys.argv[1] - with open(init_pyi_path) as f: - init_pyi = f.read() - - with open(init_pyi_path, "w+") as f: - init_pyi = init_pyi.replace("List[", "list[") - if "from __future__ import annotations" not in init_pyi: - f.write("from __future__ import annotations\n") - if "import numpy.typing" not in init_pyi: - f.write("import numpy.typing\n") - if "from . import _apngasm_python" not in init_pyi: - init_pyi = init_pyi.replace( - "import _apngasm_python", "from . import _apngasm_python" - ) - f.write(init_pyi) - - -if __name__ == "__main__": - main() diff --git a/scripts/update_stub.py b/scripts/update_stub.py deleted file mode 100755 index ef249ff..0000000 --- a/scripts/update_stub.py +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env python3 -import os -from pathlib import Path -import shutil -import zipfile - - -def main(): - py_bin = shutil.which("python3") - if not py_bin: - py_bin = shutil.which("python") - if not py_bin: - raise RuntimeError("Cannot find path for python") - - proj_dir = Path(Path(__file__).parent, "../").resolve() - dist_dir = Path(proj_dir, "dist") - - shutil.rmtree(dist_dir, ignore_errors=True) - os.chdir(proj_dir) - os.system(py_bin + " -m build .") - - for zip_file in os.listdir(dist_dir): - if os.path.splitext(zip_file)[1] != ".whl": - continue - zip_path = os.path.join(dist_dir, zip_file) - with zipfile.ZipFile(zip_path, mode="r") as archive: - for file in archive.namelist(): - if os.path.splitext(file)[1] != ".pyi": - continue - dest_path = os.path.join("src-python", file) - dest_dir = os.path.split(dest_path)[0] - if not os.path.isdir(dest_dir): - os.makedirs(dest_dir) - with open(dest_path, "wb+") as f: - f.write(archive.read(file)) - - -if __name__ == "__main__": - main() diff --git a/src-python/apngasm_python/__init__.py b/src-python/apngasm_python/__init__.py index 56fca9e..5b8043f 100755 --- a/src-python/apngasm_python/__init__.py +++ b/src-python/apngasm_python/__init__.py @@ -1,3 +1,4 @@ #!/usr/bin/env python3 """apngasm-python""" -__version__ = "1.2.3" + +__version__ = "1.3.0" diff --git a/src-python/apngasm_python/__init__.pyi b/src-python/apngasm_python/__init__.pyi deleted file mode 100644 index bda5b5a..0000000 --- a/src-python/apngasm_python/__init__.pyi +++ /dev/null @@ -1 +0,0 @@ -__version__: str diff --git a/src-python/apngasm_python/_apngasm_python/__init__.pyi b/src-python/apngasm_python/_apngasm_python.pyi similarity index 66% rename from src-python/apngasm_python/_apngasm_python/__init__.pyi rename to src-python/apngasm_python/_apngasm_python.pyi index c711beb..b03b6b2 100644 --- a/src-python/apngasm_python/_apngasm_python/__init__.pyi +++ b/src-python/apngasm_python/_apngasm_python.pyi @@ -1,55 +1,48 @@ -from __future__ import annotations -import numpy.typing -from typing import Any, Optional, overload, Typing, Sequence -from enum import Enum -from . import _apngasm_python +from collections.abc import Sequence +from typing import Annotated, Optional, overload + +from numpy.typing import ArrayLike + class APNGAsm: - """ - Class representing APNG file, storing APNGFrame(s) and other metadata. - """ + """Class representing APNG file, storing APNGFrame(s) and other metadata.""" - def __init__(self, frames: list[_apngasm_python.APNGFrame]) -> None: - """ - Construct APNGAsm object from an existing vector of apngasm frames. - - :param list[apngasm_python._apngasm_python.APNGFrame] frames: A list of APNGFrame objects. - """ - ... - @overload def __init__(self) -> None: + """Construct an empty APNGAsm object.""" + + @overload + def __init__(self, frames: Sequence[APNGFrame]) -> None: """ - Construct an empty APNGAsm object. + Construct APNGAsm object from an existing vector of apngasm frames. + + :param list[apngasm_python._apngasm_python.APNGFrame] frames: A list of APNGFrame objects. """ - ... - - def add_frame(self, frame: _apngasm_python.APNGFrame) -> int: + + def add_frame(self, frame: APNGFrame) -> int: """ Adds an APNGFrame object to the frame vector. - + :param frame: The APNGFrame object to be added. :type frame: apngasm_python._apngasm_python.APNGFrame - + :return: The new number of frames/the number of this frame on the frame vector. :rtype: int """ - ... - + def add_frame_from_file(self, file_path: str, delay_num: int = 100, delay_den: int = 1000) -> int: """ Adds a frame from a PNG file or frames from a APNG file to the frame vector. - + :param str file_path: The relative or absolute path to an image file. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). - + :return: The new number of frames/the number of this frame on the frame vector. :rtype: int """ - ... - - def add_frame_from_rgb(self, pixels_rgb: _apngasm_python.rgb, width: int, height: int, trns_color: _apngasm_python.rgb = 0, delay_num: int = 100, delay_den: int = 1000) -> int: + + def add_frame_from_rgb(self, pixels_rgb: rgb, width: int, height: int, trns_color: Optional[rgb], delay_num: int = 100, delay_den: int = 1000) -> int: """ Adds an APNGFrame object to the vector. Not possible to use in Python. As alternative, @@ -57,20 +50,19 @@ class APNGAsm: First create an empty APNGFrame with frame = APNGFrame(), then set frame.width, frame.height, frame.color_type, frame.pixels, frame.palette, frame.delay_num, frame.delay_den manually. - + :param apngasm_python._apngasm_python.rgb pixels_rgb: The RGB pixel data. :param int width: The width of the pixel data. :param int height: The height of the pixel data. :param apngasm_python._apngasm_python.rgb trns_color: The color [r, g, b] to be treated as transparent. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). - + :return: The new number of frames/the number of this frame on the frame vector. :rtype: int """ - ... - - def add_frame_from_rgba(self, pixels_rgba: _apngasm_python.rgba, width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> int: + + def add_frame_from_rgba(self, pixels_rgba: rgba, width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> int: """ Adds an APNGFrame object to the vector. Not possible to use in Python. As alternative, @@ -78,211 +70,172 @@ class APNGAsm: First create an empty APNGFrame with frame = APNGFrame(), then set frame.width, frame.height, frame.color_type, frame.pixels, frame.palette, frame.delay_num, frame.delay_den manually. - + :param apngasm_python._apngasm_python.rgba pixels_rgba: The RGBA pixel data. :param int width: The width of the pixel data. :param int height: The height of the pixel data. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). - + :return: The new number of frames/the number of this frame on the frame vector. :rtype: int """ - ... - + def assemble(self, output_path: str) -> bool: """ Assembles and outputs an APNG file. - + :param str output_path: The output file path. - + :return: true if assemble completed succesfully. :rtype: bool """ - ... - - def disassemble(self, file_path: str) -> list[_apngasm_python.APNGFrame]: + + def disassemble(self, file_path: str) -> list[APNGFrame]: """ Disassembles an APNG file. - + :param str file_path: The file path to the PNG image to be disassembled. - + :return: A vector containing the frames of the disassembled PNG. :rtype: list[apngasm_python._apngasm_python.APNGFrame] """ - ... - - def frame_count(self) -> int: - """ - Returns the number of frames. - - :return: number of frames. - :rtype: int - """ - ... - - def get_frames(self) -> list[_apngasm_python.APNGFrame]: - """ - Returns the frame vector. - - :return: frame vector. - :rtype: list[apngasm_python._apngasm_python.APNGFrame] - """ - ... - - def get_loops(self) -> int: - """ - Returns the loop count. - - :return: loop count. - :rtype: int - """ - ... - - def is_skip_first(self) -> bool: + + def save_pngs(self, output_dir: str) -> bool: """ - Returns the flag of skip first frame. - - :return: flag of skip first frame. + Saves individual PNG files of the frames in the frame vector. + + :param str output_dir: The directory where the PNG fils will be saved. + + :return: true if all files were saved successfully. :rtype: bool """ - ... - - def load_animation_spec(self, file_path: str) -> list[_apngasm_python.APNGFrame]: + + def load_animation_spec(self, file_path: str) -> list[APNGFrame]: """ Loads an animation spec from JSON or XML. Loaded frames are added to the end of the frame vector. For more details on animation specs see: https://github.com/Genshin/PhantomStandards - + :param str file_path: The path of JSON or XML file. - + :return: A vector containing the frames :rtype: list[apngasm_python._apngasm_python.APNGFrame] """ - ... - - def reset(self) -> int: - """ - Destroy all frames in memory/dispose of the frame vector. - Leaves the apngasm object in a clean state. - Returns number of frames disposed of. - - :return: number of frames disposed of. - :rtype: int - """ - ... - + def save_json(self, output_path: str, image_dir: str) -> bool: """ Saves a JSON animation spec file. - + :param str output_path: Path to save the file to. :param str image_dir: Directory where frame files are to be saved if not the same path as the animation spec. - + :return: true if save was successful. :rtype: bool """ - ... - - def save_pngs(self, output_dir: str) -> bool: - """ - Saves individual PNG files of the frames in the frame vector. - - :param str output_dir: The directory where the PNG fils will be saved. - - :return: true if all files were saved successfully. - :rtype: bool - """ - ... - + def save_xml(self, output_path: str, image_dir: str) -> bool: """ Saves an XML animation spec file. - + :param str file_path: Path to save the file to. :param str image_dir: Directory where frame files are to be saved if not the same path as the animation spec. - + :return: true if save was successful. :rtype: bool """ - ... - - def set_apngasm_listener(self, listener: Optional[_apngasm_python.IAPNGAsmListener] = None) -> None: + + def set_apngasm_listener(self, listener: Optional[IAPNGAsmListener] = None) -> None: """ Sets a listener. - + :param Optional[apngasm_python._apngasm_python.IAPNGAsmListener] listener: A pointer to the listener object. If the argument is NULL a default APNGAsmListener will be created and assigned. """ - ... - + def set_loops(self, loops: int = 0) -> None: """ Set loop count of animation. - + :param int loops: Loop count of animation. If the argument is 0 a loop count is infinity. """ - ... - + def set_skip_first(self, skip_first: bool) -> None: """ Set flag of skip first frame. - + :param int skip_first: Flag of skip first frame. """ - ... - + + def get_frames(self) -> list[APNGFrame]: + """ + Returns the frame vector. + + :return: frame vector. + :rtype: list[apngasm_python._apngasm_python.APNGFrame] + """ + + def get_loops(self) -> int: + """ + Returns the loop count. + + :return: loop count. + :rtype: int + """ + + def is_skip_first(self) -> bool: + """ + Returns the flag of skip first frame. + + :return: flag of skip first frame. + :rtype: bool + """ + + def frame_count(self) -> int: + """ + Returns the number of frames. + + :return: number of frames. + :rtype: int + """ + + def reset(self) -> int: + """ + Destroy all frames in memory/dispose of the frame vector. + Leaves the apngasm object in a clean state. + Returns number of frames disposed of. + + :return: number of frames disposed of. + :rtype: int + """ + def version(self) -> str: """ Returns the version of APNGAsm. - + :return: the version of APNGAsm. :rtype: str """ - ... - + class APNGFrame: - """ - Class representing a frame in APNG. - """ + """Class representing a frame in APNG.""" - def __init__(self, pixels: _apngasm_python.rgba, width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> None: - """ - Creates an APNGFrame from a bitmapped array of RBGA pixel data. - Not possible to use in Python. To create APNGFrame from pixel data in memory, - Use create_frame_from_rgb() or create_frame_from_rgba(). Or manually, - First create an empty APNGFrame with frame = APNGFrame(), - then set frame.width, frame.height, frame.color_type, frame.pixels, - frame.palette, frame.delay_num, frame.delay_den manually. - - :param apngasm_python._apngasm_python.rgba pixels: The RGBA pixel data. - :param int width: The width of the pixel data. - :param int height: The height of the pixel data. - :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). - :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). - """ - ... - @overload def __init__(self) -> None: - """ - Creates an empty APNGFrame. - """ - ... - + """Creates an empty APNGFrame.""" + @overload def __init__(self, file_path: str, delay_num: int = 100, delay_den: int = 1000) -> None: """ Creates an APNGFrame from a PNG file. - + :param str file_path: The relative or absolute path to an image file. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). """ - ... - + @overload - def __init__(self, pixels: _apngasm_python.rgb, width: int, height: int, trns_color: _apngasm_python.rgb, delay_num: int = 100, delay_den: int = 1000) -> None: + def __init__(self, pixels: rgb, width: int, height: int, trns_color: rgb, delay_num: int = 100, delay_den: int = 1000) -> None: """ Creates an APNGFrame from a bitmapped array of RBG pixel data. Not possible to use in Python. To create APNGFrame from pixel data in memory, @@ -290,7 +243,7 @@ class APNGFrame: First create an empty APNGFrame with frame = APNGFrame(), then set frame.width, frame.height, frame.color_type, frame.pixels, frame.palette, frame.delay_num, frame.delay_den manually. - + :param apngasm_python._apngasm_python.rgb pixels: The RGB pixel data. :param int width: The width of the pixel data. :param int height: The height of the pixel data. @@ -298,308 +251,232 @@ class APNGFrame: :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). """ - ... - - @property - def color_type(self) -> int: + + @overload + def __init__(self, pixels: rgba, width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> None: """ - The color_type of the frame. - - 0: Grayscale (Pillow mode='L') - 2: RGB (Pillow mode='RGB') - 3: Palette (Pillow mode='P') - 4: Grayscale + Alpha (Pillow mode='LA') - 6: RGBA (Pillow mode='RGBA') + Creates an APNGFrame from a bitmapped array of RBGA pixel data. + Not possible to use in Python. To create APNGFrame from pixel data in memory, + Use create_frame_from_rgb() or create_frame_from_rgba(). Or manually, + First create an empty APNGFrame with frame = APNGFrame(), + then set frame.width, frame.height, frame.color_type, frame.pixels, + frame.palette, frame.delay_num, frame.delay_den manually. + + :param apngasm_python._apngasm_python.rgba pixels: The RGBA pixel data. + :param int width: The width of the pixel data. + :param int height: The height of the pixel data. + :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). + :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). """ - ... - @color_type.setter - def color_type(self, arg: int, /) -> None: + + def save(self, out_path: str) -> bool: """ - The color_type of the frame. - - 0: Grayscale (Pillow mode='L') - 2: RGB (Pillow mode='RGB') - 3: Palette (Pillow mode='P') - 4: Grayscale + Alpha (Pillow mode='LA') - 6: RGBA (Pillow mode='RGBA') + Saves this frame as a single PNG file. + + :param str out_path: The relative or absolute path to save the image file to. + + :return: true if save was successful. + :rtype: bool """ - ... - + @property - def delay_den(self) -> int: + def pixels(self) -> Annotated[ArrayLike, dict(dtype='uint8', shape=(None, None, None))]: """ - The denominator of the duration of frame. Duration of time is delay_num / delay_den seconds. - """ - ... - @delay_den.setter - def delay_den(self, arg: int, /) -> None: - """ - The denominator of the duration of frame. Duration of time is delay_num / delay_den seconds. + The raw pixel data of frame, expressed as a 3D numpy array in Python. + Note that setting this value will also set the variable 'rows' internally. + This should be set AFTER you set the width, height and color_type. """ - ... - + + @pixels.setter + def pixels(self, arg: Annotated[ArrayLike, dict(dtype='uint8', shape=(None, None, None))], /) -> None: ... + @property - def delay_num(self) -> int: - """ - The nominator of the duration of frame. Duration of time is delay_num / delay_den seconds. - """ - ... - @delay_num.setter - def delay_num(self, arg: int, /) -> None: - """ - The nominator of the duration of frame. Duration of time is delay_num / delay_den seconds. - """ - ... - + def width(self) -> int: + """The width of frame.""" + + @width.setter + def width(self, arg: int, /) -> None: ... + @property def height(self) -> int: - """ - The height of frame. - """ - ... + """The height of frame.""" + @height.setter - def height(self, arg: int, /) -> None: + def height(self, arg: int, /) -> None: ... + + @property + def color_type(self) -> int: """ - The height of frame. + The color_type of the frame. + + 0: Grayscale (Pillow mode='L') + 2: RGB (Pillow mode='RGB') + 3: Palette (Pillow mode='P') + 4: Grayscale + Alpha (Pillow mode='LA') + 6: RGBA (Pillow mode='RGBA') """ - ... - + + @color_type.setter + def color_type(self, arg: int, /) -> None: ... + @property - def palette(self) -> numpy.typing.NDArray: + def palette(self) -> Annotated[ArrayLike, dict(dtype='uint8', shape=(256, 3))]: """ The palette data of frame. Only applies to 'P' mode Image (i.e. Not RGB, RGBA). Expressed as 2D numpy array in format of [[r0, g0, b0], [r1, g1, b1], ..., [r255, g255, b255]] in Python. """ - ... + @palette.setter - def palette(self, arg: numpy.typing.NDArray, /) -> None: - """ - The palette data of frame. Only applies to 'P' mode Image (i.e. Not RGB, RGBA). - Expressed as 2D numpy array in format of [[r0, g0, b0], [r1, g1, b1], ..., [r255, g255, b255]] in Python. - """ - ... - - @property - def palette_size(self) -> int: - """ - The palette data size of frame. - """ - ... - @palette_size.setter - def palette_size(self, arg: int, /) -> None: - """ - The palette data size of frame. - """ - ... - - @property - def pixels(self) -> numpy.typing.NDArray: - """ - The raw pixel data of frame, expressed as a 3D numpy array in Python. - Note that setting this value will also set the variable 'rows' internally. - This should be set AFTER you set the width, height and color_type. - """ - ... - @pixels.setter - def pixels(self, arg: numpy.typing.NDArray, /) -> None: - """ - The raw pixel data of frame, expressed as a 3D numpy array in Python. - Note that setting this value will also set the variable 'rows' internally. - This should be set AFTER you set the width, height and color_type. - """ - ... - - def save(self, out_path: str) -> bool: - """ - Saves this frame as a single PNG file. - - :param str out_path: The relative or absolute path to save the image file to. - - :return: true if save was successful. - :rtype: bool - """ - ... - + def palette(self, arg: Annotated[ArrayLike, dict(dtype='uint8', shape=(256, 3))], /) -> None: ... + @property - def transparency(self) -> numpy.typing.NDArray: + def transparency(self) -> Annotated[ArrayLike, dict(dtype='uint8', shape=(None))]: """ The transparency color of frame that is treated as transparent, expressed as 1D numpy array. For more info, refer to 'tRNS Transparency' in http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html """ - ... + @transparency.setter - def transparency(self, arg: numpy.typing.NDArray, /) -> None: - """ - The transparency color of frame that is treated as transparent, expressed as 1D numpy array. - For more info, refer to 'tRNS Transparency' in http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html - """ - ... - + def transparency(self, arg: Annotated[ArrayLike, dict(dtype='uint8', shape=(None))], /) -> None: ... + + @property + def palette_size(self) -> int: + """The palette data size of frame.""" + + @palette_size.setter + def palette_size(self, arg: int, /) -> None: ... + @property def transparency_size(self) -> int: - """ - The transparency data size of frame. - """ - ... + """The transparency data size of frame.""" + @transparency_size.setter - def transparency_size(self, arg: int, /) -> None: - """ - The transparency data size of frame. - """ - ... - + def transparency_size(self, arg: int, /) -> None: ... + @property - def width(self) -> int: + def delay_num(self) -> int: """ - The width of frame. + The nominator of the duration of frame. Duration of time is delay_num / delay_den seconds. """ - ... - @width.setter - def width(self, arg: int, /) -> None: + + @delay_num.setter + def delay_num(self, arg: int, /) -> None: ... + + @property + def delay_den(self) -> int: """ - The width of frame. + The denominator of the duration of frame. Duration of time is delay_num / delay_den seconds. """ - ... - + + @delay_den.setter + def delay_den(self, arg: int, /) -> None: ... + class IAPNGAsmListener: - """ - Class for APNGAsmListener. Meant to be used internally. - """ + """Class for APNGAsmListener. Meant to be used internally.""" - def __init__(*args, **kwargs): - """ - Initialize self. See help(type(self)) for accurate signature. - """ - ... - -def create_frame_from_rgb(pixels: numpy.typing.NDArray, width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> _apngasm_python.APNGFrame: +def create_frame_from_rgb(pixels: Annotated[ArrayLike, dict(dtype='uint8', shape=(None, None, 3))], width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> APNGFrame: """ Creates an APNGFrame from a bitmapped array of RBG pixel data. - + :param numpy.typing.NDArray pixels: The RGB pixel data, expressed as 3D numpy array. :param int width: The width of the pixel data. :param int height: The height of the pixel data. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). - + :return: A APNGFrame object. :rtype: apngasm_python._apngasm_python.APNGFrame """ - ... -def create_frame_from_rgb_trns(pixels: numpy.typing.NDArray, width: int, height: int, trns_color: numpy.typing.NDArray, delay_num: int = 100, delay_den: int = 1000) -> _apngasm_python.APNGFrame: +def create_frame_from_rgb_trns(pixels: Annotated[ArrayLike, dict(dtype='uint8', shape=(None, None, 3))], width: int, height: int, trns_color: Annotated[ArrayLike, dict(dtype='uint8', shape=(3))], delay_num: int = 100, delay_den: int = 1000) -> APNGFrame: """ Creates an APNGFrame from a bitmapped array of RBG pixel data, with one color treated as transparent. - + :param numpy.typing.NDArray pixels: The RGB pixel data, expressed as 3D numpy array. :param int width: The width of the pixel data. :param int height: The height of the pixel data. :param numpy.typing.NDArray trns_color: The color [r, g, b] to be treated as transparent, expressed as 1D numpy array. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR). :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR). - + :return: A APNGFrame object. :rtype: apngasm_python._apngasm_python.APNGFrame """ - ... -def create_frame_from_rgba(pixels: numpy.typing.NDArray, width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> _apngasm_python.APNGFrame: +def create_frame_from_rgba(pixels: Annotated[ArrayLike, dict(dtype='uint8', shape=(None, None, 4))], width: int, height: int, delay_num: int = 100, delay_den: int = 1000) -> APNGFrame: """ Creates an APNGFrame from a bitmapped array of RBGA pixel data. - + :param numpy.typing.NDArray pixels: The RGBA pixel data, expressed as 3D numpy array. :param int width: The width of the pixel data. :param int height: The height of the pixel data. :param int delay_num: The delay numerator for this frame (defaults to DEFAULT_FRAME_NUMERATOR) :param int delay_den: The delay denominator for this frame (defaults to DEFAULT_FRAME_DENMINATOR) - + :return: A APNGFrame object. :rtype: apngasm_python._apngasm_python.APNGFrame """ - ... class rgb: - """ - Class for RGB object. Meant to be used internally. - """ + """Class for RGB object. Meant to be used internally.""" - def __init__(self, arg0: int, arg1: int, arg2: int, /) -> None: - """ - Create a RGB object. Meant to be used internally. - """ - ... - @overload def __init__(self) -> None: - """ - Create an empty RGB object. Meant to be used internally. - """ - ... - + """Create an empty RGB object. Meant to be used internally.""" + + @overload + def __init__(self, arg0: int, arg1: int, arg2: int, /) -> None: + """Create a RGB object. Meant to be used internally.""" + @property - def b(self) -> int: - ... - @b.setter - def b(self, arg: int, /) -> None: - ... - + def r(self) -> int: ... + + @r.setter + def r(self, arg: int, /) -> None: ... + @property - def g(self) -> int: - ... + def g(self) -> int: ... + @g.setter - def g(self, arg: int, /) -> None: - ... - + def g(self, arg: int, /) -> None: ... + @property - def r(self) -> int: - ... - @r.setter - def r(self, arg: int, /) -> None: - ... - + def b(self) -> int: ... + + @b.setter + def b(self, arg: int, /) -> None: ... + class rgba: - """ - Class for RGBA object. Meant to be used internally. - """ + """Class for RGBA object. Meant to be used internally.""" - def __init__(self, arg0: int, arg1: int, arg2: int, arg3: int, /) -> None: - """ - Create a RGBA object. Meant to be used internally. - """ - ... - @overload def __init__(self) -> None: - """ - Create an empty RGBA object. Meant to be used internally. - """ - ... - - @property - def a(self) -> int: - ... - @a.setter - def a(self, arg: int, /) -> None: - ... - + """Create an empty RGBA object. Meant to be used internally.""" + + @overload + def __init__(self, arg0: int, arg1: int, arg2: int, arg3: int, /) -> None: + """Create a RGBA object. Meant to be used internally.""" + @property - def b(self) -> int: - ... - @b.setter - def b(self, arg: int, /) -> None: - ... - + def r(self) -> int: ... + + @r.setter + def r(self, arg: int, /) -> None: ... + @property - def g(self) -> int: - ... + def g(self) -> int: ... + @g.setter - def g(self, arg: int, /) -> None: - ... - + def g(self, arg: int, /) -> None: ... + @property - def r(self) -> int: - ... - @r.setter - def r(self, arg: int, /) -> None: - ... - + def b(self) -> int: ... + + @b.setter + def b(self, arg: int, /) -> None: ... + + @property + def a(self) -> int: ... + + @a.setter + def a(self, arg: int, /) -> None: ... diff --git a/src-python/apngasm_python/apngasm.py b/src-python/apngasm_python/apngasm.py index 1f00a47..527d20d 100755 --- a/src-python/apngasm_python/apngasm.py +++ b/src-python/apngasm_python/apngasm.py @@ -1,31 +1,16 @@ #!/usr/bin/env python3 from __future__ import annotations -from ._apngasm_python import ( - APNGAsm, - APNGFrame, - IAPNGAsmListener, - create_frame_from_rgb, - create_frame_from_rgb_trns, - create_frame_from_rgba, -) -from ._apngasm_python import __version__ # type: ignore - -try: - import numpy - import numpy.typing - - NUMPY_LOADED = True -except ModuleNotFoundError: - NUMPY_LOADED = False - -try: - from PIL import Image - PILLOW_LOADED = True -except ModuleNotFoundError: - PILLOW_LOADED = False +from typing import TYPE_CHECKING, Any, Optional + +if TYPE_CHECKING: + from numpy.typing import NDArray + from PIL import Image -from typing import Optional +from ._apngasm_python import APNGFrame # type: ignore +from ._apngasm_python import (APNGAsm, IAPNGAsmListener, create_frame_from_rgb, + create_frame_from_rgb_trns, + create_frame_from_rgba) class APNGAsmBinder: @@ -42,58 +27,62 @@ def __init__(self): def __enter__(self): return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type, exc_val, exc_tb): # type: ignore self.apngasm.reset() - if PILLOW_LOADED: + def frame_pixels_as_pillow( + self, frame: int, new_value: Optional[Image.Image] = None + ) -> Optional[Image.Image]: + """ + Get/Set the raw pixel data of frame, expressed as a Pillow object. + This should be set AFTER you set the width, height and color_type. - def frame_pixels_as_pillow( - self, frame: int, new_value: Optional[Image.Image] = None - ) -> Optional[Image.Image]: - """ - Get/Set the raw pixel data of frame, expressed as a Pillow object. - This should be set AFTER you set the width, height and color_type. + :param int frame: Target frame number. + :param Optional[PIL.Image.Image] new_value: If set, then the raw pixel data of + frame is set with this value. - :param int frame: Target frame number. - :param Optional[PIL.Image.Image] new_value: If set, then the raw pixel data of frame - is set with this value. + :return: Pillow image object of the frame (get) or None (set) + :rtype: Optional[PIL.Image.Image] + """ + from numpy import array + from PIL import Image - :return: Pillow image object of the frame (get) or None (set) - :rtype: Optional[PIL.Image.Image] - """ - if new_value: - self.apngasm.get_frames()[frame].pixels = numpy.array(new_value) - else: - mode = self.color_type_dict[self.apngasm.get_frames()[frame].color_type] - return Image.frombytes( - mode, - ( - self.apngasm.get_frames()[frame].width, - self.apngasm.get_frames()[frame].height, - ), - self.apngasm.get_frames()[frame].pixels, - ) + if new_value: + self.apngasm.get_frames()[frame].pixels = array(new_value) + return None + else: + mode = self.color_type_dict[self.apngasm.get_frames()[frame].color_type] + return Image.frombytes( # type: ignore + mode, + ( + self.apngasm.get_frames()[frame].width, + self.apngasm.get_frames()[frame].height, + ), + self.apngasm.get_frames()[frame].pixels, + ) - if NUMPY_LOADED: + def frame_pixels_as_numpy( + self, frame: int, new_value: Optional[NDArray[Any]] = None + ) -> Optional[NDArray[Any]]: + """ + Get/Set the raw pixel data of frame, expressed as a 3D numpy array. + This should be set AFTER you set the width, height and color_type. - def frame_pixels_as_numpy( - self, frame: int, new_value: Optional[numpy.typing.NDArray] = None - ) -> Optional[numpy.typing.NDArray]: - """ - Get/Set the raw pixel data of frame, expressed as a 3D numpy array. - This should be set AFTER you set the width, height and color_type. + :param int frame: Target frame number. + :param Optional[numpy.typing.NDArray[Any]] new_value: If set, then the + raw pixel data of frame is set with this value. - :param int frame: Target frame number. - :param Optional[numpy.typing.NDArray] new_value: If set, then the raw pixel data of frame - is set with this value. + :return: 3D numpy array representation of + raw pixel data of frame (get) or None (set) + :rtype: Optional[numpy.typing.NDArray[Any]] + """ + from numpy import array - :return: 3D numpy array representation of raw pixel data of frame (get) or None (set) - :rtype: Optional[numpy.typing.NDArray] - """ - if new_value: - self.apngasm.get_frames()[frame].pixels = new_value - else: - return self.apngasm.get_frames()[frame].pixels + if new_value: + self.apngasm.get_frames()[frame].pixels = new_value + return None + else: + return array(self.apngasm.get_frames()[frame].pixels) def frame_width(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: """ @@ -108,6 +97,7 @@ def frame_width(self, frame: int, new_value: Optional[int] = None) -> Optional[i """ if new_value: self.apngasm.get_frames()[frame].width = new_value + return None else: return self.apngasm.get_frames()[frame].width @@ -126,6 +116,7 @@ def frame_height( """ if new_value: self.apngasm.get_frames()[frame].height = new_value + return None else: return self.apngasm.get_frames()[frame].height @@ -150,49 +141,59 @@ def frame_color_type( """ if new_value: self.apngasm.get_frames()[frame].color_type = new_value + return None else: return self.apngasm.get_frames()[frame].color_type - if NUMPY_LOADED: + def frame_palette( + self, frame: int, new_value: Optional[NDArray[Any]] = None + ) -> Optional[NDArray[Any]]: + """ + Get/Set the palette data of frame. + Only applies to 'P' mode Image (i.e. Not RGB, RGBA). + Expressed as 2D numpy array + in format of [[r0, g0, b0], [r1, g1, b1], ..., [r255, g255, b255]] + + :param int frame: Target frame number. + :param Optional[numpy.typing.NDArray[Any]] new_value: If set, then + the palette data of frame is set with this value. + + :return: 2D numpy array representation of + palette data of frame (get) or None (set) + :rtype: Optional[numpy.typing.NDArray[Any]] + """ + from numpy import array + + if new_value: + self.apngasm.get_frames()[frame].palette = new_value + return None + else: + return array(self.apngasm.get_frames()[frame].palette) - def frame_palette( - self, frame: int, new_value: Optional[numpy.typing.NDArray] = None - ) -> Optional[numpy.typing.NDArray]: - """ - Get/Set the palette data of frame. Only applies to 'P' mode Image (i.e. Not RGB, RGBA) - Expressed as 2D numpy array in format of [[r0, g0, b0], [r1, g1, b1], ..., [r255, g255, b255]] + def frame_transparency( + self, frame: int, new_value: Optional[NDArray[Any]] = None + ) -> Optional[NDArray[Any]]: + """ + Get/Set the color [r, g, b] to be treated as transparent in the frame, + expressed as 1D numpy array. + For more info, refer to 'tRNS Transparency' in + http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html - :param int frame: Target frame number. - :param Optional[numpy.typing.NDArray] new_value: If set, then the palette data of frame - is set with this value. + :param int frame: Target frame number. + :param Optional[numpy.typing.NDArray[Any]] new_value: If set, then the + transparency of frame is set with this value. - :return: 2D numpy array representation of palette data of frame (get) or None (set) - :rtype: Optional[numpy.typing.NDArray] - """ - if new_value: - self.apngasm.get_frames()[frame].palette = new_value - else: - return self.apngasm.get_frames()[frame].palette - - def frame_transparency( - self, frame: int, new_value: Optional[numpy.typing.NDArray] = None - ) -> Optional[numpy.typing.NDArray]: - """ - Get/Set the color [r, g, b] to be treated as transparent in the frame, expressed as 1D numpy array. - For more info, refer to 'tRNS Transparency' in - http://www.libpng.org/pub/png/spec/1.2/PNG-Chunks.html - - :param int frame: Target frame number. - :param Optional[numpy.typing.NDArray] new_value: If set, then the transparency of frame - is set with this value. - - :return: The color [r, g, b] to be treated as transparent in the frame (get) or None (set) - :rtype: Optional[numpy.typing.NDArray] - """ - if new_value: - self.apngasm.get_frames()[frame].transparency = new_value - else: - return self.apngasm.get_frames()[frame].transparency + :return: The color [r, g, b] to be treated as transparent + in the frame (get) or None (set) + :rtype: Optional[numpy.typing.NDArray[Any]] + """ + from numpy import array + + if new_value: + self.apngasm.get_frames()[frame].transparency = new_value + return None + else: + return array(self.apngasm.get_frames()[frame].transparency) def frame_palette_size( self, frame: int, new_value: Optional[int] = None @@ -209,6 +210,7 @@ def frame_palette_size( """ if new_value: self.apngasm.get_frames()[frame].palette_size = new_value + return None else: return self.apngasm.get_frames()[frame].palette_size @@ -227,6 +229,7 @@ def frame_transparency_size( """ if new_value: self.apngasm.get_frames()[frame].transparency_size = new_value + return None else: return self.apngasm.get_frames()[frame].transparency_size @@ -238,14 +241,15 @@ def frame_delay_num( Duration of time is delay_num / delay_den seconds. :param int frame: Target frame number. - :param Optional[int] new_value: If set, then the nominator of the duration of frame - is set with this value. + :param Optional[int] new_value: If set, then the nominator of the + duration of frame is set with this value. :return: Nominator of the duration of frame. :rtype: Optional[int] """ if new_value: self.apngasm.get_frames()[frame].delay_num = new_value + return None else: return self.apngasm.get_frames()[frame].delay_num @@ -257,14 +261,15 @@ def frame_delay_den( Duration of time is delay_num / delay_den seconds. :param int frame: Target frame number. - :param Optional[int] new_value: If set, then the denominator of the duration of frame - is set with this value. + :param Optional[int] new_value: If set, then the denominator of the + duration of frame is set with this value. :return: Denominator of the duration of frame. :rtype: Optional[int] """ if new_value: self.apngasm.get_frames()[frame].delay_den = new_value + return None else: return self.apngasm.get_frames()[frame].delay_den @@ -285,115 +290,120 @@ def add_frame_from_file( file_path=file_path, delay_num=delay_num, delay_den=delay_den ) - if PILLOW_LOADED: - - def add_frame_from_pillow( - self, pillow_image: Image.Image, delay_num: int = 100, delay_den: int = 1000 - ) -> int: - """ - Add a frame from Pillow image. - The frame duration is equal to delay_num / delay_den seconds. - Default frame duration is 100/1000 second, or 0.1 second. - - :param PIL.Image.Image pillow_image: Pillow image object. - :param int delay_num: The delay numerator for this frame (defaults to 100). - :param int delay_den: The delay denominator for this frame (defaults to 1000). - - :return: The new number of frames. - :rtype: int - """ - if pillow_image.mode not in ("RGB", "RGBA"): - pillow_image = pillow_image.convert("RGBA") - return self.add_frame_from_numpy( - numpy_data=numpy.array(pillow_image), - width=pillow_image.width, - height=pillow_image.height, - mode=pillow_image.mode, - delay_num=delay_num, - delay_den=delay_den, - ) + def add_frame_from_pillow( + self, pillow_image: Image.Image, delay_num: int = 100, delay_den: int = 1000 + ) -> int: + """ + Add a frame from Pillow image. + The frame duration is equal to delay_num / delay_den seconds. + Default frame duration is 100/1000 second, or 0.1 second. - if NUMPY_LOADED: - - def add_frame_from_numpy( - self, - numpy_data: numpy.typing.NDArray, - width: Optional[int] = None, - height: Optional[int] = None, - trns_color: Optional[numpy.typing.NDArray] = None, - mode: Optional[str] = None, - delay_num: int = 100, - delay_den: int = 1000, - ) -> int: - """ - Add frame from numpy array. - The frame duration is equal to delay_num / delay_den seconds. - Default frame duration is 100/1000 second, or 0.1 second. - - :param numpy.typing.NDArray numpy_data: The pixel data, expressed as 3D numpy array. - :param Optional[int] width: The width of the pixel data. - If not given, the 2nd dimension size of numpy_data is used. - :param Optional[int] height: The height of the pixel data. - If not given, the 1st dimension size of numpy_data is used. - :param Optional[str] mode: The color mode of data. Possible values are RGB or RGBA. - If not given, it is determined using the 3rd dimension size of numpy_data. - :param Optional[numpy.typing.NDArray] trns_color: The color [r, g, b] to be treated as transparent, expressed as 1D numpy array. - Only use if RGB mode. - :param int delay_num: The delay numerator for this frame (defaults to 100). - :param int delay_den: The delay denominator for this frame (defaults to 1000). - - :return: The new number of frames. - :rtype: int - """ - width = width if width else numpy.shape(numpy_data)[1] - height = height if height else numpy.shape(numpy_data)[0] - - if not mode: - if numpy.shape(numpy_data)[2] == 3: + :param PIL.Image.Image pillow_image: Pillow image object. + :param int delay_num: The delay numerator for this frame (defaults to 100). + :param int delay_den: The delay denominator for this frame (defaults to 1000). + + :return: The new number of frames. + :rtype: int + """ + from numpy import array + + if pillow_image.mode not in ("RGB", "RGBA"): + pillow_image = pillow_image.convert("RGBA") + return self.add_frame_from_numpy( + numpy_data=array(pillow_image), + width=pillow_image.width, + height=pillow_image.height, + mode=pillow_image.mode, + delay_num=delay_num, + delay_den=delay_den, + ) + + def add_frame_from_numpy( + self, + numpy_data: NDArray[Any], + width: Optional[int] = None, + height: Optional[int] = None, + trns_color: Optional[NDArray[Any]] = None, + mode: Optional[str] = None, + delay_num: int = 100, + delay_den: int = 1000, + ) -> int: + """ + Add frame from numpy array. + The frame duration is equal to delay_num / delay_den seconds. + Default frame duration is 100/1000 second, or 0.1 second. + + :param numpy.typing.NDArray[Any] numpy_data: The pixel data, expressed as + 3D numpy array. + :param Optional[int] width: The width of the pixel data. + If not given, the 2nd dimension size of numpy_data is used. + :param Optional[int] height: The height of the pixel data. + If not given, the 1st dimension size of numpy_data is used. + :param Optional[str] mode: The color mode of data. Possible values are + RGB or RGBA. If not given, it is determined using the 3rd dimension size + of numpy_data. + :param Optional[numpy.typing.NDArray[Any]] trns_color: The color [r, g, b] to + be treated as transparent, expressed as 1D numpy array. + Only use if RGB mode. + :param int delay_num: The delay numerator for this frame (defaults to 100). + :param int delay_den: The delay denominator for this frame (defaults to 1000). + + :return: The new number of frames. + :rtype: int + """ + from numpy import ndarray, shape + + width = width if width else shape(numpy_data)[1] + height = height if height else shape(numpy_data)[0] + + if not mode: + if len(shape(numpy_data)) == 3: + if shape(numpy_data)[2] == 3: mode = "RGB" - elif numpy.shape(numpy_data)[2] == 4: + elif shape(numpy_data)[2] == 4: mode = "RGBA" - else: - raise TypeError( - "Cannot determine mode from numpy_data. " - "expected 3rd dimension size to be 3 (RGB) or 4 (RGBA). " - f"The given numpy_data 3rd dimension size was {numpy.shape(numpy_data)[2]}." - ) - - if mode == "RGB": - if type(trns_color) == numpy.typing.NDArray: - frame = create_frame_from_rgb_trns( - pixels=numpy_data, - width=width, - height=height, - trns_color=trns_color, - delay_num=delay_num, - delay_den=delay_den, - ) - else: - frame = create_frame_from_rgb( - pixels=numpy_data, - width=width, - height=height, - delay_num=delay_num, - delay_den=delay_den, - ) - elif mode == "RGBA": - if type(trns_color) == numpy.typing.NDArray: - raise TypeError( - "Cannot set trns_color on RGBA mode Pillow object. Must be RGB." - ) - frame = create_frame_from_rgba( + else: + raise TypeError( + "Cannot determine mode from numpy_data. " + "expected 3rd dimension size to be 3 (RGB) or 4 (RGBA). " + "The given numpy_data shape was " + f"{shape(numpy_data)}." + ) + + if mode == "RGB": + if isinstance(trns_color, ndarray): + frame = create_frame_from_rgb_trns( pixels=numpy_data, width=width, height=height, + trns_color=trns_color, delay_num=delay_num, delay_den=delay_den, ) else: - raise TypeError(f"Invalid mode: {mode}. Must be RGB or RGBA.") + frame = create_frame_from_rgb( + pixels=numpy_data, + width=width, + height=height, + delay_num=delay_num, + delay_den=delay_den, + ) + elif mode == "RGBA": + if isinstance(trns_color, ndarray): + raise TypeError( + "Cannot set trns_color on RGBA mode Pillow object. Must be RGB." + ) + frame = create_frame_from_rgba( + pixels=numpy_data, + width=width, + height=height, + delay_num=delay_num, + delay_den=delay_den, + ) + else: + raise TypeError(f"Invalid mode: {mode}. Must be RGB or RGBA.") - return self.apngasm.add_frame(frame) + return self.apngasm.add_frame(frame) def assemble(self, output_path: str) -> bool: """ @@ -406,31 +416,40 @@ def assemble(self, output_path: str) -> bool: """ return self.apngasm.assemble(output_path) - def disassemble_as_numpy(self, file_path: str) -> list[APNGFrame]: + def disassemble_as_numpy(self, file_path: str) -> list[NDArray[Any]]: """ Disassembles an APNG file to a list of frames, expressed as 3D numpy array. :param str file_path: The file path to the PNG image to be disassembled. :return: A list containing the frames of the disassembled PNG. - :rtype: list[apngasm_python._apngasm_python.APNGFrame] + :rtype: list[numpy.typing.NDArray[Any]] """ - return self.apngasm.disassemble(file_path) + from numpy import array + + frames = self.apngasm.disassemble(file_path) + frames_numpy: list[NDArray[Any]] = [] + for frame in frames: + frames_numpy.append(array(frame.pixels)) + + return frames_numpy - def disassemble_as_pillow(self, file_path: str) -> list[APNGFrame]: + def disassemble_as_pillow(self, file_path: str) -> list[Image.Image]: """ Disassembles an APNG file to a list of frames, expressed as Pillow images. :param str file_path: The file path to the PNG image to be disassembled. :return: A list containing the frames of the disassembled PNG. - :rtype: list[apngasm_python._apngasm_python.APNGFrame] + :rtype: list[PIL.Image.Image] """ - frames_numpy = self.apngasm.disassemble(file_path) - frames_pillow = [] - for frame in frames_numpy: + from PIL import Image + + frames = self.apngasm.disassemble(file_path) + frames_pillow: list[Image.Image] = [] + for frame in frames: mode = self.color_type_dict[frame.color_type] - frame_pillow = Image.frombytes( + frame_pillow = Image.frombytes( # type: ignore mode, (frame.width, frame.height), frame.pixels ) frames_pillow.append(frame_pillow) @@ -490,23 +509,24 @@ def save_xml(self, output_path: str, image_dir: str) -> bool: """ return self.apngasm.save_xml(output_path, image_dir) - def set_apng_asm_listener(self, listener: Optional[IAPNGAsmListener] = None): + def set_apngasm_listener(self, listener: Optional[IAPNGAsmListener] = None): # type: ignore """ Sets a listener. You probably won't need to use this function. - :param Optional[apngasm_python._apngasm_python.IAPNGAsmListener] listener: A pointer to the listener object. - If the argument is None, + :param Optional[apngasm_python._apngasm_python.IAPNGAsmListener] listener: + A pointer to the listener object. If the argument is None, a default APNGAsmListener will be created and assigned. """ - raise NotImplementedError("set_apng_asm_listener is not implemented") - # return self.apngasm.set_apng_asm_listener(listener) + raise NotImplementedError("set_apngasm_listener is not implemented") + # return self.apngasm.set_apngasm_listener(listener) def set_loops(self, loops: int = 0): """ Set loop count of animation. - :param int loops: Loop count of animation. If the argument is 0 a loop count is infinity. + :param int loops: Loop count of animation. If the argument is 0 + a loop count is infinity. """ return self.apngasm.set_loops(loops) @@ -536,14 +556,14 @@ def get_loops(self) -> int: """ return self.apngasm.get_loops() - def is_skip_first(self) -> int: + def is_skip_first(self) -> bool: """ Returns the flag of skip first frame. :return: flag of skip first frame. - :rtype: int + :rtype: bool """ - return self.apngasm.get_loops() + return self.apngasm.is_skip_first() def frame_count(self) -> int: """ diff --git a/src-python/apngasm_python/apngasm.pyi b/src-python/apngasm_python/apngasm.pyi deleted file mode 100644 index f175464..0000000 --- a/src-python/apngasm_python/apngasm.pyi +++ /dev/null @@ -1,45 +0,0 @@ -import numpy.typing -from ._apngasm_python import APNGAsm as APNGAsm, APNGFrame as APNGFrame, IAPNGAsmListener as IAPNGAsmListener, __version__ as __version__, create_frame_from_rgb as create_frame_from_rgb, create_frame_from_rgb_trns as create_frame_from_rgb_trns, create_frame_from_rgba as create_frame_from_rgba -from PIL import Image -from _typeshed import Incomplete -from typing import Optional - -NUMPY_LOADED: bool -PILLOW_LOADED: bool - -class APNGAsmBinder: - color_type_dict: Incomplete - apngasm: Incomplete - def __init__(self) -> None: ... - def __enter__(self): ... - def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: types.TracebackType | None) -> None: ... - def frame_pixels_as_pillow(self, frame: int, new_value: Optional[Image.Image] = None) -> Optional[Image.Image]: ... - def frame_pixels_as_numpy(self, frame: int, new_value: Optional[numpy.typing.NDArray] = None) -> Optional[numpy.typing.NDArray]: ... - def frame_width(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def frame_height(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def frame_color_type(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def frame_palette(self, frame: int, new_value: Optional[numpy.typing.NDArray] = None) -> Optional[numpy.typing.NDArray]: ... - def frame_transparency(self, frame: int, new_value: Optional[numpy.typing.NDArray] = None) -> Optional[numpy.typing.NDArray]: ... - def frame_palette_size(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def frame_transparency_size(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def frame_delay_num(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def frame_delay_den(self, frame: int, new_value: Optional[int] = None) -> Optional[int]: ... - def add_frame_from_file(self, file_path: str, delay_num: int = 100, delay_den: int = 1000) -> int: ... - def add_frame_from_pillow(self, pillow_image: Image.Image, delay_num: int = 100, delay_den: int = 1000) -> int: ... - def add_frame_from_numpy(self, numpy_data: numpy.typing.NDArray, width: Optional[int] = None, height: Optional[int] = None, trns_color: Optional[numpy.typing.NDArray] = None, mode: Optional[str] = None, delay_num: int = 100, delay_den: int = 1000) -> int: ... - def assemble(self, output_path: str) -> bool: ... - def disassemble_as_numpy(self, file_path: str) -> list[APNGFrame]: ... - def disassemble_as_pillow(self, file_path: str) -> list[APNGFrame]: ... - def save_pngs(self, output_dir: str) -> bool: ... - def load_animation_spec(self, file_path: str) -> list[APNGFrame]: ... - def save_json(self, output_path: str, image_dir: str) -> bool: ... - def save_xml(self, output_path: str, image_dir: str) -> bool: ... - def set_apng_asm_listener(self, listener: Optional[IAPNGAsmListener] = None): ... - def set_loops(self, loops: int = 0): ... - def set_skip_first(self, skip_first: bool): ... - def get_frames(self) -> list[APNGFrame]: ... - def get_loops(self) -> int: ... - def is_skip_first(self) -> int: ... - def frame_count(self) -> int: ... - def reset(self) -> int: ... - def version(self) -> str: ... diff --git a/src/apngasm_python.cpp b/src/apngasm_python.cpp index aaca366..52f6e8e 100644 --- a/src/apngasm_python.cpp +++ b/src/apngasm_python.cpp @@ -1,4 +1,4 @@ -#ifdef _WINDOWS +#if defined(_WIN32) && !defined(__GNUC__) # ifdef _apngasm_python_EXPORTS # define APNGASM_PY_DECLSPEC __declspec(dllexport) # else @@ -37,7 +37,7 @@ NB_MODULE(MODULE_NAME, m) { m.attr("__version__") = VERSION_INFO; m.def("create_frame_from_rgb", []( - nb::ndarray> *pixels, + nb::ndarray> *pixels, unsigned int width, unsigned int height, unsigned delayNum = apngasm::DEFAULT_FRAME_NUMERATOR, unsigned delayDen = apngasm::DEFAULT_FRAME_DENOMINATOR @@ -72,7 +72,7 @@ NB_MODULE(MODULE_NAME, m) { )pbdoc"); m.def("create_frame_from_rgb_trns", []( - nb::ndarray> *pixels, + nb::ndarray> *pixels, unsigned int width, unsigned int height, nb::ndarray> *trns_color, unsigned delayNum = apngasm::DEFAULT_FRAME_NUMERATOR, @@ -117,7 +117,7 @@ NB_MODULE(MODULE_NAME, m) { )pbdoc"); m.def("create_frame_from_rgba", []( - nb::ndarray> *pixels, + nb::ndarray> *pixels, unsigned int width, unsigned int height, unsigned delayNum = apngasm::DEFAULT_FRAME_NUMERATOR, unsigned delayDen = apngasm::DEFAULT_FRAME_DENOMINATOR @@ -257,9 +257,9 @@ NB_MODULE(MODULE_NAME, m) { [](apngasm::APNGFrame &t) APNGASM_PY_DECLSPEC { size_t rowbytes = rowbytesMap[t._colorType]; size_t shape[3] = { t._height, t._width, rowbytes }; - return nb::ndarray>(t._pixels, 3, shape); + return nb::ndarray>(t._pixels, 3, shape, nb::handle()); }, - [](apngasm::APNGFrame &t, nb::ndarray> *v) APNGASM_PY_DECLSPEC { + [](apngasm::APNGFrame &t, nb::ndarray> *v) APNGASM_PY_DECLSPEC { size_t rowbytes = rowbytesMap[t._colorType]; unsigned char *pixelsNew = new unsigned char[v->size()]; unsigned char *v_ptr = v->data(); @@ -316,7 +316,7 @@ NB_MODULE(MODULE_NAME, m) { paletteView[i][2] = t._palette[i].b; } size_t shape[2] = { 256, 3 }; - return nb::ndarray>(paletteView, 2, shape); + return nb::ndarray>(paletteView, 2, shape, nb::handle()); }, [](apngasm::APNGFrame &t, nb::ndarray> *v) APNGASM_PY_DECLSPEC { unsigned char *v_ptr = v->data(); @@ -335,9 +335,9 @@ NB_MODULE(MODULE_NAME, m) { .def_prop_rw("transparency", [](apngasm::APNGFrame &t) APNGASM_PY_DECLSPEC { size_t shape[1] = { static_cast(t._transparencySize) }; - return nb::ndarray>(t._transparency, 1, shape); + return nb::ndarray>(t._transparency, 1, shape, nb::handle()); }, - [](apngasm::APNGFrame &t, nb::ndarray> *v) APNGASM_PY_DECLSPEC { + [](apngasm::APNGFrame &t, nb::ndarray> *v) APNGASM_PY_DECLSPEC { unsigned char *v_ptr = v->data(); for (int i = 0; i < v->shape(0); ++i) { t._transparency[i] = *v_ptr; @@ -423,7 +423,7 @@ NB_MODULE(MODULE_NAME, m) { )pbdoc") .def("add_frame_from_rgb", nb::overload_cast(&apngasm::APNGAsm::addFrame), - "pixels_rgb"_a, "width"_a, "height"_a, "trns_color"_a = NULL, "delay_num"_a = apngasm::DEFAULT_FRAME_NUMERATOR, "delay_den"_a = apngasm::DEFAULT_FRAME_DENOMINATOR, + "pixels_rgb"_a, "width"_a, "height"_a, "trns_color"_a.none(), "delay_num"_a = apngasm::DEFAULT_FRAME_NUMERATOR, "delay_den"_a = apngasm::DEFAULT_FRAME_DENOMINATOR, R"pbdoc( Adds an APNGFrame object to the vector. Not possible to use in Python. As alternative, diff --git a/tests/test_binder.py b/tests/test_binder.py new file mode 100644 index 0000000..b6316fc --- /dev/null +++ b/tests/test_binder.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +import os +from importlib.util import find_spec + +import pytest +from _pytest._py.path import LocalPath +from apngasm_python._apngasm_python import APNGFrame +from apngasm_python.apngasm import APNGAsmBinder + +PILLOW_LOADED = True if find_spec("PIL") else False +NUMPY_LOADED = True if find_spec("numpy") else False + +file_dir = os.path.split(__file__)[0] +samples_dir = os.path.join(file_dir, "../samples") +frames_dir = os.path.join(samples_dir, "frames") +elephant_frame0_path = os.path.join(frames_dir, "elephant-001.png") +input_dir = os.path.join(samples_dir, "input") +output_dir = os.path.join(samples_dir, "output") +ball_apng_path = os.path.join(input_dir, "ball.apng") +grey_png_path = os.path.join(input_dir, "grey.png") +palette_png_path = os.path.join(input_dir, "palette.png") +animation_spec_0_png_path = os.path.join(input_dir, "0.png") +animation_spec_1_png_path = os.path.join(input_dir, "1.png") +animation_spec_json = os.path.join(input_dir, "animation_spec.json") +animation_spec_xml = os.path.join(input_dir, "animation_spec.xml") + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_pixels_as_pillow(): + from PIL import Image + + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + frame = apngasm.frame_pixels_as_pillow(0) + + assert isinstance(frame, Image.Image) + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_pixels_as_numpy(): + import numpy + + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + frame = apngasm.frame_pixels_as_numpy(0) + + assert isinstance(frame, numpy.ndarray) + + +def test_frame_width(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + width = apngasm.frame_width(0) + + assert width == 480 + + +def test_frame_height(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + height = apngasm.frame_height(0) + + assert height == 400 + + +def test_frame_color_type(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + color_type = apngasm.frame_color_type(0) + + assert color_type == 6 + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_palette(): + import numpy + + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(palette_png_path) + palette = apngasm.frame_palette(0) + assert isinstance(palette, numpy.ndarray) + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_transparency(): + import numpy + from PIL import Image + + apngasm = APNGAsmBinder() + with Image.open(elephant_frame0_path) as im_rgba: + im_rgb = im_rgba.convert("RGB") + arr = numpy.asarray(im_rgb, dtype="int32") + white = numpy.array([255, 255, 255]) + apngasm.add_frame_from_numpy(arr, trns_color=white) + transparency = apngasm.frame_transparency(0) + assert isinstance(transparency, numpy.ndarray) + + +def test_frame_palette_size(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(palette_png_path) + palette_size = apngasm.frame_palette_size(0) + + assert palette_size == 0 + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_transparency_size(): + import numpy + from PIL import Image + + apngasm = APNGAsmBinder() + with Image.open(elephant_frame0_path) as im_rgba: + im_rgb = im_rgba.convert("RGB") + arr = numpy.asarray(im_rgb, dtype="int32") + white = numpy.array([255, 255, 255]) + apngasm.add_frame_from_numpy(arr, trns_color=white) + transparency_size = apngasm.frame_transparency_size(0) + assert transparency_size == 6 + + +def test_frame_delay_num(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path, 50, 250) + delay_num = apngasm.frame_delay_num(0) + assert delay_num == 50 + + +def test_frame_delay_den(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path, 50, 250) + delay_den = apngasm.frame_delay_den(0) + assert delay_den == 250 + + +def test_add_frame_from_file_rgba(): + apngasm = APNGAsmBinder() + frame_count = apngasm.add_frame_from_file(elephant_frame0_path) + assert frame_count == 1 + + +def test_add_frame_from_file_grey(): + apngasm = APNGAsmBinder() + frame_count = apngasm.add_frame_from_file(grey_png_path) + assert frame_count == 1 + + +def test_add_frame_from_file_palette(): + apngasm = APNGAsmBinder() + frame_count = apngasm.add_frame_from_file(palette_png_path) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_add_frame_from_pillow_rgba(): + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(elephant_frame0_path) as im: + frame_count = apngasm.add_frame_from_pillow(im) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_add_frame_from_pillow_grey(): + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(grey_png_path) as im: + frame_count = apngasm.add_frame_from_pillow(im) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_add_frame_from_pillow_palette(): + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(palette_png_path) as im: + frame_count = apngasm.add_frame_from_pillow(im) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_add_frame_from_numpy_rgba(): + import numpy + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(elephant_frame0_path) as im: + arr = numpy.asarray(im, dtype="int32") + frame_count = apngasm.add_frame_from_numpy(arr) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_add_frame_from_numpy_rgb(): + import numpy + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(elephant_frame0_path) as im_rgba: + im_rgb = im_rgba.convert("RGB") + arr = numpy.asarray(im_rgb, dtype="int32") + frame_count = apngasm.add_frame_from_numpy(arr) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_add_frame_from_numpy_rgb_trns(): + import numpy + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(elephant_frame0_path) as im_rgba: + im_rgb = im_rgba.convert("RGB") + arr = numpy.asarray(im_rgb, dtype="int32") + white = numpy.array([255, 255, 255]) + frame_count = apngasm.add_frame_from_numpy(arr, trns_color=white) + assert frame_count == 1 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_add_frame_from_numpy_non_rgb(): + import numpy + from PIL import Image + + apngasm = APNGAsmBinder() + + with Image.open(palette_png_path) as im: + arr = numpy.asarray(im, dtype="int32") + with pytest.raises(TypeError): + apngasm.add_frame_from_numpy(arr) + + +def test_assemble(tmpdir: LocalPath): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(grey_png_path) + apngasm.add_frame_from_file(palette_png_path) + + out = os.path.join(tmpdir, "0.apng") + apngasm.assemble(out) + + assert os.path.isfile(out) + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_disassemble_as_numpy(): + import numpy + + apngasm = APNGAsmBinder() + arrs = apngasm.disassemble_as_numpy(ball_apng_path) + + assert len(arrs) == 20 + for arr in arrs: + assert isinstance(arr, numpy.ndarray) + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_disassemble_as_pillow(): + from PIL import Image + + apngasm = APNGAsmBinder() + ims = apngasm.disassemble_as_pillow(ball_apng_path) + + assert len(ims) == 20 + for im in ims: + assert isinstance(im, Image.Image) + + +def test_save_pngs(tmpdir: LocalPath): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + apngasm.save_pngs(str(tmpdir)) + + assert len(os.listdir(tmpdir)) == 1 + + +def test_load_animation_spec_xml(): + apngasm = APNGAsmBinder() + frames = apngasm.load_animation_spec(animation_spec_xml) + + assert len(frames) == 2 + + +def test_load_animation_spec_json(): + apngasm = APNGAsmBinder() + frames = apngasm.load_animation_spec(animation_spec_json) + + assert len(frames) == 2 + + +def test_save_json(tmpdir: LocalPath): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(animation_spec_0_png_path) + apngasm.add_frame_from_file(animation_spec_1_png_path) + out = os.path.join(tmpdir, "animation_spec.json") + + apngasm.save_json(out, str(tmpdir)) + + with open(out) as f, open(animation_spec_json) as g: + assert f.read() == g.read() + + +def test_save_xml(tmpdir: LocalPath): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(animation_spec_0_png_path) + apngasm.add_frame_from_file(animation_spec_1_png_path) + out = os.path.join(tmpdir, "animation_spec.xml") + + apngasm.save_xml(out, str(tmpdir)) + + with open(out) as f, open(animation_spec_xml) as g: + assert f.read() == g.read() + + +def test_set_apngasm_listener(): + apngasm = APNGAsmBinder() + with pytest.raises(NotImplementedError): + apngasm.set_apngasm_listener() + + +def test_get_set_loops(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + apngasm.set_loops(5) + assert apngasm.get_loops() == 5 + + +def test_skip_first(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(elephant_frame0_path) + apngasm.set_skip_first(True) + assert apngasm.is_skip_first() is True + apngasm.set_skip_first(False) + assert apngasm.is_skip_first() is False + + +def test_get_frames(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(grey_png_path) + apngasm.add_frame_from_file(palette_png_path) + frames = apngasm.get_frames() + + assert len(frames) == 2 + for frame in frames: + assert isinstance(frame, APNGFrame) + + +def test_frame_count(): + apngasm = APNGAsmBinder() + apngasm.add_frame_from_file(grey_png_path) + apngasm.add_frame_from_file(palette_png_path) + frame_count = apngasm.frame_count() + + assert frame_count == 2 + + +def test_reset(): + apngasm = APNGAsmBinder() + assert apngasm.frame_count() == 0 + apngasm.add_frame_from_file(grey_png_path) + assert apngasm.frame_count() == 1 + apngasm.reset() + assert apngasm.frame_count() == 0 + apngasm.add_frame_from_file(palette_png_path) + assert apngasm.frame_count() == 1 + + +def test_version(): + apngasm = APNGAsmBinder() + assert isinstance(apngasm.version(), str) + + +def test_with(): + with APNGAsmBinder() as apngasm: + assert isinstance(apngasm.version(), str) diff --git a/tests/test_direct.py b/tests/test_direct.py new file mode 100644 index 0000000..f589a28 --- /dev/null +++ b/tests/test_direct.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +import os +from importlib.util import find_spec + +import pytest +from _pytest._py.path import LocalPath +from apngasm_python._apngasm_python import (APNGAsm, APNGFrame, + create_frame_from_rgb_trns, + create_frame_from_rgba) + +PILLOW_LOADED = True if find_spec("PIL") else False +NUMPY_LOADED = True if find_spec("numpy") else False + +file_dir = os.path.split(__file__)[0] +samples_dir = os.path.join(file_dir, "../samples") +frames_dir = os.path.join(samples_dir, "frames") +elephant_frame0_path = os.path.join(frames_dir, "elephant-001.png") +input_dir = os.path.join(samples_dir, "input") +output_dir = os.path.join(samples_dir, "output") +ball_apng_path = os.path.join(input_dir, "ball.apng") +grey_png_path = os.path.join(input_dir, "grey.png") +palette_png_path = os.path.join(input_dir, "palette.png") +animation_spec_0_png_path = os.path.join(input_dir, "0.png") +animation_spec_1_png_path = os.path.join(input_dir, "1.png") +animation_spec_json = os.path.join(input_dir, "animation_spec.json") +animation_spec_xml = os.path.join(input_dir, "animation_spec.xml") + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_pixels(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba(numpy.array(image), image.width, image.height) + + assert isinstance(frame.pixels, numpy.ndarray) + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_width(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba(numpy.array(image), image.width, image.height) + + assert frame.width == 480 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_height(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba(numpy.array(image), image.width, image.height) + + assert frame.height == 400 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_color_type(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba(numpy.array(image), image.width, image.height) + + assert frame.color_type == 6 + + +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +def test_frame_palette(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba(numpy.array(image), image.width, image.height) + assert isinstance(frame.palette, numpy.ndarray) + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_transparency(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path).convert("RGB") + frame = create_frame_from_rgb_trns( + numpy.array(image), image.width, image.height, numpy.array([255, 255, 255]) + ) + + assert isinstance(frame.transparency, numpy.ndarray) + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_palette_size(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba(numpy.array(image), image.width, image.height) + + assert frame.palette_size == 0 + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_transparency_size(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path).convert("RGB") + frame = create_frame_from_rgb_trns( + numpy.array(image), image.width, image.height, numpy.array([255, 255, 255]) + ) + assert frame.transparency_size == 6 + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_delay_num(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba( + numpy.array(image), image.width, image.height, 50, 250 + ) + assert frame.delay_num == 50 + + +@pytest.mark.skipif(NUMPY_LOADED is False, reason="Numpy not installed") +@pytest.mark.skipif(PILLOW_LOADED is False, reason="Pillow not installed") +def test_frame_delay_den(): + import numpy + from PIL import Image + + image = Image.open(elephant_frame0_path) + frame = create_frame_from_rgba( + numpy.array(image), image.width, image.height, 50, 250 + ) + assert frame.delay_den == 250 + + +def test_add_frame_from_file_rgba(): + apngasm = APNGAsm() + frame_count = apngasm.add_frame_from_file(elephant_frame0_path) + assert frame_count == 1 + + +def test_add_frame_from_file_grey(): + apngasm = APNGAsm() + frame_count = apngasm.add_frame_from_file(grey_png_path) + assert frame_count == 1 + + +def test_add_frame_from_file_palette(): + apngasm = APNGAsm() + frame_count = apngasm.add_frame_from_file(palette_png_path) + assert frame_count == 1 + + +def test_assemble(tmpdir: LocalPath): + apngasm = APNGAsm() + apngasm.add_frame_from_file(grey_png_path) + apngasm.add_frame_from_file(palette_png_path) + + out = os.path.join(tmpdir, "0.apng") + apngasm.assemble(out) + + assert os.path.isfile(out) + + +def test_disassemble(): + apngasm = APNGAsm() + frames = apngasm.disassemble(ball_apng_path) + + assert len(frames) == 20 + for frame in frames: + assert isinstance(frame, APNGFrame) + + +def test_save_pngs(tmpdir: LocalPath): + apngasm = APNGAsm() + apngasm.add_frame_from_file(elephant_frame0_path) + apngasm.save_pngs(str(tmpdir)) + + assert len(os.listdir(tmpdir)) == 1 + + +def test_load_animation_spec_xml(): + apngasm = APNGAsm() + frames = apngasm.load_animation_spec(animation_spec_xml) + + assert len(frames) == 2 + + +def test_load_animation_spec_json(): + apngasm = APNGAsm() + frames = apngasm.load_animation_spec(animation_spec_json) + + assert len(frames) == 2 + + +def test_save_json(tmpdir: LocalPath): + apngasm = APNGAsm() + apngasm.add_frame_from_file(animation_spec_0_png_path) + apngasm.add_frame_from_file(animation_spec_1_png_path) + out = os.path.join(tmpdir, "animation_spec.json") + + apngasm.save_json(out, str(tmpdir)) + + with open(out) as f, open(animation_spec_json) as g: + assert f.read() == g.read() + + +def test_save_xml(tmpdir: LocalPath): + apngasm = APNGAsm() + apngasm.add_frame_from_file(animation_spec_0_png_path) + apngasm.add_frame_from_file(animation_spec_1_png_path) + out = os.path.join(tmpdir, "animation_spec.xml") + + apngasm.save_xml(out, str(tmpdir)) + + with open(out) as f, open(animation_spec_xml) as g: + assert f.read() == g.read() + + +def test_get_set_loops(): + apngasm = APNGAsm() + apngasm.add_frame_from_file(elephant_frame0_path) + apngasm.set_loops(5) + assert apngasm.get_loops() == 5 + + +def test_skip_first(): + apngasm = APNGAsm() + apngasm.add_frame_from_file(elephant_frame0_path) + apngasm.set_skip_first(True) + assert apngasm.is_skip_first() is True + apngasm.set_skip_first(False) + assert apngasm.is_skip_first() is False + + +def test_get_frames(): + apngasm = APNGAsm() + apngasm.add_frame_from_file(grey_png_path) + apngasm.add_frame_from_file(palette_png_path) + frames = apngasm.get_frames() + + assert len(frames) == 2 + for frame in frames: + assert isinstance(frame, APNGFrame) + + +def test_frame_count(): + apngasm = APNGAsm() + apngasm.add_frame_from_file(grey_png_path) + apngasm.add_frame_from_file(palette_png_path) + frame_count = apngasm.frame_count() + + assert frame_count == 2 + + +def test_reset(): + apngasm = APNGAsm() + assert apngasm.frame_count() == 0 + apngasm.add_frame_from_file(grey_png_path) + assert apngasm.frame_count() == 1 + apngasm.reset() + assert apngasm.frame_count() == 0 + apngasm.add_frame_from_file(palette_png_path) + assert apngasm.frame_count() == 1 + + +def test_version(): + apngasm = APNGAsm() + assert isinstance(apngasm.version(), str)