Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ config.json

# PVRTexToolCLI
PVRTexToolCLI*
.DS_Store
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dependencies = [
"sc-compression==0.6.6",
"colorama==0.4.6",
"zstandard>=0.23.0,<0.24",
"pillow~=11.2.1",
"pillow~=12.1.0",
"loguru==0.7.3",
]

Expand All @@ -26,9 +26,10 @@ lzham = ["pylzham>=0.1.3,<0.2"]
[dependency-groups]
dev = [
"pyright>=1.1.376,<2",
"black>=24.8.0,<27",
"pre-commit>=3.8.0,<5",
"ruff>=0.11.2,<0.16",
"black>=24.8.0,<25",
"pre-commit>=3.8.0,<4",
"ruff>=0.11.2,<0.12",
"ty>=0.0.15",
]

[project.scripts]
Expand Down
74 changes: 74 additions & 0 deletions src/ktx/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import os
from pathlib import Path
import tempfile
from typing import ClassVar

from PIL import Image

from ktx.exceptions import ToolNotFoundException

from .ktx_tool import KhronosKtxTool
from .pvr_tex_tool import PvrTexTool
from .tool_protocol import KtxToolProtocol


def get_image_from_ktx_data(data: bytes) -> Image.Image:
with tempfile.NamedTemporaryFile(delete=False, suffix=".ktx1") as tmp:
tmp.write(data)

try:
image = get_image_from_ktx(Path(tmp.name))
finally:
os.remove(tmp.name)

return image


def get_image_from_ktx(filepath: Path) -> Image.Image:
png_filepath = KtxTool.convert_ktx_to_png(filepath)
image_open = Image.open(png_filepath)

try:
return image_open.copy()
finally:
image_open.close()
os.remove(png_filepath)


class KtxTool:
TOOLS: ClassVar[tuple[KtxToolProtocol, ...]] = (
KhronosKtxTool,
PvrTexTool,
)

@classmethod
def is_available(cls) -> bool:
for tool in cls.TOOLS:
if tool.is_available():
return True

return False

@classmethod
def convert_ktx_to_png(
cls, filepath: Path, output_folder: Path | None = None
) -> Path:
for tool in cls.TOOLS:
if not tool.is_available():
continue

return tool.convert_ktx_to_png(filepath, output_folder)

raise ToolNotFoundException("No tools available for ktx handling")

@classmethod
def convert_png_to_ktx(
cls, filepath: Path, output_folder: Path | None = None
) -> Path:
for tool in cls.TOOLS:
if not tool.is_available():
continue

return tool.convert_png_to_ktx(filepath, output_folder)

raise ToolNotFoundException("No tools available for ktx handling")
31 changes: 31 additions & 0 deletions src/ktx/_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os
from pathlib import Path
import platform

# Put executable files in "src/ktx/bin"
_main_dir = Path(__file__).parent
bin_dir = _main_dir / "bin"


# Note: a solution from
# https://stackoverflow.com/questions/11210104/check-if-a-program-exists-from-a-python-script
def get_executable_path(
*paths: os.PathLike[str] | str,
) -> os.PathLike[str] | str | None:
from shutil import which

for path in paths:
# Fix of https://github.com/xcoder-tool/XCoder/issues/22
executable_path = which(path)
if executable_path is not None:
return path

return None


is_windows = platform.system() == "Windows"
null_output = f"{'nul' if is_windows else '/dev/null'} 2>&1"


def run(command: str, output_path: str = null_output) -> int:
return os.system(f"{command} > {output_path}")
3 changes: 3 additions & 0 deletions src/ktx/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from ._tool_not_found import ToolNotFoundException

__all__ = ["ToolNotFoundException"]
1 change: 1 addition & 0 deletions src/ktx/exceptions/_tool_not_found.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
class ToolNotFoundException(Exception): ...
64 changes: 64 additions & 0 deletions src/ktx/ktx_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from pathlib import Path

from ._common import bin_dir, get_executable_path, run
from .exceptions import ToolNotFoundException

_toktx_cli_name = "toktx"
_toktx_cli_path = get_executable_path(bin_dir / _toktx_cli_name, _toktx_cli_name)

_ktx_cli_name = "ktx"
_ktx_cli_path = get_executable_path(bin_dir / _ktx_cli_name, _ktx_cli_name)

_ktx2ktx2_cli_name = "ktx2ktx2"
_ktx2ktx2_cli_path = get_executable_path(
bin_dir / _ktx2ktx2_cli_name, _ktx2ktx2_cli_name
)


class KhronosKtxTool:
@classmethod
def is_available(cls) -> bool:
return (
_ktx_cli_path is not None
and _ktx2ktx2_cli_path is not None
and _toktx_cli_path is not None
)

@classmethod
def convert_ktx_to_png(
cls, filepath: Path, output_folder: Path | None = None
) -> Path:
cls._ensure_tool_installed()

ktx2_filepath = filepath.with_suffix(".ktx2")
output_filepath = filepath.with_suffix(".png")
if output_folder is not None:
output_filepath = output_folder / output_filepath.name

run(f"{_ktx2ktx2_cli_path} {filepath!s}")
run(f"{_ktx_cli_path} extract {ktx2_filepath!s} {output_filepath!s}")

return output_filepath

@classmethod
def convert_png_to_ktx(
cls, filepath: Path, output_folder: Path | None = None
) -> Path:
cls._ensure_tool_installed()

output_filepath = filepath.with_suffix(".ktx")
if output_folder is not None:
output_filepath = output_folder / output_filepath.name

run(
f"{_toktx_cli_path} --encode etc1s --genmipmap {output_filepath!s} {filepath!s}"
)

return output_filepath

@classmethod
def _ensure_tool_installed(cls):
if cls.is_available():
return

