[桌宠 UI 与生图资产]:完成桌宠状态展示与项目内透明资产,包含 UI 状态映射、无 GUI fallback、PNG 资产生成脚本和资产校验
This commit is contained in:
@@ -23,6 +23,8 @@ from .conversation import ConversationContext
|
||||
from .llm import MockLlmProvider, OpenAICompatibleLlmProvider
|
||||
from .pipeline import PipelineResult, VoicePipeline
|
||||
from .tts import MacSayTtsProvider, SentenceBuffer, SineTtsProvider
|
||||
from .assets import validate_pet_assets
|
||||
from .ui import ConsolePetWindow, PetStateController, PetVisualState
|
||||
|
||||
__all__ = [
|
||||
"AppConfig",
|
||||
@@ -45,6 +47,10 @@ __all__ = [
|
||||
"MacSayTtsProvider",
|
||||
"SentenceBuffer",
|
||||
"SineTtsProvider",
|
||||
"validate_pet_assets",
|
||||
"ConsolePetWindow",
|
||||
"PetStateController",
|
||||
"PetVisualState",
|
||||
"Message",
|
||||
"PipelineState",
|
||||
"PlaybackResult",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .models import ErrorCode, ProviderError
|
||||
from .ui import PetVisualState
|
||||
|
||||
REQUIRED_ASSET_STATES = tuple(state.value for state in PetVisualState)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PngInfo:
|
||||
width: int
|
||||
height: int
|
||||
color_type: int
|
||||
has_transparency: bool
|
||||
|
||||
|
||||
def load_asset_manifest(asset_dir: Path) -> dict[str, str]:
|
||||
manifest_path = asset_dir / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
raise ProviderError(
|
||||
ErrorCode.ASSET_MISSING,
|
||||
f"asset manifest is missing: {manifest_path}",
|
||||
False,
|
||||
"pet-assets",
|
||||
"assets",
|
||||
)
|
||||
with manifest_path.open("r", encoding="utf-8") as handle:
|
||||
data = json.load(handle)
|
||||
states = data.get("states", {})
|
||||
if not isinstance(states, dict):
|
||||
raise ProviderError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
"asset manifest states must be an object",
|
||||
False,
|
||||
"pet-assets",
|
||||
"assets",
|
||||
)
|
||||
return {str(key): str(value) for key, value in states.items()}
|
||||
|
||||
|
||||
def validate_pet_assets(asset_dir: str | Path) -> list[PngInfo]:
|
||||
root = Path(asset_dir)
|
||||
states = load_asset_manifest(root)
|
||||
missing = [state for state in REQUIRED_ASSET_STATES if state not in states]
|
||||
if missing:
|
||||
raise ProviderError(
|
||||
ErrorCode.ASSET_MISSING,
|
||||
f"asset manifest missing states: {', '.join(missing)}",
|
||||
False,
|
||||
"pet-assets",
|
||||
"assets",
|
||||
)
|
||||
infos: list[PngInfo] = []
|
||||
for state in REQUIRED_ASSET_STATES:
|
||||
path = root / states[state]
|
||||
if not path.exists():
|
||||
raise ProviderError(
|
||||
ErrorCode.ASSET_MISSING,
|
||||
f"asset file missing for {state}: {path}",
|
||||
False,
|
||||
"pet-assets",
|
||||
"assets",
|
||||
)
|
||||
info = read_png_info(path)
|
||||
if info.color_type != 6 or not info.has_transparency:
|
||||
raise ProviderError(
|
||||
ErrorCode.VALIDATION_FAILED,
|
||||
f"asset must be RGBA with transparent pixels: {path}",
|
||||
False,
|
||||
"pet-assets",
|
||||
"assets",
|
||||
)
|
||||
infos.append(info)
|
||||
return infos
|
||||
|
||||
|
||||
def read_png_info(path: str | Path) -> PngInfo:
|
||||
data = Path(path).read_bytes()
|
||||
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
raise ValueError(f"not a PNG file: {path}")
|
||||
offset = 8
|
||||
width = height = color_type = -1
|
||||
idat = bytearray()
|
||||
while offset < len(data):
|
||||
length = struct.unpack(">I", data[offset : offset + 4])[0]
|
||||
offset += 4
|
||||
chunk_type = data[offset : offset + 4]
|
||||
offset += 4
|
||||
chunk_data = data[offset : offset + length]
|
||||
offset += length + 4
|
||||
if chunk_type == b"IHDR":
|
||||
width, height, _bit_depth, color_type, *_ = struct.unpack(">IIBBBBB", chunk_data)
|
||||
elif chunk_type == b"IDAT":
|
||||
idat.extend(chunk_data)
|
||||
elif chunk_type == b"IEND":
|
||||
break
|
||||
has_transparency = False
|
||||
if color_type == 6 and width > 0 and height > 0 and idat:
|
||||
raw = zlib.decompress(bytes(idat))
|
||||
stride = width * 4
|
||||
pos = 0
|
||||
for _ in range(height):
|
||||
filter_type = raw[pos]
|
||||
pos += 1
|
||||
if filter_type != 0:
|
||||
raise ValueError("unsupported PNG filter in generated asset")
|
||||
row = raw[pos : pos + stride]
|
||||
pos += stride
|
||||
if any(row[idx] < 255 for idx in range(3, len(row), 4)):
|
||||
has_transparency = True
|
||||
break
|
||||
return PngInfo(width, height, color_type, has_transparency)
|
||||
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from .models import PipelineState
|
||||
|
||||
|
||||
class PetVisualState(str, Enum):
|
||||
IDLE = "idle"
|
||||
LISTENING = "listening"
|
||||
RECORDING = "recording"
|
||||
THINKING = "thinking"
|
||||
SPEAKING = "speaking"
|
||||
ERROR = "error"
|
||||
|
||||
|
||||
STATE_MAP: dict[PipelineState, PetVisualState] = {
|
||||
PipelineState.IDLE: PetVisualState.IDLE,
|
||||
PipelineState.WAKE_LISTENING: PetVisualState.IDLE,
|
||||
PipelineState.SPEECH_DETECTING: PetVisualState.LISTENING,
|
||||
PipelineState.RECORDING: PetVisualState.RECORDING,
|
||||
PipelineState.TRANSCRIBING: PetVisualState.THINKING,
|
||||
PipelineState.THINKING: PetVisualState.THINKING,
|
||||
PipelineState.SPEAKING: PetVisualState.SPEAKING,
|
||||
PipelineState.INTERRUPTED: PetVisualState.ERROR,
|
||||
PipelineState.ERROR_RECOVERING: PetVisualState.ERROR,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PetStateController:
|
||||
current: PetVisualState = PetVisualState.IDLE
|
||||
|
||||
def apply_pipeline_state(self, state: PipelineState) -> PetVisualState:
|
||||
self.current = STATE_MAP[state]
|
||||
return self.current
|
||||
|
||||
|
||||
class ConsolePetWindow:
|
||||
def __init__(self, controller: PetStateController | None = None) -> None:
|
||||
self.controller = controller or PetStateController()
|
||||
self.history: list[PetVisualState] = []
|
||||
|
||||
def update(self, state: PipelineState) -> PetVisualState:
|
||||
visual = self.controller.apply_pipeline_state(state)
|
||||
self.history.append(visual)
|
||||
return visual
|
||||
|
||||
|
||||
class PySidePetWindow:
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
import PySide6 # type: ignore[import-not-found] # noqa: F401
|
||||
except Exception as exc: # pragma: no cover - optional GUI path
|
||||
raise RuntimeError("PySide6 is not installed; use ConsolePetWindow fallback") from exc
|
||||
Reference in New Issue
Block a user