Source code for simvx.core.animation.sprite

"""Sprite nodes with frame-based animation support."""

import os
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import numpy as np

from ..descriptors import Property
from ..graphics.texture import Texture, is_live_target
from ..graphics.texture_slot import TextureSlot
from ..math.types import Vec2
from ..nodes_2d.node2d import Node2D
from ..properties import Colour
from ..signals import Signal

# ============================================================================
# Native texture size
# ============================================================================

#: Quad size used when a sprite asks for its native size but the texture's pixel
#: dimensions cannot be read (an image container this module does not recognise).
_NATIVE_SIZE_FALLBACK = 64

#: Bytes read from the head of a texture file to find its dimensions. Every
#: supported container puts them in the first few bytes except JPEG, whose frame
#: header sits after the (small) quantisation and Huffman tables.
_HEADER_SCAN_BYTES = 65536

#: Dimensions per (path, mtime, byte size), so sprites sharing a texture read the
#: header once. Keyed on the stat so an asset edited on disk is re-read.
_FILE_SIZE_CACHE: dict[tuple[str, int, int], tuple[int, int] | None] = {}


def _stored_dimension(value: Any) -> Any:
    """A serialised sprite dimension as the ``width`` / ``height`` property spells it.

    ``None`` means "draw at the texture's native size". Saves written before that
    was the spelling say ``0`` instead, and they mean the same thing, so they load
    as ``None`` rather than pinning a zero-area quad. Anything else passes through
    as stored, which is why the return is as untyped as the dict it came from.
    """
    return None if not value else value


def _jpeg_size(data: bytes) -> tuple[int, int] | None:
    """Walk JPEG markers to the start-of-frame segment and read its dimensions."""
    pos, end = 2, len(data)
    while pos + 9 < end:
        if data[pos] != 0xFF:
            pos += 1
            continue
        marker = data[pos + 1]
        if marker == 0xFF:
            pos += 1  # fill byte
            continue
        if marker == 0x01 or 0xD0 <= marker <= 0xD9:
            pos += 2  # standalone marker, no payload
            continue
        # SOF0-SOF15 carry the frame size; DHT/JPG/DAC share the range but do not.
        if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
            height = int.from_bytes(data[pos + 5 : pos + 7], "big")
            width = int.from_bytes(data[pos + 7 : pos + 9], "big")
            return (width, height) if width and height else None
        pos += 2 + int.from_bytes(data[pos + 2 : pos + 4], "big")
    return None


def _webp_size(data: bytes) -> tuple[int, int] | None:
    """Read dimensions from a WebP container (lossy, lossless, or extended)."""
    chunk = data[12:16]
    if chunk == b"VP8 " and len(data) >= 30:
        return int.from_bytes(data[26:28], "little") & 0x3FFF, int.from_bytes(data[28:30], "little") & 0x3FFF
    if chunk == b"VP8L" and len(data) >= 25:
        bits = int.from_bytes(data[21:25], "little")
        return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
    if chunk == b"VP8X" and len(data) >= 30:
        return (
            int.from_bytes(data[24:27], "little") + 1,
            int.from_bytes(data[27:30], "little") + 1,
        )
    return None


def _size_from_header(data: bytes) -> tuple[int, int] | None:
    """Pixel ``(width, height)`` read from an encoded image header, else ``None``.

    Header-only: no pixel decode, no third-party imaging library, so it costs a
    short read regardless of how large the image is. Covers the raster and
    block-compressed containers the texture loaders accept.
    """
    if len(data) < 24:
        return None
    if data[:8] == b"\x89PNG\r\n\x1a\n":
        return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big")
    if data[:3] == b"\xff\xd8\xff":
        return _jpeg_size(data)
    if data[:6] in (b"GIF87a", b"GIF89a"):
        return int.from_bytes(data[6:8], "little"), int.from_bytes(data[8:10], "little")
    if data[:2] == b"BM":
        width = int.from_bytes(data[18:22], "little", signed=True)
        height = int.from_bytes(data[22:26], "little", signed=True)
        return (abs(width), abs(height)) if width and height else None
    if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
        return _webp_size(data)
    if data[:4] == b"DDS ":
        return int.from_bytes(data[16:20], "little"), int.from_bytes(data[12:16], "little")
    if data[:12] == b"\xabKTX 20\xbb\r\n\x1a\n":
        return int.from_bytes(data[20:24], "little"), int.from_bytes(data[24:28], "little")
    return None