raise ToolNotFoundException("ktx-tool not found.")
56 changes: 56 additions & 0 deletions src/ktx/pvr_tex_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from pathlib import Path

from ._common import bin_dir, get_executable_path, run
from .exceptions import ToolNotFoundException

_color_space = "sRGB"
_format = "ETC1,UBN,lRGB"
_quality = "etcfast"

_cli_name = "PVRTexToolCLI"
_cli_path = get_executable_path(bin_dir / _cli_name, _cli_name)


class PvrTexTool:
@classmethod
def is_available(cls) -> bool:
return _cli_path is not None

@classmethod
def convert_ktx_to_png(
cls, filepath: Path, output_folder: Path | None = None
) -> Path:
cls._ensure_tool_installed()

output_filepath = filepath.with_suffix(".png")
if output_folder is not None:
output_filepath = output_folder / output_filepath.name

run(
f"{_cli_path} -noout -ics {_color_space} -i {filepath!s} -d {output_filepath!s}"
)

return output_filepath

@classmethod
def convert_png_to_ktx(
cls, filepath: Path, output_folder: Path | None = None
) -> Path:
cls._ensure_tool_installed()

output_filepath = filepath.with_suffix(".ktx")
if output_folder is not None:
output_filepath = output_folder / output_filepath.name

run(
f"{_cli_path} -f {_format} -q {_quality} -i {filepath!s} -o {output_filepath!s}"
)

return output_filepath

@classmethod
def _ensure_tool_installed(cls):
if cls.is_available():
return

raise ToolNotFoundException("PVRTexTool not found.")
17 changes: 17 additions & 0 deletions src/ktx/tool_protocol.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from pathlib import Path
from typing import Protocol


class KtxToolProtocol(Protocol):
@classmethod
def is_available(cls) -> bool: ...

@classmethod
def convert_ktx_to_png(
cls, filepath: Path, output_folder: Path | None = None
) -> Path: ...

@classmethod
def convert_png_to_ktx(
cls, filepath: Path, output_folder: Path | None = None
) -> Path: ...
21 changes: 13 additions & 8 deletions src/xcoder/bytestream.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,33 @@ def __init__(self, endian: Literal["little", "big"] = "little"):
super().__init__()
self._endian: Literal["little", "big"] = endian

def write_int(self, integer: int, length: int = 1, signed: bool = False):
def write_tagged(self, tag: int, data: bytes) -> None:
self.write_ubyte(tag)
self.write_uint32(len(data))
self.write(data)

def write_int(self, integer: int, length: int = 1, signed: bool = False) -> None:
self.write(integer.to_bytes(length, self._endian, signed=signed))

def write_ubyte(self, integer: int):
def write_ubyte(self, integer: int) -> None:
self.write_int(integer)

def write_byte(self, integer: int):
def write_byte(self, integer: int) -> None:
self.write_int(integer, signed=True)

def write_uint16(self, integer: int):
def write_uint16(self, integer: int) -> None:
self.write_int(integer, 2)

def write_int16(self, integer: int):
def write_int16(self, integer: int) -> None:
self.write_int(integer, 2, True)

def write_uint32(self, integer: int):
def write_uint32(self, integer: int) -> None:
self.write_int(integer, 4)

def write_int32(self, integer: int):
def write_int32(self, integer: int) -> None:
self.write_int(integer, 4, True)

def write_string(self, string: str | None = None):
def write_string(self, string: str | None) -> None:
if string is None:
self.write_byte(0xFF)
return
Expand Down
11 changes: 7 additions & 4 deletions src/xcoder/console.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
from typing import ClassVar


class Console:
previous_percentage: int = -1
_previous_percentage: ClassVar[int] = -1

@classmethod
def progress_bar(cls, message: str, current: int, total: int) -> None:
percentage = (current + 1) * 100 // total
if percentage == cls.previous_percentage:
if percentage == cls._previous_percentage:
return

print(f"\r[{percentage}%] {message}", end="")

if percentage == 100:
print()
cls.previous_percentage = -1
cls._previous_percentage = -1
else:
cls.previous_percentage = percentage
cls._previous_percentage = percentage

@staticmethod
def ask_integer(message: str):
Expand Down
3 changes: 0 additions & 3 deletions src/xcoder/exceptions/__init__.py

This file was deleted.

2 changes: 0 additions & 2 deletions src/xcoder/exceptions/tool_not_found.py

This file was deleted.

10 changes: 7 additions & 3 deletions src/xcoder/features/ktx.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

from loguru import logger

from ktx import KtxTool
from xcoder.localization import locale
from xcoder.pvr_tex_tool import convert_ktx_to_png, convert_png_to_ktx

IN_PNG_PATH = Path("./TEX/In-PNG")
IN_KTX_PATH = Path("./TEX/In-KTX")
Expand All @@ -13,6 +13,8 @@


def convert_png_textures_to_ktx():
assert KtxTool.is_available()

input_folder = IN_PNG_PATH
output_folder = OUT_KTX_PATH

Expand All @@ -25,10 +27,12 @@ def convert_png_textures_to_ktx():
continue

logger.info(locale.collecting_inf % file)
convert_png_to_ktx(png_filepath, output_folder=output_folder)
KtxTool.convert_png_to_ktx(png_filepath, output_folder=output_folder)


def convert_ktx_textures_to_png():
assert KtxTool.is_available()

input_folder = IN_KTX_PATH
output_folder = OUT_PNG_PATH

Expand All @@ -41,4 +45,4 @@ def convert_ktx_textures_to_png():
continue

logger.info(locale.collecting_inf % file)
convert_ktx_to_png(ktx_filepath, output_folder=output_folder)
KtxTool.convert_ktx_to_png(ktx_filepath, output_folder=output_folder)
Loading
Loading