"""Python wrapper around the compiled ``_simvx_basis_transcoder`` cffi extension.
Exposes a tiny Pythonic surface over the vendored basis_universal transcoder:
:func:`transcode_uastc_to_bc7` / :func:`transcode_uastc_to_astc4x4` /
:func:`transcode_uastc_to_etc2` turn one UASTC level's packed 16-byte blocks
into BC7 / ASTC-4x4 / ETC2-RGBA blocks (all 16 B/block). Used by
``simvx.graphics.assets.ktx2_loader`` to load single-source UASTC KTX2 textures
on the desktop, with ``TextureManager`` picking the target per device caps.
Build the extension once after install:
uv run python -m simvx.graphics._native.basis_transcoder_build
If the .so is missing, this module still imports cleanly (the ImportError is
swallowed): callers check :func:`is_available` and fall through to a warn + -1
sentinel, never a crash. Mirrors ``simvx.core._native.miniaudio_engine``.
"""
from __future__ import annotations
import logging
import os
import sys
from pathlib import Path
from typing import Any
log = logging.getLogger(__name__)
__all__ = [
"BasisTranscoderUnavailable",
"is_available",
"astc_available",
"etc2_available",
"refresh",
"transcode_uastc_to_bc7",
"transcode_uastc_to_astc4x4",
"transcode_uastc_to_etc2",
"ensure_built",
]
[docs]
class BasisTranscoderUnavailable(RuntimeError):
"""The compiled ``_simvx_basis_transcoder`` extension was not found.
Run ``uv run python -m simvx.graphics._native.basis_transcoder_build`` (or
``simvx build-textures``) to compile it once, with a C++ compiler present.
"""
def _try_import_extension() -> tuple[Any | None, Any | None]:
"""Import the compiled extension, returning (ffi, lib) or (None, None)."""
here = Path(__file__).resolve().parent
# cffi names the module ``_simvx_basis_transcoder`` (no package prefix), so
# the extension dir must be importable.
if str(here) not in sys.path:
sys.path.insert(0, str(here))
try:
import _simvx_basis_transcoder as _ext # type: ignore[import-not-found]
except ImportError:
return None, None
return _ext.ffi, _ext.lib
_FFI, _LIB = _try_import_extension()
_INITIALISED = False
[docs]
def is_available() -> bool:
"""True if the native extension was built, loaded, and ships the BC7 target."""
return _LIB is not None and int(_LIB.simvx_basis_is_available()) == 1
[docs]
def astc_available() -> bool:
"""True if the extension shipped the UASTC->ASTC-4x4 target (BASISD_SUPPORT_ASTC).
A BC7-only build (the stale define-set) returns False here, so callers can
verify the full-matrix .so actually shipped rather than silently skipping.
"""
return _LIB is not None and int(_LIB.simvx_basis_astc_available()) == 1
[docs]
def etc2_available() -> bool:
"""True if the extension shipped the UASTC->ETC2-RGBA target (BASISD_SUPPORT_ETC2_EAC_A8)."""
return _LIB is not None and int(_LIB.simvx_basis_etc2_available()) == 1
[docs]
def refresh() -> bool:
"""Re-run the extension import, e.g. after a successful auto-build."""
global _FFI, _LIB, _INITIALISED
_FFI, _LIB = _try_import_extension()
_INITIALISED = False
return is_available()
[docs]
def ensure_built() -> bool:
"""Attempt a one-shot build of the extension if it is not already available.
Honours ``SIMVX_SKIP_TEXTURE_BUILD``: when set, the build is skipped and the
current availability is returned unchanged. Any build failure (no compiler,
missing cffi/setuptools, etc.) is swallowed: the extension simply stays
unavailable and the caller degrades gracefully. Returns the post-attempt
availability.
"""
if is_available():
return True
if os.environ.get("SIMVX_SKIP_TEXTURE_BUILD"):
return False
try:
from . import basis_transcoder_build # noqa: PLC0415
basis_transcoder_build.build(verbose=False)
except Exception as exc: # noqa: BLE001 - first-use best effort
log.debug("basis transcoder first-use build failed: %s", exc)
return False
return refresh()
def _require_lib():
if _LIB is None:
raise BasisTranscoderUnavailable(
"The basis transcoder extension is not built. Run:\n"
" uv run python -m simvx.graphics._native.basis_transcoder_build\n"
"or `simvx build-textures` (a C++ compiler is required)."
)
return _LIB
def _ensure_init() -> None:
"""Idempotent global table init (the transcoder needs it before any call)."""
global _INITIALISED
if not _INITIALISED:
_require_lib().simvx_basis_init()
_INITIALISED = True
def _transcode_uastc(uastc_bytes: bytes, width: int, height: int, *,
fn_name: str, target: str) -> bytes:
"""Shared UASTC -> 16-byte-target transcode (BC7 / ASTC-4x4 / ETC2-RGBA).
All three targets pack 16 bytes per 4x4 block, so the size math and output
buffer are identical; only the native fn differs (``fn_name``). ``target``
names the format for error messages. Raises :class:`BasisTranscoderUnavailable`
if the extension is absent, or ``ValueError`` on a size/transcode failure.
"""
lib = _require_lib()
_ensure_init()
blocks_x = max(1, (width + 3) // 4)
blocks_y = max(1, (height + 3) // 4)
num_blocks = blocks_x * blocks_y
expected_in = num_blocks * 16
if len(uastc_bytes) != expected_in:
raise ValueError(
f"UASTC level size mismatch: {width}x{height} needs {expected_in} bytes "
f"({num_blocks} blocks x 16), got {len(uastc_bytes)}"
)
out_size = int(lib.simvx_basis_uastc_transcoded_size(width, height))
out = bytearray(out_size)
rc = int(
getattr(lib, fn_name)(
_FFI.from_buffer(uastc_bytes), num_blocks, _FFI.from_buffer(out), out_size
)
)
if rc != 0:
raise ValueError(f"UASTC->{target} transcode failed (code {rc}) for {width}x{height}")
return bytes(out)
[docs]
def transcode_uastc_to_bc7(uastc_bytes: bytes, width: int, height: int) -> bytes:
"""Transcode one UASTC level's packed blocks to BC7 (16 B/block).
``uastc_bytes`` is the tightly-packed 16-byte UASTC blocks for a level of
``width`` x ``height`` texels. Returns ``num_blocks * 16`` bytes of BC7.
"""
return _transcode_uastc(uastc_bytes, width, height,
fn_name="simvx_basis_transcode_uastc_to_bc7", target="BC7")
[docs]
def transcode_uastc_to_astc4x4(uastc_bytes: bytes, width: int, height: int) -> bytes:
"""Transcode one UASTC level's packed blocks to ASTC LDR 4x4 (16 B/block).
Requires the .so to have shipped the ASTC target (:func:`astc_available`).
"""
return _transcode_uastc(uastc_bytes, width, height,
fn_name="simvx_basis_transcode_uastc_to_astc4x4", target="ASTC-4x4")
[docs]
def transcode_uastc_to_etc2(uastc_bytes: bytes, width: int, height: int) -> bytes:
"""Transcode one UASTC level's packed blocks to ETC2 RGBA (EAC_A8, 16 B/block).
Requires the .so to have shipped the ETC2 target (:func:`etc2_available`).
"""
return _transcode_uastc(uastc_bytes, width, height,
fn_name="simvx_basis_transcode_uastc_to_etc2", target="ETC2-RGBA")