ziomek 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. ziomek-0.1.1/.gitignore +21 -0
  2. ziomek-0.1.1/LICENSE +21 -0
  3. ziomek-0.1.1/PKG-INFO +50 -0
  4. ziomek-0.1.1/pyproject.toml +55 -0
  5. ziomek-0.1.1/src/ziomek/__init__.py +2 -0
  6. ziomek-0.1.1/src/ziomek/api/__init__.py +1 -0
  7. ziomek-0.1.1/src/ziomek/api/avatar.py +136 -0
  8. ziomek-0.1.1/src/ziomek/api/health.py +37 -0
  9. ziomek-0.1.1/src/ziomek/api/tts.py +71 -0
  10. ziomek-0.1.1/src/ziomek/avatar/__init__.py +1 -0
  11. ziomek-0.1.1/src/ziomek/avatar/skins.py +127 -0
  12. ziomek-0.1.1/src/ziomek/avatar/sprites.py +48 -0
  13. ziomek-0.1.1/src/ziomek/avatar/state_machine.py +399 -0
  14. ziomek-0.1.1/src/ziomek/cli.py +82 -0
  15. ziomek-0.1.1/src/ziomek/client/__init__.py +1 -0
  16. ziomek-0.1.1/src/ziomek/client/cli.py +78 -0
  17. ziomek-0.1.1/src/ziomek/client/gamepad.py +86 -0
  18. ziomek-0.1.1/src/ziomek/client/server.py +207 -0
  19. ziomek-0.1.1/src/ziomek/config.py +9 -0
  20. ziomek-0.1.1/src/ziomek/server.py +59 -0
  21. ziomek-0.1.1/src/ziomek/tts/__init__.py +1 -0
  22. ziomek-0.1.1/src/ziomek/tts/engine.py +35 -0
  23. ziomek-0.1.1/src/ziomek/tts/generate.py +37 -0
  24. ziomek-0.1.1/src/ziomek/tts/playback.py +50 -0
  25. ziomek-0.1.1/src/ziomek/tts/voice.py +28 -0
  26. ziomek-0.1.1/tests/__init__.py +1 -0
  27. ziomek-0.1.1/tests/avatar/__init__.py +1 -0
  28. ziomek-0.1.1/tests/avatar/test_api.py +53 -0
  29. ziomek-0.1.1/tests/avatar/test_skins.py +85 -0
  30. ziomek-0.1.1/tests/avatar/test_sprites.py +46 -0
  31. ziomek-0.1.1/tests/avatar/test_state_machine.py +158 -0
  32. ziomek-0.1.1/tests/client/__init__.py +0 -0
  33. ziomek-0.1.1/tests/client/test_gamepad.py +29 -0
  34. ziomek-0.1.1/tests/client/test_server.py +30 -0
  35. ziomek-0.1.1/tests/test_server.py +35 -0
  36. ziomek-0.1.1/tests/test_version.py +6 -0
  37. ziomek-0.1.1/tests/tts/__init__.py +1 -0
  38. ziomek-0.1.1/tests/tts/test_api.py +35 -0
  39. ziomek-0.1.1/tests/tts/test_engine.py +19 -0
  40. ziomek-0.1.1/tests/tts/test_generate.py +44 -0
  41. ziomek-0.1.1/tests/tts/test_playback.py +58 -0
  42. ziomek-0.1.1/tests/tts/test_voice.py +50 -0
