Files
Bjorn/web_utils/image_utils.py

438 lines
24 KiB
Python

# image_utils.py
from __future__ import annotations
import os, json, re, shutil, io, logging
from io import BytesIO
from pathlib import Path
from typing import List, Optional
from urllib.parse import urlparse, parse_qs, unquote
from PIL import Image
from logger import Logger
logger = Logger(name="image_utils.py", level=logging.DEBUG)
ALLOWED_IMAGE_EXTS = {'.bmp', '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp'}
class ImageUtils:
"""Utilities for image management (NO persona/character logic here)."""
# Fixed sizes used by the frontend spec for action icons
STATUS_W, STATUS_H = 28, 28
def __init__(self, shared_data, character_utils=None):
self.logger = logger
self.shared_data = shared_data
self.character_utils = character_utils # optional DI for renumber/help
# batch resize options for manual tools
self.should_resize_images = False
self.resize_width = 100
self.resize_height = 100
# dirs
self.status_images_dir = getattr(shared_data, "status_images_dir")
self.static_images_dir = getattr(shared_data, "static_images_dir")
self.web_dir = getattr(shared_data, "web_dir")
self.images_dir = getattr(shared_data, "images_dir", None)
self.web_images_dir = getattr(shared_data, "web_images_dir", os.path.join(self.web_dir, "images"))
self.actions_icons_dir= getattr(shared_data, "actions_icons_dir", os.path.join(self.images_dir or self.web_dir, "actions_icons"))
for d in (self.status_images_dir, self.static_images_dir, self.web_images_dir, self.actions_icons_dir):
try: os.makedirs(d, exist_ok=True)
except Exception: pass
# ---------- helpers ----------
def _to_bmp(self, raw: bytes, w: Optional[int]=None, h: Optional[int]=None) -> bytes:
with Image.open(BytesIO(raw)) as im:
if im.mode != 'RGB': im = im.convert('RGB')
if w and h:
try: res = Image.Resampling.LANCZOS
except AttributeError: res = Image.LANCZOS
im = im.resize((w, h), res)
out = BytesIO(); im.save(out, format='BMP'); return out.getvalue()
def _safe(self, name: str) -> str:
return os.path.basename((name or '').strip().replace('\x00', ''))
def _mime(self, path: str) -> str:
p = path.lower()
if p.endswith('.bmp'): return 'image/bmp'
if p.endswith('.png'): return 'image/png'
if p.endswith('.jpg') or p.endswith('.jpeg'): return 'image/jpeg'
if p.endswith('.gif'): return 'image/gif'
if p.endswith('.ico'): return 'image/x-icon'
if p.endswith('.webp'): return 'image/webp'
return 'application/octet-stream'
def _send_json(self, h, payload: dict, status: int=200):
h.send_response(status); h.send_header('Content-Type','application/json'); h.end_headers()
h.wfile.write(json.dumps(payload).encode('utf-8'))
def _err(self, h, msg: str, code: int=500): self._send_json(h, {'status':'error','message':msg}, code)
def _ensure_action_dir(self, action: str) -> str:
p = os.path.join(self.status_images_dir, action); os.makedirs(p, exist_ok=True); return p
def _list_images(self, directory: str, with_dims: bool=False):
if not os.path.isdir(directory): return []
items = []
for fname in os.listdir(directory):
p = os.path.join(directory, fname)
if not os.path.isfile(p): continue
ext = os.path.splitext(fname)[1].lower()
if ext not in ALLOWED_IMAGE_EXTS: continue
if with_dims:
try:
with Image.open(p) as img: w, h = img.size
items.append({'name': fname, 'width': w, 'height': h})
except Exception:
items.append({'name': fname, 'width': None, 'height': None})
else:
items.append(fname)
return items
# ---------- ACTION (status folder) IMAGES (no characters here) ----------
def get_actions(self, h):
try:
actions = []
for e in os.scandir(self.status_images_dir):
if e.is_dir():
name = e.name
actions.append({'name': name, 'has_status_icon': os.path.exists(os.path.join(e.path, f"{name}.bmp"))})
self._send_json(h, {'status':'success','actions':actions})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def get_action_images(self, h):
try:
q = parse_qs(urlparse(h.path).query); action = (q.get('action',[None])[0] or '').strip()
if not action: raise ValueError('Action parameter is required')
adir = os.path.join(self.status_images_dir, action)
if not os.path.exists(adir): raise FileNotFoundError(f"Action '{action}' does not exist")
images = []
for fn in os.listdir(adir):
if fn.lower().endswith('.bmp'):
p = os.path.join(adir, fn)
try:
with Image.open(p) as img: w, hh = img.size
except Exception: w = hh = None
images.append({'name': fn, 'width': w, 'height': hh})
self._send_json(h, {'status':'success','images':images})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def get_status_icon(self, h):
try:
q = parse_qs(urlparse(h.path).query); action = (q.get('action',[None])[0] or '').strip()
if not action: raise ValueError('action is required')
p = os.path.join(self.status_images_dir, action, f"{action}.bmp")
if not os.path.exists(p): h.send_response(404); h.end_headers(); return
with open(p, 'rb') as f: data = f.read()
h.send_response(200); h.send_header('Content-Type','image/bmp'); h.end_headers(); h.wfile.write(data)
except Exception as e:
self.logger.error(e); h.send_response(404); h.end_headers()
def serve_status_image(self, h):
try:
url_path = unquote(urlparse(h.path).path); prefix = '/images/status/'
if not url_path.startswith(prefix): h.send_error(400, "Bad Request"); return
rel = url_path[len(prefix):]
base = Path(self.status_images_dir).resolve()
target = (base/rel).resolve()
if not str(target).startswith(str(base)): h.send_error(403,"Forbidden"); return
if not target.exists() or not target.is_file(): h.send_error(404,"Image not found"); return
with open(target,'rb') as f: content = f.read()
h.send_response(200); h.send_header('Content-Type', self._mime(str(target)))
h.send_header('Content-Length', str(len(content))); h.end_headers(); h.wfile.write(content)
except Exception as e:
self.logger.error(e); h.send_error(500, "Internal Server Error")
def upload_status_image(self, h):
"""Add/replace <action>/<action>.bmp (always 28x28 BMP)."""
import cgi
try:
ctype, pdict = cgi.parse_header(h.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(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
for key in ('type','action_name','status_image'):
if key not in form: raise ValueError(f'Missing field: {key}')
if (form.getvalue('type') or '').strip() != 'action': raise ValueError("type must be 'action'")
action = (form.getvalue('action_name') or '').strip()
if not action: raise ValueError("action_name is required")
file_item = form['status_image']
if not getattr(file_item,'filename',''): raise ValueError('No file')
adir = self._ensure_action_dir(action)
raw = file_item.file.read()
bmp = self._to_bmp(raw, self.STATUS_W, self.STATUS_H)
with open(os.path.join(adir, f"{action}.bmp"), 'wb') as f: f.write(bmp)
self._send_json(h, {'status':'success','message':'Status image added/updated','path':f"{action}/{action}.bmp"})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
# ---------- STATIC IMAGES ----------
def list_static_images_with_dimensions(self, h):
try:
self._send_json(h, {'status':'success','images': self._list_images(self.static_images_dir, with_dims=True)})
except Exception as e: self.logger.error(e); self._err(h, str(e))
def upload_static_image(self, h):
import cgi
try:
ctype, pdict = cgi.parse_header(h.headers.get('Content-Type'))
if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data')
pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
if 'static_image' not in form or not getattr(form['static_image'],'filename',''): raise ValueError('No static_image provided')
filename = self._safe(form['static_image'].filename); base, _ = os.path.splitext(filename); filename = base + '.bmp'
raw = form['static_image'].file.read()
if self.should_resize_images:
out = self._to_bmp(raw, self.resize_width, self.resize_height)
else:
with Image.open(BytesIO(raw)) as im: w, h = im.size
out = self._to_bmp(raw, w, h)
with open(os.path.join(self.static_images_dir, filename),'wb') as f: f.write(out)
self._send_json(h, {'status':'success','message':'Static image uploaded successfully'})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def serve_static_image(self, h):
try:
path = unquote(urlparse(h.path).path)
name = self._safe(os.path.basename(path))
full = os.path.join(self.static_images_dir, name)
if not os.path.exists(full): raise FileNotFoundError(name)
with open(full,'rb') as f: data = f.read()
h.send_response(200); h.send_header('Content-Type', self._mime(full)); h.end_headers(); h.wfile.write(data)
except Exception as e:
self.logger.error(e); h.send_response(404); h.end_headers()
# ---------- WEB IMAGES & ACTION ICONS ----------
def list_web_images_with_dimensions(self, h):
try: self._send_json(h, {'status':'success','images': self._list_images(self.web_images_dir, with_dims=True)})
except Exception as e: self.logger.error(e); self._err(h, str(e))
def upload_web_image(self, h):
import cgi
try:
ctype, pdict = cgi.parse_header(h.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(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
if 'web_image' not in form or not getattr(form['web_image'],'filename',''): raise ValueError('Aucun fichier web_image fourni')
file_item = form['web_image']; filename = self._safe(file_item.filename)
base, ext = os.path.splitext(filename);
if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png'
data = file_item.file.read()
with open(os.path.join(self.web_images_dir, filename), 'wb') as f: f.write(data)
self._send_json(h, {'status':'success','message':'Web image uploaded','file':filename})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def serve_web_image(self, h):
try:
url_path = unquote(urlparse(h.path).path); prefix='/web/images/'
if not url_path.startswith(prefix): h.send_error(400,"Bad Request"); return
rel = self._safe(url_path[len(prefix):]); target = os.path.join(self.web_images_dir, rel)
if not os.path.isfile(target): h.send_error(404,"Not found"); return
with open(target,'rb') as f: content = f.read()
h.send_response(200); h.send_header('Content-Type', self._mime(target))
h.send_header('Content-Length', str(len(content))); h.end_headers(); h.wfile.write(content)
except Exception as e:
self.logger.error(e); h.send_error(500,"Internal Server Error")
def list_actions_icons_with_dimensions(self, h):
try: self._send_json(h, {'status':'success','images': self._list_images(self.actions_icons_dir, with_dims=True)})
except Exception as e: self.logger.error(e); self._err(h, str(e))
def upload_actions_icon(self, h):
import cgi
try:
ctype, pdict = cgi.parse_header(h.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(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
if 'icon_image' not in form or not getattr(form['icon_image'],'filename',''): raise ValueError('Aucun fichier icon_image fourni')
file_item = form['icon_image']; filename = self._safe(file_item.filename)
base, ext = os.path.splitext(filename);
if ext.lower() not in ALLOWED_IMAGE_EXTS: filename = base + '.png'
data = file_item.file.read()
with open(os.path.join(self.actions_icons_dir, filename),'wb') as f: f.write(data)
self._send_json(h, {'status':'success','message':'Action icon uploaded','file':filename})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def serve_actions_icon(self, h):
try:
rel = h.path[len('/actions_icons/'):].lstrip('/')
rel = os.path.normpath(rel).replace("\\","/")
if rel.startswith("../"): h.send_error(400,"Invalid path"); return
image_path = os.path.join(self.actions_icons_dir, rel)
if not os.path.exists(image_path): h.send_error(404,"Image not found"); return
with open(image_path,'rb') as f: content = f.read()
h.send_response(200); h.send_header('Content-Type', self._mime(image_path))
h.send_header('Content-Length', str(len(content))); h.end_headers(); h.wfile.write(content)
except Exception as e:
self.logger.error(e); h.send_error(500,"Internal Server Error")
# ---------- CRUD that might touch action character files ----------
def delete_images(self, h):
"""Delete images in 'static'|'web'|'icons' or action folder. When type='action', call CharacterUtils to renumber."""
try:
data = json.loads(h.rfile.read(int(h.headers['Content-Length'])).decode('utf-8'))
tp = data.get('type'); action = data.get('action'); names = data.get('image_names', [])
if not tp or not names: raise ValueError('type and image_names are required')
if tp == 'action':
if not action: raise ValueError("action is required for type=action")
base = os.path.join(self.status_images_dir, self._safe(action))
for n in names:
p = os.path.join(base, self._safe(n))
if os.path.exists(p): os.remove(p)
if self.character_utils:
self.character_utils.update_character_image_numbers(action)
elif tp == 'static':
for n in names:
p = os.path.join(self.static_images_dir, self._safe(n))
if os.path.exists(p): os.remove(p)
elif tp == 'web':
for n in names:
p = os.path.join(self.web_images_dir, self._safe(n))
if os.path.exists(p): os.remove(p)
elif tp == 'icons':
for n in names:
p = os.path.join(self.actions_icons_dir, self._safe(n))
if os.path.exists(p): os.remove(p)
else:
raise ValueError("type must be 'action','static','web','icons'")
self._send_json(h, {'status':'success','message':'Images deleted successfully'})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def replace_image(self, h):
"""Replace image. For type='action': status icon here; character images delegated to CharacterUtils."""
import cgi
try:
ctype, pdict = cgi.parse_header(h.headers.get('Content-Type'))
if ctype != 'multipart/form-data': raise ValueError('Content-Type must be multipart/form-data')
pdict['boundary']=bytes(pdict['boundary'],'utf-8'); pdict['CONTENT-LENGTH']=int(h.headers.get('Content-Length'))
form = cgi.FieldStorage(fp=BytesIO(h.rfile.read(pdict['CONTENT-LENGTH'])),
headers=h.headers, environ={'REQUEST_METHOD':'POST'}, keep_blank_values=True)
tp = form.getvalue('type'); image_name = self._safe(form.getvalue('image_name') or '')
file_item = form['new_image'] if 'new_image' in form else None
if not tp or not image_name or not file_item or not getattr(file_item,'filename',''):
raise ValueError('type, image_name and new_image are required')
if tp == 'action':
action = self._safe(form.getvalue('action') or '')
if not action: raise ValueError("action is required for type=action")
# status icon = <action>.bmp -> handle here
if image_name.lower() == f"{action.lower()}.bmp":
base = os.path.join(self.status_images_dir, action)
if not os.path.exists(os.path.join(base, image_name)):
raise FileNotFoundError(f"{image_name} not found")
raw = file_item.file.read()
out = self._to_bmp(raw, self.STATUS_W, self.STATUS_H)
with open(os.path.join(base, image_name),'wb') as f: f.write(out)
else:
# delegate character image replacement
if not self.character_utils:
raise RuntimeError("CharacterUtils not wired into ImageUtils")
return self.character_utils.replace_character_image(h, form, action, image_name)
elif tp == 'static':
path = os.path.join(self.static_images_dir, image_name)
if not os.path.exists(path): raise FileNotFoundError(image_name)
raw = file_item.file.read()
with Image.open(path) as im: w, hh = im.size
out = self._to_bmp(raw, w, hh)
with open(path, 'wb') as f: f.write(out)
elif tp == 'web':
path = os.path.join(self.web_images_dir, image_name)
if not os.path.exists(path): raise FileNotFoundError(image_name)
with open(path,'wb') as f: f.write(file_item.file.read())
elif tp == 'icons':
path = os.path.join(self.actions_icons_dir, image_name)
if not os.path.exists(path): raise FileNotFoundError(image_name)
with open(path,'wb') as f: f.write(file_item.file.read())
else:
raise ValueError("type must be 'action'|'static'|'web'|'icons'")
self._send_json(h, {'status':'success','message':'Image replaced successfully'})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def resize_images(self, h):
"""Batch-resize statics; when 'action' is requested, delegate to CharacterUtils."""
try:
data = json.loads(h.rfile.read(int(h.headers['Content-Length'])).decode('utf-8'))
tp = data.get('type'); action = data.get('action'); names = data.get('image_names', [])
w = int(data.get('width', 100)); hh = int(data.get('height', 100))
if tp == 'static':
base = self.static_images_dir
for n in names:
p = os.path.join(base, self._safe(n))
if not os.path.exists(p): continue
with open(p,'rb') as f: raw=f.read()
with Image.open(BytesIO(raw)) as im: _w,_h = im.size
out = self._to_bmp(raw, w or _w, hh or _h)
with open(p,'wb') as f: f.write(out)
self._send_json(h, {'status':'success'})
elif tp == 'action':
if not self.character_utils:
raise RuntimeError("CharacterUtils not wired into ImageUtils")
return self.character_utils.resize_action_images(h, data)
else:
raise ValueError("Type must be 'static' or 'action'")
except Exception as e:
self.logger.error(e); self._err(h, str(e))
# ---------- misc ----------
def restore_default_images(self, h):
try:
images_dir = getattr(self.shared_data, "images_dir", None)
default_images_dir = getattr(self.shared_data, "default_images_dir", None)
if not default_images_dir or not os.path.exists(default_images_dir):
raise FileNotFoundError(f"Default images directory not found: {default_images_dir}")
if images_dir and os.path.exists(images_dir): shutil.rmtree(images_dir)
shutil.copytree(default_images_dir, images_dir)
self._send_json(h, {'status':'success','message':'Images restored successfully'})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def set_resize_option(self, h):
try:
data = json.loads(h.rfile.read(int(h.headers['Content-Length'])).decode('utf-8'))
self.should_resize_images = bool(data.get('resize', False))
self.resize_width = int(data.get('width', 100))
self.resize_height = int(data.get('height', 100))
self._send_json(h, {'status':'success','message':'Resize options updated'})
except Exception as e:
self.logger.error(e); self._err(h, str(e))
def serve_bjorn_status_image(self, h):
try:
out = io.BytesIO()
self.shared_data.bjorn_status_image.save(out, format="PNG")
data = out.getvalue()
h.send_response(200); h.send_header('Content-Type','image/png'); h.send_header('Cache-Control','no-cache')
h.end_headers(); h.wfile.write(data)
except BrokenPipeError:
pass
except Exception as e:
self.logger.error(e)
def serve_image(self, h):
path = os.path.join(self.shared_data.web_dir, 'screen.png')
try:
with open(path,'rb') as f:
h.send_response(200); h.send_header('Content-type','image/png')
h.send_header('Cache-Control','max-age=0, must-revalidate')
h.end_headers(); h.wfile.write(f.read())
except FileNotFoundError:
h.send_response(404); h.end_headers()
except BrokenPipeError: pass
except Exception as e: self.logger.error(e)