mirror of
https://github.com/infinition/Bjorn.git
synced 2025-12-13 08:04:59 +00:00
410 lines
18 KiB
Python
410 lines
18 KiB
Python
"""
|
|
Character and persona management utilities.
|
|
Handles character switching, creation, and image management.
|
|
"""
|
|
from __future__ import annotations
|
|
import os
|
|
import re
|
|
import json
|
|
import shutil
|
|
import time
|
|
import logging
|
|
from pathlib import Path
|
|
from io import BytesIO
|
|
from typing import Any, Dict, Optional
|
|
from urllib.parse import urlparse, parse_qs
|
|
|
|
import io
|
|
import cgi
|
|
from PIL import Image
|
|
|
|
from logger import Logger
|
|
|
|
logger = Logger(name="character_utils.py", level=logging.DEBUG)
|
|
|
|
|
|
class CharacterUtils:
|
|
"""Utilities for character/persona management."""
|
|
|
|
def __init__(self, shared_data):
|
|
self.shared_data = shared_data
|
|
self.logger = logger
|
|
|
|
# --------- helpers ---------
|
|
|
|
def _send_error_response(self, handler, message: str, status_code: int = 500):
|
|
handler.send_response(status_code)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'error', 'message': message}).encode('utf-8'))
|
|
|
|
def _to_bmp_bytes(self, raw: bytes, width: int | None = None, height: int | None = None) -> bytes:
|
|
"""Convert any image bytes to BMP (optionally resize)."""
|
|
with Image.open(BytesIO(raw)) as im:
|
|
if im.mode != 'RGB':
|
|
im = im.convert('RGB')
|
|
if width and height:
|
|
try:
|
|
resample = Image.Resampling.LANCZOS
|
|
except AttributeError:
|
|
resample = Image.LANCZOS
|
|
im = im.resize((width, height), resample)
|
|
out = BytesIO()
|
|
im.save(out, format='BMP')
|
|
return out.getvalue()
|
|
|
|
def get_existing_character_numbers(self, action_dir: str | Path, action_name: str) -> set[int]:
|
|
"""
|
|
Retourne l'ensemble des numéros déjà utilisés pour les images characters
|
|
(p. ex. <action>1.bmp, <action>2.bmp, ...).
|
|
"""
|
|
d = Path(action_dir)
|
|
if not d.exists():
|
|
return set()
|
|
nums: set[int] = set()
|
|
pat = re.compile(rf"^{re.escape(action_name)}(\d+)\.bmp$", re.IGNORECASE)
|
|
for p in d.glob("*.bmp"):
|
|
m = pat.match(p.name)
|
|
if m:
|
|
try:
|
|
nums.add(int(m.group(1)))
|
|
except ValueError:
|
|
pass
|
|
return nums
|
|
|
|
# --------- endpoints ---------
|
|
|
|
def get_current_character(self):
|
|
"""Lit le personnage courant depuis la config (DB)."""
|
|
try:
|
|
return self.shared_data.config.get('current_character', 'BJORN') or 'BJORN'
|
|
except Exception:
|
|
return 'BJORN'
|
|
|
|
def serve_bjorn_say(self, handler):
|
|
try:
|
|
bjorn_says_data = {"text": self.shared_data.bjorn_says}
|
|
handler.send_response(200)
|
|
handler.send_header("Content-Type", "application/json")
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps(bjorn_says_data).encode('utf-8'))
|
|
except Exception as e:
|
|
handler.send_response(500)
|
|
handler.send_header("Content-Type", "application/json")
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({"status": "error", "message": str(e)}).encode('utf-8'))
|
|
|
|
def serve_bjorn_character(self, handler):
|
|
try:
|
|
img_byte_arr = io.BytesIO()
|
|
self.shared_data.bjorn_character.save(img_byte_arr, format='PNG')
|
|
img_byte_arr = img_byte_arr.getvalue()
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'image/png')
|
|
handler.send_header('Cache-Control', 'no-cache')
|
|
handler.end_headers()
|
|
handler.wfile.write(img_byte_arr)
|
|
except BrokenPipeError:
|
|
pass
|
|
except Exception as e:
|
|
self.logger.error(f"Error serving status image: {e}")
|
|
|
|
def list_characters(self, handler):
|
|
"""List all available characters with metadata."""
|
|
try:
|
|
characters_dir = self.shared_data.settings_dir
|
|
characters = []
|
|
|
|
for entry in os.scandir(characters_dir):
|
|
if entry.is_dir():
|
|
character_name = entry.name
|
|
idle_image_path = os.path.join(entry.path, 'IDLE', 'IDLE1.bmp') # legacy path?
|
|
has_idle_image = os.path.exists(idle_image_path)
|
|
characters.append({'name': character_name, 'has_idle_image': has_idle_image})
|
|
|
|
current_character = self.get_current_character()
|
|
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
resp = {'status': 'success', 'characters': characters, 'current_character': current_character}
|
|
handler.wfile.write(json.dumps(resp).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in list_characters: {e}")
|
|
self._send_error_response(handler, str(e))
|
|
|
|
def get_character_icon(self, handler):
|
|
"""Serve character icon (IDLE1.bmp)."""
|
|
try:
|
|
query_components = parse_qs(urlparse(handler.path).query)
|
|
character = (query_components.get('character', [None])[0] or '').strip()
|
|
if not character:
|
|
raise ValueError('Character parameter is required')
|
|
|
|
current_character = self.get_current_character()
|
|
if character == current_character:
|
|
# Quand le perso est actif, ses images sont dans status_images_dir/IDLE/IDLE1.bmp
|
|
idle_image_path = os.path.join(self.shared_data.status_images_dir, 'IDLE', 'IDLE1.bmp')
|
|
else:
|
|
idle_image_path = os.path.join(self.shared_data.settings_dir, character, 'status', 'IDLE', 'IDLE1.bmp')
|
|
|
|
if not os.path.exists(idle_image_path):
|
|
raise FileNotFoundError(f"IDLE1.bmp for character '{character}' not found")
|
|
|
|
with open(idle_image_path, 'rb') as f:
|
|
image_data = f.read()
|
|
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'image/bmp')
|
|
handler.end_headers()
|
|
handler.wfile.write(image_data)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in get_character_icon: {e}")
|
|
handler.send_error(404)
|
|
|
|
def create_character(self, handler):
|
|
"""Create a new character by copying current character's images."""
|
|
try:
|
|
content_length = int(handler.headers['Content-Length'])
|
|
post_data = handler.rfile.read(content_length).decode('utf-8')
|
|
data = json.loads(post_data)
|
|
new_character_name = (data.get('character_name') or '').strip()
|
|
|
|
if not new_character_name:
|
|
raise ValueError('Character name is required')
|
|
|
|
new_character_dir = os.path.join(self.shared_data.settings_dir, new_character_name)
|
|
if os.path.exists(new_character_dir):
|
|
raise FileExistsError(f"Character '{new_character_name}' already exists")
|
|
|
|
self.save_current_character_images(new_character_dir)
|
|
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character created successfully'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in create_character: {e}")
|
|
self._send_error_response(handler, str(e))
|
|
|
|
def switch_character(self, handler):
|
|
"""Switch to a different character, saving current modifications first."""
|
|
try:
|
|
content_length = int(handler.headers['Content-Length'])
|
|
post_data = handler.rfile.read(content_length).decode('utf-8')
|
|
data = json.loads(post_data)
|
|
selected_character_name = (data.get('character_name') or '').strip()
|
|
|
|
if not selected_character_name:
|
|
raise ValueError('Character name is required')
|
|
|
|
current_character = self.get_current_character()
|
|
if selected_character_name == current_character:
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character already selected'}).encode('utf-8'))
|
|
return
|
|
|
|
# Save current character's images
|
|
current_character_dir = os.path.join(self.shared_data.settings_dir, current_character)
|
|
self.save_current_character_images(current_character_dir)
|
|
|
|
# Check new character exists
|
|
selected_character_dir = os.path.join(self.shared_data.settings_dir, selected_character_name)
|
|
if not os.path.exists(selected_character_dir):
|
|
raise FileNotFoundError(f"Character '{selected_character_name}' does not exist")
|
|
|
|
# Activate
|
|
self.copy_character_images(
|
|
selected_character_dir,
|
|
self.shared_data.status_images_dir,
|
|
self.shared_data.static_images_dir
|
|
)
|
|
|
|
# Update config
|
|
self.shared_data.config['bjorn_name'] = selected_character_name
|
|
self.shared_data.config['current_character'] = selected_character_name
|
|
self.shared_data.save_config()
|
|
self.shared_data.load_config()
|
|
|
|
time.sleep(1)
|
|
self.shared_data.load_images()
|
|
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character switched successfully'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in switch_character: {e}")
|
|
self._send_error_response(handler, str(e))
|
|
|
|
def delete_character(self, handler):
|
|
"""Delete a character, handling current character case."""
|
|
try:
|
|
content_length = int(handler.headers['Content-Length'])
|
|
post_data = handler.rfile.read(content_length).decode('utf-8')
|
|
data = json.loads(post_data)
|
|
character_name = (data.get('character_name') or '').strip()
|
|
|
|
if not character_name:
|
|
raise ValueError('Character name is required')
|
|
|
|
if character_name == 'BJORN':
|
|
raise ValueError("Cannot delete the default 'BJORN' character")
|
|
|
|
character_dir = os.path.join(self.shared_data.settings_dir, character_name)
|
|
if not os.path.exists(character_dir):
|
|
raise FileNotFoundError(f"Character '{character_name}' does not exist")
|
|
|
|
current_character = self.get_current_character()
|
|
if character_name == current_character:
|
|
bjorn_dir = os.path.join(self.shared_data.settings_dir, 'BJORN')
|
|
if not os.path.exists(bjorn_dir):
|
|
raise FileNotFoundError("Default 'BJORN' character does not exist")
|
|
|
|
self.copy_character_images(
|
|
bjorn_dir,
|
|
self.shared_data.status_images_dir,
|
|
self.shared_data.static_images_dir
|
|
)
|
|
|
|
self.shared_data.config['bjorn_name'] = 'BJORN'
|
|
self.shared_data.config['current_character'] = 'BJORN'
|
|
self.shared_data.save_config()
|
|
self.shared_data.load_config()
|
|
self.shared_data.load_images()
|
|
|
|
shutil.rmtree(character_dir)
|
|
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Character deleted successfully'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in delete_character: {e}")
|
|
self._send_error_response(handler, str(e))
|
|
|
|
def save_current_character_images(self, character_dir):
|
|
"""Save current character's status and static images."""
|
|
try:
|
|
if not os.path.exists(character_dir):
|
|
os.makedirs(character_dir)
|
|
|
|
dest_status_dir = os.path.join(character_dir, 'status')
|
|
if os.path.exists(dest_status_dir):
|
|
shutil.rmtree(dest_status_dir)
|
|
shutil.copytree(self.shared_data.status_images_dir, dest_status_dir)
|
|
|
|
dest_static_dir = os.path.join(character_dir, 'static')
|
|
if os.path.exists(dest_static_dir):
|
|
shutil.rmtree(dest_static_dir)
|
|
shutil.copytree(self.shared_data.static_images_dir, dest_static_dir)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in save_current_character_images: {e}")
|
|
|
|
def copy_character_images(self, source_dir, dest_status_dir, dest_static_dir):
|
|
"""Copy character images from source to destination directories."""
|
|
try:
|
|
source_status_dir = os.path.join(source_dir, 'status')
|
|
if os.path.exists(source_status_dir):
|
|
if os.path.exists(dest_status_dir):
|
|
shutil.rmtree(dest_status_dir)
|
|
shutil.copytree(source_status_dir, dest_status_dir)
|
|
|
|
source_static_dir = os.path.join(source_dir, 'static')
|
|
if os.path.exists(source_static_dir):
|
|
if os.path.exists(dest_static_dir):
|
|
shutil.rmtree(dest_static_dir)
|
|
shutil.copytree(source_static_dir, dest_static_dir)
|
|
except Exception as e:
|
|
self.logger.error(f"Error in copy_character_images: {e}")
|
|
|
|
def upload_character_images(self, handler):
|
|
"""Ajoute des images de characters pour une action existante (toujours BMP + numérotation)."""
|
|
try:
|
|
ctype, pdict = cgi.parse_header(handler.headers.get('Content-Type'))
|
|
if ctype != 'multipart/form-data':
|
|
raise ValueError('Content-Type doit être multipart/form-data')
|
|
|
|
pdict['boundary'] = bytes(pdict['boundary'], "utf-8")
|
|
pdict['CONTENT-LENGTH'] = int(handler.headers.get('Content-Length'))
|
|
|
|
form = cgi.FieldStorage(
|
|
fp=io.BytesIO(handler.rfile.read(pdict['CONTENT-LENGTH'])),
|
|
headers=handler.headers,
|
|
environ={'REQUEST_METHOD': 'POST'},
|
|
keep_blank_values=True
|
|
)
|
|
|
|
if 'action_name' not in form:
|
|
raise ValueError("Le nom de l'action est requis")
|
|
|
|
action_name = (form.getvalue('action_name') or '').strip()
|
|
if not action_name:
|
|
raise ValueError("Le nom de l'action est requis")
|
|
|
|
if 'character_images' not in form:
|
|
raise ValueError('Aucun fichier image fourni')
|
|
|
|
action_dir = os.path.join(self.shared_data.status_images_dir, action_name)
|
|
if not os.path.exists(action_dir):
|
|
raise FileNotFoundError(f"L'action '{action_name}' n'existe pas")
|
|
|
|
existing_numbers = self.get_existing_character_numbers(action_dir, action_name)
|
|
next_number = max(existing_numbers, default=0) + 1
|
|
|
|
file_items = form['character_images']
|
|
if not isinstance(file_items, list):
|
|
file_items = [file_items]
|
|
|
|
for file_item in file_items:
|
|
if not getattr(file_item, 'filename', ''):
|
|
continue
|
|
raw = file_item.file.read()
|
|
bmp = self._to_bmp_bytes(raw)
|
|
out_path = os.path.join(action_dir, f"{action_name}{next_number}.bmp")
|
|
with open(out_path, 'wb') as f:
|
|
f.write(bmp)
|
|
next_number += 1
|
|
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images de characters ajoutées avec succès'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Erreur dans upload_character_images: {e}")
|
|
import traceback
|
|
self.logger.error(traceback.format_exc())
|
|
self._send_error_response(handler, str(e))
|
|
|
|
|
|
def reload_fonts(self, handler):
|
|
"""Recharge les fonts en exécutant load_fonts."""
|
|
try:
|
|
self.shared_data.load_fonts()
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Fonts loaded successfully.'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in load_fonts: {e}")
|
|
handler.send_response(500)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8'))
|
|
|
|
def reload_images(self, handler):
|
|
"""Recharge les images en exécutant load_images."""
|
|
try:
|
|
self.shared_data.load_images()
|
|
handler.send_response(200)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'success', 'message': 'Images rechargées avec succès.'}).encode('utf-8'))
|
|
except Exception as e:
|
|
self.logger.error(f"Error in reload_images: {e}")
|
|
handler.send_response(500)
|
|
handler.send_header('Content-Type', 'application/json')
|
|
handler.end_headers()
|
|
handler.wfile.write(json.dumps({'status': 'error', 'message': str(e)}).encode('utf-8')) |