def _offscreen_target_size(source: Any) -> tuple[int, int] | None:
    """Pixel ``(width, height)`` of a live offscreen render target, else ``None``.

    Duck-typed on ``texture_size``, which a ``SubViewport`` and a ``RenderView``
    both answer without this module importing either. The value is read fresh
    every time and must never be cached: an offscreen target can be resized
    while it runs (a ``ViewportContainer`` resizes its viewport to match the
    control's rect), and a sprite showing that feed has to follow.
    """
    size = getattr(source, "texture_size", None)
    if size is None:
        return None
    width, height = int(size[0]), int(size[1])
    return (width, height) if width > 0 and height > 0 else None


def _native_size_of(source: Any) -> tuple[int, int] | None:
    """Native pixel ``(width, height)`` of a texture source, or ``None`` if unknown.

    Accepts the same sources a sprite's ``texture`` property does: a live
    offscreen render target, an RGBA ``ndarray`` (dimensions come straight off
    its shape), raw encoded bytes, or a path to an image file. Resolves without
    the GPU, so it works identically on every backend and before (or without)
    the texture ever being uploaded.
    """
    if isinstance(source, Texture):
        size = source.size
        return size if size[0] > 0 and size[1] > 0 else None
    live = _offscreen_target_size(source)
    if live is not None:
        return live
    if isinstance(source, np.ndarray):
        if source.ndim >= 2 and source.shape[0] and source.shape[1]:
            return int(source.shape[1]), int(source.shape[0])
        return None
    if isinstance(source, bytes | bytearray | memoryview):
        return _size_from_header(bytes(source[:_HEADER_SCAN_BYTES]))
    if isinstance(source, str | Path):
        path = str(source)
        if not path:
            return None
        try:
            st = os.stat(path)
        except OSError:
            return None
        key = (path, st.st_mtime_ns, st.st_size)
        if key not in _FILE_SIZE_CACHE:
            try:
                with open(path, "rb") as fh:
                    _FILE_SIZE_CACHE[key] = _size_from_header(fh.read(_HEADER_SCAN_BYTES))
            except OSError:
                _FILE_SIZE_CACHE[key] = None
        return _FILE_SIZE_CACHE[key]
    return None


# ============================================================================
# Sprite2D
# ============================================================================


