Skip to content

Commit f70dc48

Browse files
feat(ktx): add khronos ktx-tool support as additional strategy
1 parent ed99561 commit f70dc48

21 files changed

Lines changed: 374 additions & 170 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ config.json
1717

1818
# PVRTexToolCLI
1919
PVRTexToolCLI*
20+
.DS_Store

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ dependencies = [
1616
"sc-compression==0.6.6",
1717
"colorama==0.4.6",
1818
"zstandard>=0.23.0,<0.24",
19-
"pillow~=11.2.1",
19+
"pillow~=12.1.0",
2020
"loguru==0.7.3",
2121
]
2222

@@ -29,6 +29,7 @@ dev = [
2929
"black>=24.8.0,<25",
3030
"pre-commit>=3.8.0,<4",
3131
"ruff>=0.11.2,<0.12",
32+
"ty>=0.0.15",
3233
]
3334

3435
[project.scripts]

src/ktx/__init__.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import os
2+
from pathlib import Path
3+
import tempfile
4+
from typing import ClassVar
5+
6+
from PIL import Image
7+
8+
from ktx.exceptions import ToolNotFoundException
9+
10+
from .ktx_tool import KhronosKtxTool
11+
from .pvr_tex_tool import PvrTexTool
12+
from .tool_protocol import KtxToolProtocol
13+
14+
15+
def get_image_from_ktx_data(data: bytes) -> Image.Image:
16+
with tempfile.NamedTemporaryFile(delete=False, suffix=".ktx1") as tmp:
17+
tmp.write(data)
18+
19+
try:
20+
image = get_image_from_ktx(Path(tmp.name))
21+
finally:
22+
os.remove(tmp.name)
23+
24+
return image
25+
26+
27+
def get_image_from_ktx(filepath: Path) -> Image.Image:
28+
png_filepath = KtxTool.convert_ktx_to_png(filepath)
29+
image_open = Image.open(png_filepath)
30+
31+
try:
32+
return image_open.copy()
33+
finally:
34+
image_open.close()
35+
os.remove(png_filepath)
36+
37+
38+
class KtxTool:
39+
TOOLS: ClassVar[tuple[KtxToolProtocol, ...]] = (
40+
KhronosKtxTool,
41+
PvrTexTool,
42+
)
43+
44+
@classmethod
45+
def is_available(cls) -> bool:
46+
for tool in cls.TOOLS:
47+
if tool.is_available():
48+
return True
49+
50+
return False
51+
52+
@classmethod
53+
def convert_ktx_to_png(
54+
cls, filepath: Path, output_folder: Path | None = None
55+
) -> Path:
56+
for tool in cls.TOOLS:
57+
if not tool.is_available():
58+
continue
59+
60+
return tool.convert_ktx_to_png(filepath, output_folder)
61+
62+
raise ToolNotFoundException("No tools available for ktx handling")
63+
64+
@classmethod
65+
def convert_png_to_ktx(
66+
cls, filepath: Path, output_folder: Path | None = None
67+
) -> Path:
68+
for tool in cls.TOOLS:
69+
if not tool.is_available():
70+
continue
71+
72+
return tool.convert_png_to_ktx(filepath, output_folder)
73+
74+
raise ToolNotFoundException("No tools available for ktx handling")

src/ktx/_common.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
from pathlib import Path
3+
import platform
4+
5+
# Put executable files in "src/ktx/bin"
6+
_main_dir = Path(__file__).parent
7+
bin_dir = _main_dir / "bin"
8+
9+
10+
# Note: a solution from
11+
# https://stackoverflow.com/questions/11210104/check-if-a-program-exists-from-a-python-script
12+
def get_executable_path(
13+
*paths: os.PathLike[str] | str,
14+
) -> os.PathLike[str] | str | None:
15+
from shutil import which
16+
17+
for path in paths:
18+
# Fix of https://github.com/xcoder-tool/XCoder/issues/22
19+
executable_path = which(path)
20+
if executable_path is not None:
21+
return path
22+
23+
return None
24+
25+
26+
is_windows = platform.system() == "Windows"
27+
null_output = f"{'nul' if is_windows else '/dev/null'} 2>&1"
28+
29+
30+
def run(command: str, output_path: str = null_output) -> int:
31+
return os.system(f"{command} > {output_path}")