@@ -0,0 +1,21 @@
1
+ out/
2
+ node_modules/
3
+ *.vsix
4
+ .DS_Store
5
+ build/
6
+ __pycache__/
7
+ .venv/
8
+ **/.venv/
9
+ *.pyc
10
+ *.egg-info/
11
+ IDEAS.md
12
+ ISSUES.md
13
+ .worktrees/
14
+
15
+ # Internal docs
16
+ docs/internal/
17
+
18
+ # Cache
19
+ .mypy_cache/
20
+ .ruff_cache/
21
+
ziomek-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bartlomiej Cieszkowski
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ziomek-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,50 @@
1
+ Metadata-Version: 2.5
2
+ Name: ziomek
3
+ Version: 0.1.1
4
+ Summary: ziomek - Humanize AI
5
+ Project-URL: Homepage, https://github.com/bartlomiejcieszkowski/ziomek
6
+ Project-URL: Bug Tracker, https://github.com/bartlomiejcieszkowski/ziomek/issues
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Bartlomiej Cieszkowski
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+ License-File: LICENSE
29
+ Keywords: agent,ai,llm,skill
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python :: 3
32
+ Requires-Python: >=3.10
33
+ Requires-Dist: fastapi>=0.100
34
+ Requires-Dist: numpy>=1.24
35
+ Requires-Dist: pillow>=10.0
36
+ Requires-Dist: pocket-tts>=3.0
37
+ Requires-Dist: pydantic>=2.0
38
+ Requires-Dist: pygame-ce>=2.5.8
39
+ Requires-Dist: requests>=2.31
40
+ Requires-Dist: safetensors>=0.4
41
+ Requires-Dist: scipy>=1.10
42
+ Requires-Dist: sounddevice>=0.4
43
+ Requires-Dist: soundfile>=0.12
44
+ Requires-Dist: torch>=2.0
45
+ Requires-Dist: uvicorn[standard]>=0.23
46
+ Provides-Extra: dev
47
+ Requires-Dist: httpx2>=2.0; extra == 'dev'
48
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
49
+ Requires-Dist: pytest>=7.0; extra == 'dev'
50
+ Requires-Dist: ruff>=0.1; extra == 'dev'
@@ -0,0 +1,55 @@
1
+ [project]
2
+ name = "ziomek"
3
+ description = "ziomek - Humanize AI"
4
+ dynamic = ["version"]
5
+ author = [
6
+ {name = "Bartlomiej Cieszkowski", email = "bartlomiej.cieszkowski@gmail.com"}
7
+ ]
8
+ requires-python = ">=3.10"
9
+ keywords = ["llm", "ai", "agent", "skill"]
10
+ license = { file = "LICENSE" }
11
+ classifiers = [
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3",
14
+ ]
15
+
16
+ dependencies = [
17
+ "pocket-tts>=3.0", "torch>=2.0", "numpy>=1.24", "scipy>=1.10",
18
+ "sounddevice>=0.4", "soundfile>=0.12", "safetensors>=0.4",
19
+ "fastapi>=0.100", "uvicorn[standard]>=0.23", "pydantic>=2.0",
20
+ "pillow>=10.0", "requests>=2.31",
21
+ "pygame-ce>=2.5.8",
22
+ ]
23
+
24
+ [project.urls]
25
+ "Homepage" = "https://github.com/bartlomiejcieszkowski/ziomek"
26
+ "Bug Tracker" = "https://github.com/bartlomiejcieszkowski/ziomek/issues"
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "httpx2>=2.0", "ruff>=0.1"]
30
+
31
+ [project.scripts]
32
+ ziomek = "ziomek.cli:main"
33
+
34
+ [build-system]
35
+ requires = ["hatchling"]
36
+ build-backend = "hatchling.build"
37
+
38
+ [tool.hatch.build]
39
+ only-packages = true
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/ziomek"]
43
+
44
+ [tool.ruff]
45
+ line-length = 120
46
+
47
+ [tool.ruff.lint]
48
+ select = ["I"]
49
+
50
+ [tool.hatch.version]
51
+ path = "src/ziomek/__init__.py"
52
+
53
+ [tool.pytest.ini_options]
54
+ log_cli = true
55
+ log_level = "NOTSET"
@@ -0,0 +1,2 @@
1
+ """ziomek — Humanize AI backend server."""
2
+ __version__ = "0.1.1"
@@ -0,0 +1 @@
1
+ """ziomek API package."""
@@ -0,0 +1,136 @@
1
+ """ziomek avatar API endpoints."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, Request
8
+ from fastapi.responses import HTMLResponse, JSONResponse
9
+
10
+ from ziomek.avatar.state_machine import AvatarInput
11
+ from ziomek.avatar.sprites import sprite_to_base64
12
+
13
+
14
+ router = APIRouter()
15
+
16
+ # Path to the shared HTML renderer
17
+ _renderer_html = Path(__file__).parent.parent / "client" / "resources" / "renderer.html"
18
+
19
+
20
+ def _not_initialized(msg: str) -> JSONResponse:
21
+ return JSONResponse(status_code=503, content={"error": msg})
22
+
23
+
24
+ @router.get("/api/avatar/view")
25
+ async def get_avatar_view() -> HTMLResponse:
26
+ """Serve the avatar renderer HTML (fetched by client from server)."""
27
+ if not _renderer_html.exists():
28
+ return HTMLResponse(
29
+ content="<html><body><h1>Renderer not found</h1></body></html>",
30
+ status_code=404,
31
+ )
32
+ return HTMLResponse(content=_renderer_html.read_text())
33
+
34
+
35
+ @router.get("/api/avatar/state")
36
+ async def get_state() -> Any:
37
+ """Get current avatar state."""
38
+ from ziomek.server import _state_machine
39
+ if _state_machine is None:
40
+ return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
41
+ state = _state_machine.tick(16)
42
+ return {
43
+ "expression": state.expressionName,
44
+ "cycleIndex": state.cycleIndex,
45
+ "message": state.message,
46
+ "messageExpiry": state.messageExpiry,
47
+ }
48
+
49
+
50
+ @router.post("/api/avatar/input")
51
+ async def set_input(request: Request) -> Any:
52
+ """Feed gamepad/VSCode state to the state machine."""
53
+ from ziomek.server import _state_machine
54
+ if _state_machine is None:
55
+ return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
56
+ body = await request.json()
57
+ inp = AvatarInput(
58
+ buttons=body.get("buttons", []),
59
+ axes=body.get("axes", [0, 0, 0]),
60
+ connected=body.get("connected", False),
61
+ streaming=body.get("streaming", False),
62
+ chatFocused=body.get("chatFocused", False),
63
+ errorState=body.get("errorState", False),
64
+ )
65
+ _state_machine.update(inp)
66
+ state = _state_machine.tick(16)
67
+ return {"expression": state.expressionName, "cycleIndex": state.cycleIndex}
68
+
69
+
70
+ @router.post("/api/avatar/expression")
71
+ async def set_expression(request: Request) -> Any:
72
+ """Force-set an expression."""
73
+ from ziomek.server import _state_machine
74
+ if _state_machine is None:
75
+ return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
76
+ body = await request.json()
77
+ name = body.get("name", "")
78
+ duration_ms = body.get("duration_ms")
79
+ ok = _state_machine.setExpression(name, duration_ms)
80
+ return {"ok": ok}
81
+
82
+
83
+ @router.post("/api/avatar/speak")
84
+ async def speak(request: Request) -> Any:
85
+ """Generate speech and optionally set an expression."""
86
+ from ziomek.server import _tts_model, _voice_cache, _audio_player, _state_machine
87
+ from ziomek.tts.generate import generate_wav
88
+
89
+ body = await request.json()
90
+ text = body.get("text", "")
91
+ voice = body.get("voice", "default")
92
+ expression = body.get("expression")
93
+
94
+ if _tts_model is None or not _tts_model.ready:
95
+ return JSONResponse(status_code=503, content={"error": "TTS not ready"})
96
+ if _state_machine is None:
97
+ return JSONResponse(status_code=503, content={"error": "State machine not initialized"})
98
+ if _voice_cache is None:
99
+ return JSONResponse(status_code=503, content={"error": "Voice cache not initialized"})
100
+ if expression:
101
+ _state_machine.setExpression(expression)
102
+ audio_b64, duration, sample_rate = generate_wav(_tts_model, _voice_cache, text, voice)
103
+ import base64
104
+ wav_bytes = base64.b64decode(audio_b64)
105
+ if _audio_player is not None:
106
+ _audio_player.play(wav_bytes, sample_rate)
107
+ return {
108
+ "status": "playing",
109
+ "duration": duration,
110
+ "sample_rate": sample_rate,
111
+ "channels": 1,
112
+ "bit_depth": 16,
113
+ "voice": voice,
114
+ }
115
+
116
+
117
+ @router.get("/api/avatar/sprite")
118
+ async def get_sprite() -> Any:
119
+ """Get sprite sheet for client rendering."""
120
+ from ziomek.server import _skin_registry
121
+ if _skin_registry is None:
122
+ return JSONResponse(status_code=503, content={"error": "Skin registry not initialized"})
123
+ skin = _skin_registry.get("single-frame")
124
+ if skin is None:
125
+ return JSONResponse(status_code=404, content={"error": "No default skin"})
126
+ try:
127
+ sprite_data = sprite_to_base64(skin.getSpriteBuffer())
128
+ except FileNotFoundError:
129
+ return JSONResponse(status_code=404, content={"error": "Sprite file not found"})
130
+ return {
131
+ "sprite_b64": sprite_data,
132
+ "width": skin.spriteWidth,
133
+ "height": skin.spriteHeight,
134
+ "frameWidth": skin.frameWidth,
135
+ "frameHeight": skin.frameHeight,
136
+ }
@@ -0,0 +1,37 @@
1
+ """ziomek health endpoints."""
2
+ from __future__ import annotations
3
+
4
+ from fastapi import APIRouter
5
+
6
+ from ziomek.server import _tts_model
7
+
8
+
9
+ router = APIRouter(prefix="/api")
10
+
11
+
12
+ @router.get("/status")
13
+ async def status():
14
+ """Get server health status."""
15
+ ready = _tts_model is not None and _tts_model.ready
16
+ model_loaded = _tts_model is not None and _tts_model.ready
17
+ return {
18
+ "ready": ready,
19
+ "model_loaded": model_loaded,
20
+ "endpoints": {
21
+ "tts": ["POST /api/tts/generate", "POST /api/tts/speak", "GET /api/tts/status", "GET /api/tts/voices"],
22
+ "avatar": ["POST /api/avatar/input", "GET /api/avatar/state", "POST /api/avatar/expression", "GET /api/avatar/sprite", "POST /api/avatar/speak", "GET /api/avatar/view"],
23
+ },
24
+ }
25
+
26
+
27
+ @router.get("/")
28
+ async def root() -> dict:
29
+ """Server root endpoint."""
30
+ return {
31
+ "name": "ziomek",
32
+ "version": "0.1.0",
33
+ "endpoints": {
34
+ "generate": "POST /api/tts/generate",
35
+ "status": "GET /api/status",
36
+ },
37
+ }
@@ -0,0 +1,71 @@
1
+ """ziomek TTS API endpoints."""
2
+ from fastapi import APIRouter, Request
3
+ from fastapi.responses import JSONResponse
4
+
5
+ router = APIRouter(prefix="/api/tts")
6
+
7
+
8
+ @router.get("/status")
9
+ async def tts_status() -> dict:
10
+ """Get TTS server status."""
11
+ from ziomek.server import _tts_model
12
+ ready = _tts_model is not None and _tts_model.ready # type: ignore[arg-type]
13
+ return {"ready": ready, "voices": ["cosette", "marius", "javert", "alba"]}
14
+
15
+
16
+ @router.get("/voices")
17
+ async def list_voices() -> dict:
18
+ """List available voices."""
19
+ return {
20
+ "voices": ["cosette", "marius", "javert", "alba", "jean", "anna", "vera", "fantine",
21
+ "charles", "paul", "eponine", "azelma", "george", "mary", "jane",
22
+ "michael", "eve", "bill_boerst", "peter_yearsley", "stuart_bell",
23
+ "caro_davy", "giovanni", "lola", "juergen", "rafael", "estelle"],
24
+ "default": "cosette",
25
+ }
26
+
27
+
28
+ @router.post("/generate")
29
+ async def tts_generate(request: Request) -> dict:
30
+ """Generate WAV audio from text."""
31
+ from ziomek.server import _tts_model, _voice_cache
32
+ if _tts_model is None or not _tts_model.ready:
33
+ return JSONResponse(status_code=503, content={"error": "TTS model not loaded"})
34
+ body = await request.json()
35
+ text = body.get("text", "")
36
+ voice = body.get("voice", "default")
37
+ from ziomek.tts.generate import generate_wav
38
+ audio_b64, duration, sample_rate = generate_wav(_tts_model, _voice_cache, text, voice)
39
+ return {
40
+ "audio_b64": audio_b64,
41
+ "duration": duration,
42
+ "sample_rate": sample_rate,
43
+ "channels": 1,
44
+ "bit_depth": 16,
45
+ "voice": voice,
46
+ }
47
+
48
+
49
+ @router.post("/speak")
50
+ async def tts_speak(request: Request) -> dict:
51
+ """Generate and play audio."""
52
+ from ziomek.server import _tts_model, _voice_cache, _audio_player
53
+ if _tts_model is None or not _tts_model.ready:
54
+ return JSONResponse(status_code=503, content={"error": "TTS not ready"})
55
+ body = await request.json()
56
+ text = body.get("text", "")
57
+ voice = body.get("voice", "default")
58
+ from ziomek.tts.generate import generate_wav
59
+ audio_b64, duration, sample_rate = generate_wav(_tts_model, _voice_cache, text, voice)
60
+ import base64
61
+ wav_bytes = base64.b64decode(audio_b64)
62
+ if _audio_player is not None: # type: ignore[arg-type]
63
+ _audio_player.play(wav_bytes, sample_rate)
64
+ return {
65
+ "status": "playing",
66
+ "duration": duration,
67
+ "sample_rate": sample_rate,
68
+ "channels": 1,
69
+ "bit_depth": 16,
70
+ "voice": voice,
71
+ }
@@ -0,0 +1 @@
1
+ """ziomek avatar package."""
@@ -0,0 +1,127 @@
1
+ """ziomek avatar skin registry — ported from TypeScript."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ # Row index map: expression name → sprite sheet row
11
+ _ROW_MAP: dict[str, int] = {
12
+ "happy": 0,
13
+ "surprised": 1,
14
+ "curious": 2,
15
+ "looking": 2,
16
+ "thinking": 2,
17
+ "focused": 3,
18
+ "bored": 3,
19
+ "dying": 4,
20
+ "neutral": 1,
21
+ }
22
+
23
+
24
+ @dataclass
25
+ class SingleFrameSkin:
26
+ """A skin that uses a single frame per expression."""
27
+
28
+ name: str
29
+ _sprite_path: str # path to PNG file
30
+ _sprite_buffer: bytes | None = field(default=None, repr=False)
31
+
32
+ _sprite_width: int = 0
33
+ _sprite_height: int = 0
34
+ _frame_width: int = 0
35
+ _frame_height: int = 0
36
+
37
+ @property
38
+ def sprite_path(self) -> str:
39
+ return self._sprite_path
40
+
41
+ def _parse_png(self) -> None:
42
+ """Parse PNG header to extract dimensions and compute frame size."""
43
+ if not self._sprite_buffer:
44
+ return
45
+ # Width: bytes 16-19 (big-endian)
46
+ self._sprite_width = int.from_bytes(self._sprite_buffer[16:20], "big")
47
+ # Height: bytes 20-23 (big-endian)
48
+ self._sprite_height = int.from_bytes(self._sprite_buffer[20:24], "big")
49
+ # Auto-calculate rows from sprite ratio (single column)
50
+ rows = max(1, round(self._sprite_height / self._sprite_width))
51
+ self._frame_width = self._sprite_width
52
+ self._frame_height = self._sprite_height // rows
53
+
54
+ def _load_sprite(self) -> None:
55
+ """Load sprite sheet from disk."""
56
+ if self._sprite_buffer is not None:
57
+ return
58
+ path = Path(self._sprite_path)
59
+ if path.is_file():
60
+ try:
61
+ with open(path, "rb") as f:
62
+ self._sprite_buffer = f.read()
63
+ self._parse_png()
64
+ except OSError as exc:
65
+ logger.warning("Failed to load sprite %s: %s", self._sprite_path, exc)
66
+
67
+ def getFrameRow(self, expression_name: str, cycle_index: int) -> int:
68
+ """Get the sprite sheet row index for an expression."""
69
+ return _ROW_MAP.get(expression_name, _ROW_MAP.get("neutral", 1))
70
+
71
+ def getFrameCount(self, expression_name: str) -> int:
72
+ """Get number of frames for an expression."""
73
+ return 1 # single frame per expression
74
+
75
+ def getExpressionNames(self) -> list[str]:
76
+ return sorted(_ROW_MAP.keys())
77
+
78
+ def getSpriteBuffer(self) -> bytes:
79
+ self._load_sprite()
80
+ if self._sprite_buffer is None:
81
+ raise FileNotFoundError(f"Sprite sheet not found: {self._sprite_path}")
82
+ return self._sprite_buffer
83
+
84
+ @property
85
+ def spriteWidth(self) -> int:
86
+ return self._sprite_width
87
+
88
+ @property
89
+ def spriteHeight(self) -> int:
90
+ return self._sprite_height
91
+
92
+ @property
93
+ def frameWidth(self) -> int:
94
+ return self._frame_width
95
+
96
+ @property
97
+ def frameHeight(self) -> int:
98
+ return self._frame_height
99
+
100
+
101
+ class SkinRegistry:
102
+ """Registry of avatar skins."""
103
+
104
+ _instance: SkinRegistry | None = None
105
+
106
+ def __init__(self) -> None:
107
+ self._skins: dict[str, SingleFrameSkin] = {}
108
+ self._register_default()
109
+
110
+ @classmethod
111
+ def getInstance(cls) -> SkinRegistry:
112
+ if cls._instance is None:
113
+ cls._instance = cls()
114
+ return cls._instance
115
+
116
+ def _register_default(self) -> None:
117
+ default_path = str(Path(__file__).parent.parent / "resources" / "single_frame_sprite_sheet.png")
118
+ self._skins["single-frame"] = SingleFrameSkin(
119
+ name="single-frame",
120
+ _sprite_path=default_path,
121
+ )
122
+
123
+ def get(self, name: str) -> SingleFrameSkin | None:
124
+ return self._skins.get(name)
125
+
126
+ def list(self) -> list[str]:
127
+ return list(self._skins.keys())
@@ -0,0 +1,48 @@
1
+ """ziomek avatar sprite sheet parser."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class SpriteMetadata:
10
+ spriteWidth: int
11
+ spriteHeight: int
12
+ frameWidth: int
13
+ frameHeight: int
14
+ rows: int
15
+
16
+
17
+ def parse_png_sprite(data: bytes) -> SpriteMetadata:
18
+ """Parse a PNG sprite sheet.
19
+
20
+ Extracts dimensions from the PNG header (bytes 16-23 for width/height),
21
+ then calculates frame count from the height/width ratio.
22
+
23
+ Same logic as avatar-panel.ts `_loadSprite()`.
24
+ """
25
+ if len(data) < 24:
26
+ raise ValueError("PNG file too small")
27
+ if data[0] != 0x89 or data[1] != 0x50: # 'P' in PNG magic
28
+ raise ValueError("Not a valid PNG file")
29
+
30
+ sprite_width = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19]
31
+ sprite_height = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23]
32
+
33
+ rows = max(1, round(sprite_height / sprite_width))
34
+ frame_width = sprite_width // 1 # single column
35
+ frame_height = sprite_height // rows
36
+
37
+ return SpriteMetadata(
38
+ spriteWidth=sprite_width,
39
+ spriteHeight=sprite_height,
40
+ frameWidth=frame_width,
41
+ frameHeight=frame_height,
42
+ rows=rows,
43
+ )
44
+
45
+
46
+ def sprite_to_base64(data: bytes) -> str:
47
+ import base64
48
+ return f"data:image/png;base64,{base64.b64encode(data).decode('utf-8')}"