File size: 7,558 Bytes
ce847d4 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
"""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
# ── serialization ────────────────────────────────────────────
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", {})
# ── backward compat: migrate old flat fields into 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)
# ── CRUD ─────────────────────────────────────────────────────
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
# ── helpers ───────────────────────────────────────────────────
def _find_profile_file(self, name: str) -> Path | None:
# Try new base64 encoding first
filepath = self._dir / (self._sanitize_filename(name) + ".json")
if filepath.exists():
return filepath
# Try old simple sanitization for backward compatibility
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("=")
|