src/ktx/exceptions/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from ._tool_not_found import ToolNotFoundException
2+
3+
__all__ = ["ToolNotFoundException"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
class ToolNotFoundException(Exception): ...

src/ktx/ktx_tool.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from pathlib import Path
2+
3+
from ._common import bin_dir, get_executable_path, run
4+
from .exceptions import ToolNotFoundException
5+
6+
_toktx_cli_name = "toktx"
7+
_toktx_cli_path = get_executable_path(bin_dir / _toktx_cli_name, _toktx_cli_name)
8+
9+
_ktx_cli_name = "ktx"
10+
_ktx_cli_path = get_executable_path(bin_dir / _ktx_cli_name, _ktx_cli_name)
11+
12+
_ktx2ktx2_cli_name = "ktx2ktx2"
13+
_ktx2ktx2_cli_path = get_executable_path(
14+
bin_dir / _ktx2ktx2_cli_name, _ktx2ktx2_cli_name
15+
)
16+
17+
18+
class KhronosKtxTool:
19+
@classmethod
20+
def is_available(cls) -> bool:
21+
return (
22+
_ktx_cli_path is not None
23+
and _ktx2ktx2_cli_path is not None
24+
and _toktx_cli_path is not None
25+
)
26+
27+
@classmethod
28+
def convert_ktx_to_png(
29+
cls, filepath: Path, output_folder: Path | None = None
30+
) -> Path:
31+
cls._ensure_tool_installed()
32+
33+
ktx2_filepath = filepath.with_suffix(".ktx2")
34+
output_filepath = filepath.with_suffix(".png")
35+
if output_folder is not None:
36+
output_filepath = output_folder / output_filepath.name
37+
38+
run(f"{_ktx2ktx2_cli_path} {filepath!s}")
39+
run(f"{_ktx_cli_path} extract {ktx2_filepath!s} {output_filepath!s}")
40+
41+
return output_filepath
42+
43+
@classmethod
44+
def convert_png_to_ktx(
45+
cls, filepath: Path, output_folder: Path | None = None
46+
) -> Path:
47+
cls._ensure_tool_installed()
48+
49+
output_filepath = filepath.with_suffix(".ktx")
50+
if output_folder is not None:
51+
output_filepath = output_folder / output_filepath.name
52+
53+
run(
54+
f"{_toktx_cli_path} --encode etc1s --genmipmap {output_filepath!s} {filepath!s}"
55+
)
56+
57+
return output_filepath
58+
59+
@classmethod
60+
def _ensure_tool_installed(cls):
61+
if cls.is_available():
62+
return
63+
64+
raise ToolNotFoundException("ktx-tool not found.")

src/ktx/pvr_tex_tool.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from pathlib import Path
2+
3+
from ._common import bin_dir, get_executable_path, run
4+
from .exceptions import ToolNotFoundException
5+
6+
_color_space = "sRGB"
7+
_format = "ETC1,UBN,lRGB"
8+
_quality = "etcfast"
9+
10+
_cli_name = "PVRTexToolCLI"
11+
_cli_path = get_executable_path(bin_dir / _cli_name, _cli_name)
12+
13+
14+
class PvrTexTool:
15+
@classmethod
16+
def is_available(cls) -> bool:
17+
return _cli_path is not None
18+
19+
@classmethod
20+
def convert_ktx_to_png(
21+
cls, filepath: Path, output_folder: Path | None = None
22+
) -> Path:
23+
cls._ensure_tool_installed()
24+
25+
output_filepath = filepath.with_suffix(".png")
26+
if output_folder is not None:
27+
output_filepath = output_folder / output_filepath.name
28+
29+
run(
30+
f"{_cli_path} -noout -ics {_color_space} -i {filepath!s} -d {output_filepath!s}"
31+
)
32+
33+
return output_filepath
34+
35+
@classmethod
36+
def convert_png_to_ktx(
37+
cls, filepath: Path, output_folder: Path | None = None
38+
) -> Path:
39+
cls._ensure_tool_installed()
40+
41+
output_filepath = filepath.with_suffix(".ktx")
42+
if output_folder is not None:
43+
output_filepath = output_folder / output_filepath.name
44+
45+
run(
46+
f"{_cli_path} -f {_format} -q {_quality} -i {filepath!s} -o {output_filepath!s}"
47+
)
48+
49+
return output_filepath
50+
51+
@classmethod
52+
def _ensure_tool_installed(cls):
53+
if cls.is_available():
54+
return
55+
56+
raise ToolNotFoundException("PVRTexTool not found.")

src/ktx/tool_protocol.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from pathlib import Path
2+
from typing import Protocol
3+
4+
5+
class KtxToolProtocol(Protocol):
6+
@classmethod
7+
def is_available(cls) -> bool: ...
8+
9+
@classmethod
10+
def convert_ktx_to_png(
11+
cls, filepath: Path, output_folder: Path | None = None
12+
) -> Path: ...
13+
14+
@classmethod
15+
def convert_png_to_ktx(
16+
cls, filepath: Path, output_folder: Path | None = None
17+
) -> Path: ...

src/xcoder/exceptions/__init__.py

Lines changed: 0 additions & 3 deletions
This file was deleted.

0 commit comments

Comments
 (0)