[docs] class Sprite2D(TextureSlot, Node2D): """2D sprite node -- renders a texture via Draw2D.draw_texture_region(). The ``texture`` property takes a file path, encoded bytes, an RGBA ndarray, a live offscreen target (a ``SubViewport`` or a ``RenderView``), or a :class:`~simvx.core.graphics.Texture` resource. The graphics backend resolves it through TextureManager and hands the resulting handle back via :meth:`~simvx.core.graphics.TextureSlot.publish_texture_slot`. The ``draw()`` callback emits a textured quad through the renderer (Draw2D). Reassigning ``texture`` re-resolves: the cached handle is dropped and the next frame shows the new image. Wrap the source in a ``Texture`` when the PIXELS change rather than the source, and call ``update()``. **A live offscreen target is sampled in display range, not scene range.** A ``SubViewport`` renders to a float target so that 3D content inside it keeps its high-dynamic-range values, but a sprite reads it through the ordinary 8-bit sprite path, so anything the offscreen scene renders brighter than 1.0 is clipped at 1.0 when a sprite samples it, and the result is quantised to 8 bits per channel. The same feed used as a 3D material stays in scene range and is tone-mapped with the rest of the frame, so the two can differ for an HDR-bright source. Author a SubViewport that a sprite samples to land inside 0..1 -- which is what UI, minimaps, portals and camera feeds already do. Attributes: texture: Path to the image file (PNG/JPG). colour: RGBA tint (0.0-1.0 floats). width: Display width in pixels, or ``None`` for the texture's native width. height: Display height in pixels, or ``None`` for the texture's native height. ``None`` is how a sprite asks for the native size; any other value is a literal pixel count, so ``0`` is a zero-area quad and not a request for anything. (A ``0`` read back out of a save written when that *was* how a sprite asked for its native size loads as ``None``; see :meth:`from_dict`.) Read :attr:`draw_size` when what you want is the size on screen, since that answers in pixels whichever way the sprite was set up. """ #: Sprite-sheet grid the texture is divided into when drawing at native size. #: A plain sprite shows the whole image (1x1); ``AnimatedSprite2D`` declares #: the sheet's frame counts as Properties over these. frames_h = 1 frames_v = 1 #: Resolved quad size in pixels, filled on first use and dropped whenever an #: input to it changes. Class-level so it is readable before ``__init__`` #: assigns it (deferred ``on_change`` hooks fire once construction returns). _draw_px: tuple[float, float] | None = None #: The frame grid ``_draw_px`` was resolved against. _draw_grid: tuple[int, int] = (1, 1) #: Native pixel size of ``texture``, or ``None`` until it has been read. _native_px: tuple[int, int] | None = None texture = Property( None, hint="Texture source: file path, PNG bytes, or RGBA uint8 ndarray", on_change="_invalidate_texture_slot", ) colour = Colour((1.0, 1.0, 1.0, 1.0)) width = Property( None, hint="Display width in pixels (None = texture native size)", on_change="_invalidate_draw_size" ) height = Property( None, hint="Display height in pixels (None = texture native size)", on_change="_invalidate_draw_size" ) # ``"linear"``: bilinear filter (default, smooth scaling). # ``"nearest"``: nearest-neighbour, the right choice for pixel-art ports # so up-scaled sprites stay crisp instead of going to mush. Backends that # don't honour the flag fall back to linear silently; the property still # round-trips through serialisation so the authoring intent is preserved. filter = Property("linear", enum=("linear", "nearest"), hint="Texture sampler filter mode") flip_h = Property(False, hint="Flip the sprite horizontally (UV-based, pivot preserved)") flip_v = Property(False, hint="Flip the sprite vertically (UV-based, pivot preserved)") def __init__( self, texture: Any = None, position=None, rotation: float = 0.0, scale=None, colour: tuple = (1.0, 1.0, 1.0, 1.0), width: int | None = None, height: int | None = None, filter: str = "linear", flip_h: bool = False, flip_v: bool = False, **kwargs, ): super().__init__(position=position, rotation=rotation, scale=scale, **kwargs) if texture is not None: self.texture = texture self.colour = colour self.width = width self.height = height self.filter = filter self.flip_h = flip_h self.flip_v = flip_v def _invalidate_draw_size(self) -> None: """Drop the cached quad size after ``width`` / ``height`` changed.""" self._draw_px = None def _invalidate_texture_slot(self) -> None: """Force the GPU texture to reload on next frame after `texture` is reassigned.""" super()._invalidate_texture_slot() self._native_px = None self._draw_px = None # A live offscreen source resolves its bindless slot live each frame: # mark the node dynamic so the item pipeline re-collects it until # the slot lands (it starts -1, then the target's manager publishes it), # and so a content update on the same slot still re-emits. This is the # ``texture=subviewport`` "canvas-on-a-quad" path; a plain path/bytes/ndarray # source stays static (the default, zero extra cost). self.dynamic = getattr(self, "_is_live_texture_source", False) @property def _is_live_texture_source(self) -> bool: """Whether ``texture`` is a live offscreen target rather than an image.""" return is_live_target(self.texture) def _resolved_texture_id(self) -> int: """The bindless slot to draw with, resolving a live offscreen source each draw. ``texture=subviewport`` / ``texture=render_view``: an offscreen target publishes its rendered image as a bindless slot on ``node.texture``, assigned by its manager (``SubViewportManager``, ``RenderViewManager``) once it has rendered. Reading it here each draw means the sprite samples the live feed the moment the slot is valid, with no per-frame ``_texture_id`` poke (the old private-attribute pattern). For a path/bytes/ndarray source this returns the SceneAdapter-resolved ``_texture_id`` unchanged. """ tex = self.texture if is_live_target(tex): return int(tex.texture) return self._texture_id def _native_texture_size(self) -> tuple[int, int]: """The texture's native pixel size, or ``(0, 0)`` when it cannot be read. Cached per texture assignment (``_invalidate_texture_slot`` clears it), and only ever consulted by a sprite that actually asked for its native size, so a sprite with an explicit ``width``/``height`` costs nothing. The size comes from the source image header rather than the GPU, so it is available on the first frame and does not wait on, or force, a texture upload. A live offscreen source (``texture=subviewport``, ``texture=render_view``) is the exception: its resolution is whatever the target is right now, so it is read every call and never cached. """ live = _offscreen_target_size(self.texture) if live is not None: return live cached = self._native_px if cached is None: cached = _native_size_of(self.texture) or (0, 0) self._native_px = cached return cached def _adopt_native_size(self, width: int, height: int) -> None: """Record pixel dimensions measured elsewhere as this texture's native size. For the containers whose headers this module cannot parse, the loaded texture is the only place the real size exists, so the graphics backend hands it back here once the upload lands. It seeds the same cache the header reader fills, and leaves ``width`` / ``height`` untouched, so a sprite drawing at native size keeps saying so. Changing the size has to dirty the node by hand. Retained 2D re-runs ``on_draw`` only for nodes that asked to be redrawn, and the size lives in plain attributes rather than a :class:`Property`, so nothing else signals it: without this the sprite would stay retained at the fallback size for the rest of the scene's life. """ measured = (int(width), int(height)) if measured == self._native_px: return self._native_px = measured self._draw_px = None self.queue_redraw() def _draw_size(self) -> tuple[float, float]: """Quad size in pixels: explicit ``width``/``height``, else texture native. ``None`` on either axis means "take it from the texture". Sprite sheets divide the native dimensions by the frame grid, so one frame is drawn at its own size rather than the whole sheet's. When the texture's dimensions are unreadable the axis falls back to ``_NATIVE_SIZE_FALLBACK``. The result is cached, because this runs once per sprite per frame from ``on_draw``: resolving a native size walks the texture source, which is far more work than a draw call should repeat while nothing has changed. Every input invalidates it -- ``width`` and ``height`` through their change hook, ``texture`` through :meth:`_invalidate_texture_slot`, and the frame grid by comparison here, since it is a plain attribute. A live offscreen source is never cached: it can be resized at any time and the sprite has to follow it. """ grid = (self.frames_h, self.frames_v) cached = self._draw_px if cached is not None and self._draw_grid == grid: return cached w, h = self.width, self.height if w is not None and h is not None: size = (float(w), float(h)) else: native_w, native_h = self._native_texture_size() if native_w > 0 and native_h > 0: native_w = max(1, native_w // (grid[0] or 1)) native_h = max(1, native_h // (grid[1] or 1)) else: native_w = native_h = _NATIVE_SIZE_FALLBACK size = (float(native_w if w is None else w), float(native_h if h is None else h)) if self._is_live_texture_source: return size self._draw_px = size self._draw_grid = grid return size
[docs] @property def draw_size(self) -> Vec2: """Pixel size of the quad this sprite draws, before ``scale``. ``width`` / ``height`` when both are set, otherwise the texture's native dimensions (one cell of the grid for a sprite sheet). Read this instead of ``width`` / ``height`` when you need the size on screen: those two stay ``None`` for a native-size sprite, whereas this always answers in pixels. Hit-testing, editor gizmos and layout code want this one. """ w, h = self._draw_size() return Vec2(w, h)
[docs] def on_draw(self, renderer) -> None: """Emit a textured quad via the renderer (Draw2D). ``flip_h`` / ``flip_v`` flip the source UV rectangle rather than the node's scale, so the sprite pivot stays at ``world_position`` (the Godot semantics ports rely on). """ tex_id = self._resolved_texture_id() if tex_id < 0 or not self.visible: return pos, s, rot = self.world_transform w, h = self._draw_size() u0, u1 = (1.0, 0.0) if self.flip_h else (0.0, 1.0) v0, v1 = (1.0, 0.0) if self.flip_v else (0.0, 1.0) renderer.draw_texture_region( tex_id, (pos.x - w * s.x * 0.5, pos.y - h * s.y * 0.5), (w * s.x, h * s.y), (u0, v0), (u1, v1), colour=self.colour, rotation=rot, )
# ============================================================================ # SpriteAnimation / AnimatedSprite2D # ============================================================================
[docs] @dataclass class SpriteAnimation: """Named sprite animation with frame range.""" name: str frames: list[int] # Frame indices fps: float = 10.0 loop: bool = True
[docs] class AnimatedSprite2D(Sprite2D): """Sprite with frame-based animation from sprite sheets. Inherits from Sprite2D (Node2D), so it participates in the scene tree and gets ``on_update(dt)`` and ``on_draw(renderer)`` called automatically. The sheet grid, the registered animations and whether playback runs are authoring state, so they are Properties and a saved scene carries them. The playback position (``frame``, ``frame_time``) is not: a reloaded scene is the sprite that was set up, not the instant a save happened to catch. Example: sprite = AnimatedSprite2D( texture="player.png", frames_h=4, frames_v=4 ) sprite.add_animation("walk", frames=[0, 1, 2, 3], fps=10, loop=True) sprite.add_animation("jump", frames=[4, 5, 6], fps=15, loop=False) sprite.play("walk") """ frames_h = Property(1, range=(1, 4096), hint="Sprite-sheet columns; below 1 clamps to 1") frames_v = Property(1, range=(1, 4096), hint="Sprite-sheet rows; below 1 clamps to 1") frame_width = Property(None, range=(1, 8192), hint="Manual frame width in pixels (None = derive from the sheet)") frame_height = Property(None, range=(1, 8192), hint="Manual frame height in pixels (None = derive from the sheet)") animations = Property(default_factory=dict, hint="Named animations, keyed by name") current_animation = Property(None, hint="Animation selected by play()") playing = Property(False, hint="Whether playback advances on update") #: Playback position and the one-shot finished flag: state that moves while #: the scene runs and must not be written into a saved file. The callback #: slot is not state at all. __transient__ = frozenset({"frame", "frame_time", "animation_finished", "on_frame_changed"}) def __init__(self, **kwargs): super().__init__(**kwargs) self.frame = 0 # Current frame index self.frame_time = 0.0 # Accumulated time for current frame self.animation_finished = False # Signals self.animation_finished_signal = Signal() self.on_frame_changed: Callable[[int], None] | None = None
[docs] def add_animation(self, name: str, frames: list[int], fps: float = 10.0, loop: bool = True): """Register a named animation.""" self.animations[name] = SpriteAnimation(name, frames, fps, loop)
[docs] def play(self, animation_name: str = "default"): """Play named animation.""" if animation_name not in self.animations: # Fallback: play all frames total_frames = self.frames_h * self.frames_v self.add_animation(animation_name, list(range(total_frames))) self.current_animation = animation_name self.frame = 0 self.frame_time = 0.0 self.playing = True self.animation_finished = False
[docs] def stop(self): """Stop animation and reset to the start of the current animation. ``playing`` becomes ``False`` and the frame counter resets so a subsequent ``play()`` or ``resume()`` begins from frame 0. """ self.playing = False self.frame = 0 self.frame_time = 0.0
[docs] def pause(self): """Pause animation, preserving the current frame and frame time. ``playing`` becomes ``False`` but no state is reset; ``resume()`` continues from where playback left off. """ self.playing = False
[docs] def resume(self): """Resume animation from the current frame.""" self.playing = True
[docs] def on_update(self, dt: float): """Advance sprite animation each frame.""" if not self.playing or not self.current_animation: return anim = self.animations[self.current_animation] self.frame_time += dt frame_duration = 1.0 / anim.fps if anim.fps > 0 else 0.0 while self.frame_time >= frame_duration and frame_duration > 0: self.frame_time -= frame_duration old_frame = self.frame self.frame += 1 # Loop or finish if self.frame >= len(anim.frames): if anim.loop: self.frame = 0 else: self.frame = len(anim.frames) - 1 self.playing = False self.animation_finished = True self.animation_finished_signal() if self.frame != old_frame: # The drawn frame (its UV region) changed: dirty the node so the # retained renderer re-runs on_draw. Fires only on an actual frame # advance (a paused / single-frame sprite stays clean), so it is the # zero-cost-when-static form of the draw contract, not blanket dynamic. self.queue_redraw() if self.on_frame_changed: self.on_frame_changed(self.frame)
[docs] def on_draw(self, renderer) -> None: """Draw the current animation frame as a textured quad with proper UVs. ``flip_h`` / ``flip_v`` (inherited from ``Sprite2D``) swap the UV endpoints: pivot remains at ``world_position`` regardless. The texture resolves through the same path as ``Sprite2D``, so a live offscreen source (a ``SubViewport``, a ``RenderView``) draws the live feed rather than nothing. The frame grid is a plain UV sub-rectangle of whatever is bound, so the default 1x1 grid samples the whole viewport and a larger grid slices it exactly as it slices a sprite sheet. Left at native size, the quad is the viewport's own resolution divided by that grid, and it tracks the viewport if it is resized. """ tex_id = self._resolved_texture_id() if tex_id < 0 or not self.visible: return pos, s, rot = self.world_transform w, h = self._draw_size() uv0, uv1 = self.frame_uv u0, u1 = (uv1.x, uv0.x) if self.flip_h else (uv0.x, uv1.x) v0, v1 = (uv1.y, uv0.y) if self.flip_v else (uv0.y, uv1.y) renderer.draw_texture_region( tex_id, (pos.x - w * s.x * 0.5, pos.y - h * s.y * 0.5), (w * s.x, h * s.y), (u0, v0), (u1, v1), colour=self.colour, rotation=rot, )
[docs] @property def current_frame_index(self) -> int: """Absolute frame index in the sprite sheet.""" if not self.current_animation or self.current_animation not in self.animations: return 0 anim = self.animations[self.current_animation] return anim.frames[self.frame] if self.frame < len(anim.frames) else 0
[docs] @property def frame_uv(self) -> tuple[Vec2, Vec2]: """UV coordinates for the current frame (top-left, bottom-right).""" idx = self.current_frame_index cols, rows = self.frames_h, self.frames_v row = idx // cols col = idx % cols u0 = col / cols v0 = row / rows u1 = (col + 1) / cols v1 = (row + 1) / rows return (Vec2(u0, v0), Vec2(u1, v1))
[docs] @classmethod def from_frames( cls, frames: list[Any] | str | Path, fps: float = 10.0, *, name: str = "default", loop: bool = True, play: bool = True, **kwargs, ) -> AnimatedSprite2D: """Build a flipbook AnimatedSprite2D from a list of frame textures or a folder. ``frames`` accepts any of: * ``list``: each element is a per-frame texture source (file path, PNG bytes, or ``H×W×4`` uint8 ndarray). Frames are stitched into a single horizontal strip atlas. * ``str`` / ``Path`` to a directory: every ``*.png`` (recursive: no, top-level only) is sorted alphabetically and treated as one frame. An empty directory or no PNG files raises ``FileNotFoundError``. The resulting sprite uses a single sheet texture (so it follows the same fast GPU path as a hand-authored atlas: no per-frame upload at runtime) with ``frames_h = N``, ``frames_v = 1``. The animation named ``name`` is registered with all N frames; ``play=True`` starts playback immediately. All frames must have the same pixel dimensions; mismatched sizes raise ``ValueError``. """ frame_list = _resolve_frame_inputs(frames) if not frame_list: raise FileNotFoundError(f"from_frames: no frames provided ({frames!r})") # Decode each frame to an RGBA ndarray, then check sizes. pixels = [_decode_frame(src) for src in frame_list] first_h, first_w = pixels[0].shape[:2] for i, p in enumerate(pixels): if p.shape[:2] != (first_h, first_w): raise ValueError(f"from_frames: frame {i} size {p.shape[:2]} != frame 0 size {(first_h, first_w)}") # Horizontal strip atlas. atlas = np.concatenate(pixels, axis=1) # shape (H, W*N, 4) n = len(pixels) sprite = cls( texture=atlas, frames_h=n, frames_v=1, width=first_w, height=first_h, **kwargs, ) sprite.add_animation(name, frames=list(range(n)), fps=fps, loop=loop) if play: sprite.play(name) return sprite
def _resolve_frame_inputs(frames: list[Any] | str | Path) -> list[Any]: """Normalise ``frames`` to a concrete list of per-frame sources.""" if isinstance(frames, (str, Path)): folder = Path(frames) if not folder.is_dir(): raise FileNotFoundError(f"from_frames: not a directory: {folder}") return sorted(folder.glob("*.png")) return list(frames) def _decode_frame(source: Any) -> np.ndarray: """Return an RGBA uint8 ``(H, W, 4)`` ndarray for any frame source.""" if isinstance(source, np.ndarray): if source.ndim != 3 or source.shape[2] != 4 or source.dtype != np.uint8: raise ValueError( f"from_frames: ndarray frames must be RGBA uint8 (H, W, 4); got {source.shape}/{source.dtype}" ) return source # File path or bytes: defer to PIL (matches TextureManager's loader). try: from PIL import Image except ImportError as exc: raise ImportError("from_frames: PIL is required to decode file/bytes frames") from exc if isinstance(source, (bytes, bytearray, memoryview)): import io img = Image.open(io.BytesIO(bytes(source))).convert("RGBA") else: img = Image.open(str(source)).convert("RGBA") return np.ascontiguousarray(np.array(img, dtype=np.uint8))