|
|
"""Capture profile management — save/load screen region coordinates per game. |
|
|
|
|
|
Profiles are stored as JSON files in working_space/temp/profiles/. |
|
|
Each profile holds a name, regions, and a flat settings dict with ALL UI state. |
|
|
|
|
|
Usage: |
|
|
from src.services.ocr.profiles import ProfileManager, CaptureProfile, CaptureRegion |
|
|
|
|
|
mgr = ProfileManager() |
|
|
profile = CaptureProfile( |
|
|
name="Elden Ring", |
|
|
regions=[CaptureRegion(x=100, y=900, width=800, height=200, label="subtitles")], |
|
|
settings={"target_lang": "pl", "tts_engine": "edge", "tts_voice": "pl-PL-ZofiaNeural"}, |
|
|
) |
|
|
mgr.save_profile(profile) |
|
|
loaded = mgr.load_profile("Elden Ring") |
|
|
""" |
|
|
|
|
|
from __future__ import annotations |
|
|
|
|
|
import base64 |
|
|
import json |
|
|
import re |
|
|
from dataclasses import asdict, dataclass, field |
|
|
from datetime import datetime |
|
|
from pathlib import Path |
|
|
from typing import Any |
|
|
|
|
|
from src.utils.logger import logger |
|
|
|
|
|
_PROFILES_DIR = ( |
|
|
Path(__file__).resolve().parent.parent.parent.parent |
|
|
/ "working_space" |
|
|
/ "temp" |
|
|
/ "profiles" |
|
|
) |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class CaptureRegion: |
|
|
"""A single screen capture region (rectangle).""" |
|
|
|
|
|
x: int |
|
|
y: int |
|
|
width: int |
|
|
height: int |
|
|
label: str = "default" |
|
|
enabled: bool = True |
|
|
|
|
|
@property |
|
|
def as_tuple(self) -> tuple[int, int, int, int]: |
|
|
return (self.x, self.y, self.width, self.height) |
|
|
|
|
|
def __str__(self) -> str: |
|
|
return f"{self.label}: ({self.x}, {self.y}, {self.width}x{self.height})" |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class CaptureProfile: |
|
|
"""A named capture profile. |
|
|
|
|
|
Attributes: |
|
|
name: Profile display name (also used to derive filename). |
|
|
regions: List of capture regions. |
|
|
settings: Flat dict holding **every** UI setting |
|
|
(target_lang, tts_engine, tts_voice, capture_interval, …). |
|
|
created_at: ISO timestamp of creation. |
|
|
updated_at: ISO timestamp of last update. |
|
|
""" |
|
|
|
|
|
name: str |
|
|
regions: list[CaptureRegion] = field(default_factory=list) |
|
|
settings: dict[str, Any] = field(default_factory=dict) |
|
|
created_at: str = "" |
|
|
updated_at: str = "" |
|
|
|
|
|
def __post_init__(self) -> None: |
|
|
now = datetime.now().isoformat() |
|
|
if not self.created_at: |
|
|
self.created_at = now |
|
|
if not self.updated_at: |
|
|
self.updated_at = now |
|
|
|
|
|
|
|
|
|
|
|
def to_dict(self) -> dict[str, Any]: |
|
|
return { |
|
|
"name": self.name, |
|
|
"regions": [asdict(r) for r in self.regions], |
|
|
"settings": self.settings, |
|
|
"created_at": self.created_at, |
|
|
"updated_at": self.updated_at, |
|
|
} |
|
|
|
|
|
@classmethod |
|
|
def from_dict(cls, data: dict[str, Any]) -> CaptureProfile: |
|
|
regions = [CaptureRegion(**r) for r in data.get("regions", [])] |
|
|
settings = data.get("settings", {}) |
|
|
|
|
|
|
|
|
_old_keys = { |
|
|
"source_lang", "target_lang", "capture_mode", "monitor_index", |
|
|
"interval", "tts_backend", "tts_voice", "description", |
|
|
} |
|
|
for key in _old_keys: |
|
|
if key in data and key not in settings: |
|
|
settings[key] = data[key] |
|
|
|
|
|
return cls( |
|
|
name=data.get("name", ""), |
|
|
regions=regions, |
|
|
settings=settings, |
|
|
created_at=data.get("created_at", ""), |
|
|
updated_at=data.get("updated_at", ""), |
|
|
) |
|
|
|
|
|
@property |
|
|
def active_regions(self) -> list[CaptureRegion]: |
|
|
return [r for r in self.regions if r.enabled] |
|
|
|
|
|
|
|
|
class ProfileManager: |
|
|
"""Manage capture profiles (save/load/list/delete).""" |
|
|
|
|
|
def __init__(self, profiles_dir: str | Path | None = None) -> None: |
|
|
self._dir = Path(profiles_dir) if profiles_dir else _PROFILES_DIR |
|
|
self._dir.mkdir(parents=True, exist_ok=True) |
|
|
|
|
|
|
|
|
|
|
|
def save_profile(self, profile: CaptureProfile) -> Path: |
|
|
"""Save profile to JSON file.""" |
|
|
profile.updated_at = datetime.now().isoformat() |
|
|
filepath = self._dir / (self._sanitize_filename(profile.name) + ".json") |
|
|
filepath.write_text( |
|
|
json.dumps(profile.to_dict(), indent=2, ensure_ascii=False), |
|
|
encoding="utf-8", |
|
|
) |
|
|
logger.info(f"Profile saved: {profile.name} → {filepath}") |
|
|
return filepath |
|
|
|
|
|
def load_profile(self, name: str) -> CaptureProfile | None: |
|
|
"""Load profile by name.""" |
|
|
filepath = self._find_profile_file(name) |
|
|
if filepath is None: |
|
|
logger.warning(f"Profile not found: {name}") |
|
|
return None |
|
|
try: |
|
|
data = json.loads(filepath.read_text(encoding="utf-8")) |
|
|
return CaptureProfile.from_dict(data) |
|
|
except Exception as e: |
|
|
logger.error(f"Failed to load profile {name}: {e}") |
|
|
return None |
|
|
|
|
|
def list_profiles(self) -> list[str]: |
|
|
"""List all saved profile names.""" |
|
|
profiles: list[str] = [] |
|
|
for f in sorted(self._dir.glob("*.json")): |
|
|
try: |
|
|
encoded = f.stem |
|
|
padding = (4 - len(encoded) % 4) % 4 |
|
|
name = base64.urlsafe_b64decode(encoded + "=" * padding).decode("utf-8") |
|
|
profiles.append(name) |
|
|
except Exception: |
|
|
profiles.append(f.stem) |
|
|
return profiles |
|
|
|
|
|
def delete_profile(self, name: str) -> bool: |
|
|
"""Delete a profile by name.""" |
|
|
filepath = self._find_profile_file(name) |
|
|
if filepath and filepath.exists(): |
|
|
filepath.unlink() |
|
|
logger.info(f"Profile deleted: {name}") |
|
|
return True |
|
|
return False |
|
|
|
|
|
def update_profile_settings(self, name: str, settings: dict[str, Any]) -> bool: |
|
|
"""Merge *settings* into an existing profile and re-save.""" |
|
|
profile = self.load_profile(name) |
|
|
if profile is None: |
|
|
return False |
|
|
profile.settings.update(settings) |
|
|
self.save_profile(profile) |
|
|
return True |
|
|
|
|
|
def update_profile_regions(self, name: str, regions: list[dict[str, Any]]) -> bool: |
|
|
"""Replace regions in an existing profile and re-save.""" |
|
|
profile = self.load_profile(name) |
|
|
if profile is None: |
|
|
return False |
|
|
profile.regions = [CaptureRegion(**r) for r in regions] |
|
|
self.save_profile(profile) |
|
|
return True |
|
|
|
|
|
|
|
|
|
|
|
def _find_profile_file(self, name: str) -> Path | None: |
|
|
|
|
|
filepath = self._dir / (self._sanitize_filename(name) + ".json") |
|
|
if filepath.exists(): |
|
|
return filepath |
|
|
|
|
|
old = re.sub(r'[<>:"/\\|?*]', "_", name.strip()).rstrip(".") + ".json" |
|
|
old_path = self._dir / old |
|
|
if old_path.exists(): |
|
|
return old_path |
|
|
return None |
|
|
|
|
|
@staticmethod |
|
|
def _sanitize_filename(name: str) -> str: |
|
|
"""Base64url encode the profile name so case is preserved on NTFS.""" |
|
|
return base64.urlsafe_b64encode(name.strip().encode("utf-8")).decode("ascii").rstrip("=") |
|
|
|