Images Editor Pass 1
This commit is contained in:
parent
d1023178fe
commit
35d5d5968a
9 changed files with 1385 additions and 16 deletions
|
|
@ -454,7 +454,7 @@ class BatchTab(QWidget):
|
|||
tt.select_files_by_name(file_set)
|
||||
# Switch to Translation page.
|
||||
if hasattr(parent, "switch_page"):
|
||||
page = getattr(parent, "PAGE_TRANSLATION", 2)
|
||||
page = getattr(parent, "PAGE_TRANSLATION", 3)
|
||||
parent.switch_page(page)
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -316,5 +316,5 @@ class GuideTab(QWidget):
|
|||
def _open_config(self) -> None:
|
||||
pw = self.parent_window
|
||||
if pw is not None and hasattr(pw, "switch_page"):
|
||||
page = getattr(pw, "PAGE_CONFIG", 5)
|
||||
page = getattr(pw, "PAGE_CONFIG", 6)
|
||||
pw.switch_page(page)
|
||||
|
|
|
|||
31
gui/main.py
31
gui/main.py
|
|
@ -707,6 +707,7 @@ from gui.guide_tab import GuideTab
|
|||
from gui.translation_tab import TranslationTab
|
||||
from gui.workflow_tab import WorkflowTab
|
||||
from gui.wolf_workflow_tab import WolfWorkflowTab
|
||||
from gui.rpgmaker_image_manager import RPGMakerImageManager
|
||||
from gui.skills_tab import SkillsTab
|
||||
from gui.batch_tab import BatchTab
|
||||
|
||||
|
|
@ -715,10 +716,11 @@ class DazedMTLGUI(QMainWindow):
|
|||
|
||||
PAGE_GUIDE = 0
|
||||
PAGE_WORKFLOW = 1
|
||||
PAGE_TRANSLATION = 2
|
||||
PAGE_BATCHES = 3
|
||||
PAGE_SKILLS = 4
|
||||
PAGE_CONFIG = 5
|
||||
PAGE_IMAGES = 2
|
||||
PAGE_TRANSLATION = 3
|
||||
PAGE_BATCHES = 4
|
||||
PAGE_SKILLS = 5
|
||||
PAGE_CONFIG = 6
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
|
@ -961,6 +963,13 @@ class DazedMTLGUI(QMainWindow):
|
|||
sidebar_layout.addWidget(btn_workflow)
|
||||
self.nav_buttons.append(btn_workflow)
|
||||
|
||||
# RPG Maker image decryption / patch selection
|
||||
btn_images = self.create_nav_button("🖼", "Images")
|
||||
btn_images.setToolTip("Images - decrypt, preview, encrypt, and add MV/MZ images to patches")
|
||||
btn_images.clicked.connect(lambda: self.switch_page(self.PAGE_IMAGES))
|
||||
sidebar_layout.addWidget(btn_images)
|
||||
self.nav_buttons.append(btn_images)
|
||||
|
||||
# Translation
|
||||
btn_translation = self.create_nav_button("🌐", "Translation")
|
||||
btn_translation.clicked.connect(lambda: self.switch_page(self.PAGE_TRANSLATION))
|
||||
|
|
@ -1027,6 +1036,8 @@ class DazedMTLGUI(QMainWindow):
|
|||
|
||||
def switch_page(self, index):
|
||||
"""Switch to the specified page and update button states."""
|
||||
if index == self.PAGE_IMAGES and hasattr(self, "image_manager_tab"):
|
||||
self.image_manager_tab.refresh_game_root_from_settings()
|
||||
self.content_stack.setCurrentIndex(index)
|
||||
|
||||
# Update button checked states
|
||||
|
|
@ -1045,19 +1056,23 @@ class DazedMTLGUI(QMainWindow):
|
|||
# RPGMaker and Wolf guided panels while keeping a single sidebar button.
|
||||
self.content_stack.addWidget(self._create_workflow_container())
|
||||
|
||||
# Translation Execution Tab (index 2)
|
||||
# RPG Maker MV/MZ Image Manager (index 2)
|
||||
self.image_manager_tab = RPGMakerImageManager(parent=self)
|
||||
self.content_stack.addWidget(self.image_manager_tab)
|
||||
|
||||
# Translation Execution Tab (index 3)
|
||||
self.translation_tab = TranslationTab(self)
|
||||
self.content_stack.addWidget(self.translation_tab)
|
||||
|
||||
# Batch History Tab (index 3)
|
||||
# Batch History Tab (index 4)
|
||||
self.batch_tab = BatchTab(self)
|
||||
self.content_stack.addWidget(self.batch_tab)
|
||||
|
||||
# Skills Tab (index 4)
|
||||
# Skills Tab (index 5)
|
||||
self.skills_tab = SkillsTab(self)
|
||||
self.content_stack.addWidget(self.skills_tab)
|
||||
|
||||
# Configuration Tab (index 5)
|
||||
# Configuration Tab (index 6)
|
||||
self.config_tab = ConfigTab()
|
||||
self.config_tab.config_changed.connect(self.on_config_changed)
|
||||
self.content_stack.addWidget(self.config_tab)
|
||||
|
|
|
|||
672
gui/rpgmaker_image_manager.py
Normal file
672
gui/rpgmaker_image_manager.py
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
"""Visual RPG Maker MV/MZ image decrypt/encrypt and patch manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt, QSize, QThread, pyqtSignal, QSettings
|
||||
from PyQt5.QtGui import QColor, QIcon, QPixmap
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QAbstractItemView,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from util.paths import APP_NAME, ORG_NAME
|
||||
|
||||
from util.rpgmaker_images import (
|
||||
ImageAsset,
|
||||
decrypt_assets,
|
||||
encrypt_assets,
|
||||
prepare_assets_for_patch,
|
||||
preview_png_bytes,
|
||||
read_encryption_key,
|
||||
scan_image_assets,
|
||||
thumbnail_png_bytes,
|
||||
)
|
||||
|
||||
|
||||
_ASSET_ID_ROLE = Qt.UserRole + 1
|
||||
_PAGE_SIZE = 80
|
||||
|
||||
|
||||
class _ImageScanWorker(QThread):
|
||||
done = pyqtSignal(int, object)
|
||||
error = pyqtSignal(int, str)
|
||||
|
||||
def __init__(self, generation: int, game_root: Path):
|
||||
super().__init__()
|
||||
self.generation = generation
|
||||
self.game_root = game_root
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.done.emit(self.generation, scan_image_assets(self.game_root))
|
||||
except Exception as exc:
|
||||
self.error.emit(self.generation, str(exc))
|
||||
|
||||
|
||||
class _ThumbnailWorker(QThread):
|
||||
done = pyqtSignal(int, object)
|
||||
|
||||
def __init__(self, generation: int, assets: list[ImageAsset], key: bytes | None):
|
||||
super().__init__()
|
||||
self.generation = generation
|
||||
self.assets = assets
|
||||
self.key = key
|
||||
|
||||
def run(self) -> None:
|
||||
thumbnails: dict[str, bytes] = {}
|
||||
for asset in self.assets:
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
data = thumbnail_png_bytes(asset, self.key, size=112)
|
||||
except Exception:
|
||||
data = b""
|
||||
thumbnails[asset.asset_id] = data
|
||||
self.done.emit(self.generation, thumbnails)
|
||||
|
||||
|
||||
class _ImageActionWorker(QThread):
|
||||
status = pyqtSignal(str)
|
||||
done = pyqtSignal(str, object)
|
||||
error = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action: str,
|
||||
game_root: Path,
|
||||
assets: list[ImageAsset],
|
||||
key: bytes | None,
|
||||
):
|
||||
super().__init__()
|
||||
self.action = action
|
||||
self.game_root = game_root
|
||||
self.assets = assets
|
||||
self.key = key
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
if self.action == "decrypt":
|
||||
result = decrypt_assets(
|
||||
self.assets, self.key, overwrite=False, progress=self.status.emit
|
||||
)
|
||||
elif self.action == "encrypt":
|
||||
result = encrypt_assets(
|
||||
self.game_root, self.assets, self.key, progress=self.status.emit
|
||||
)
|
||||
else:
|
||||
result = prepare_assets_for_patch(
|
||||
self.game_root, self.assets, self.key, progress=self.status.emit
|
||||
)
|
||||
self.done.emit(self.action, result)
|
||||
except Exception as exc:
|
||||
self.error.emit(str(exc))
|
||||
|
||||
|
||||
class RPGMakerImageManager(QWidget):
|
||||
"""Paginated contact sheet for projects containing thousands of images."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
game_root: str | Path | None = None,
|
||||
parent=None,
|
||||
settings: QSettings | None = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.settings = settings or QSettings(ORG_NAME, APP_NAME)
|
||||
saved_root = str(self.settings.value("workflow/last_game_folder", "") or "")
|
||||
initial_root = str(game_root or saved_root).strip()
|
||||
self.game_root = Path(initial_root).expanduser().resolve() if initial_root else None
|
||||
self.assets: list[ImageAsset] = []
|
||||
self.assets_by_id: dict[str, ImageAsset] = {}
|
||||
self.filtered_assets: list[ImageAsset] = []
|
||||
self.selected_ids: set[str] = set()
|
||||
self.page = 0
|
||||
self.key: bytes | None = None
|
||||
self._scan_worker: _ImageScanWorker | None = None
|
||||
self._scan_workers: list[_ImageScanWorker] = []
|
||||
self._scan_generation = 0
|
||||
self._thumbnail_worker: _ThumbnailWorker | None = None
|
||||
self._thumbnail_workers: list[_ThumbnailWorker] = []
|
||||
self._action_worker: _ImageActionWorker | None = None
|
||||
self._thumbnail_generation = 0
|
||||
|
||||
self._build_ui()
|
||||
if self.game_root:
|
||||
self.folder_edit.setText(str(self.game_root))
|
||||
self._load_project()
|
||||
else:
|
||||
self.status_label.setText("Select an RPG Maker MV/MZ game folder to begin.")
|
||||
self._set_actions_enabled(False)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(18, 14, 18, 12)
|
||||
title = QLabel("RPG Maker MV/MZ Image Manager")
|
||||
title.setStyleSheet("font-size:18px;font-weight:bold;color:#e0e0e0;")
|
||||
root.addWidget(title)
|
||||
intro = QLabel(
|
||||
"Browse encrypted images visually, create editable PNG copies, then select only "
|
||||
"the translated images you want in the patch. Pages keep very large games responsive."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
intro.setStyleSheet("color:#b8b8b8;font-size:13px;padding:2px 0 6px 0;")
|
||||
root.addWidget(intro)
|
||||
|
||||
folder_row = QHBoxLayout()
|
||||
folder_row.addWidget(QLabel("Game folder:"))
|
||||
self.folder_edit = QLineEdit()
|
||||
self.folder_edit.setPlaceholderText("Select an RPG Maker MV/MZ game folder…")
|
||||
self.folder_edit.returnPressed.connect(self._load_project)
|
||||
folder_row.addWidget(self.folder_edit, 1)
|
||||
browse_button = QPushButton("Browse…")
|
||||
browse_button.clicked.connect(self._browse_game_root)
|
||||
folder_row.addWidget(browse_button)
|
||||
load_button = QPushButton("Load")
|
||||
load_button.clicked.connect(self._load_project)
|
||||
folder_row.addWidget(load_button)
|
||||
root.addLayout(folder_row)
|
||||
|
||||
filters = QHBoxLayout()
|
||||
self.search_edit = QLineEdit()
|
||||
self.search_edit.setPlaceholderText("Filter by any part of the folder or filename…")
|
||||
self.search_edit.textChanged.connect(self._apply_filters)
|
||||
filters.addWidget(self.search_edit, 2)
|
||||
self.folder_combo = QComboBox()
|
||||
self.folder_combo.addItem("All folders", "")
|
||||
self.folder_combo.currentIndexChanged.connect(self._apply_filters)
|
||||
filters.addWidget(self.folder_combo, 1)
|
||||
self.state_combo = QComboBox()
|
||||
self.state_combo.addItem("All images", "all")
|
||||
self.state_combo.addItem("Encrypted", "encrypted")
|
||||
self.state_combo.addItem("Editable PNG ready", "plain")
|
||||
self.state_combo.addItem("Unencrypted PNG", "plain_only")
|
||||
self.state_combo.currentIndexChanged.connect(self._apply_filters)
|
||||
filters.addWidget(self.state_combo)
|
||||
root.addLayout(filters)
|
||||
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
self.image_list = QListWidget()
|
||||
self.image_list.setViewMode(QListWidget.IconMode)
|
||||
self.image_list.setResizeMode(QListWidget.Adjust)
|
||||
self.image_list.setMovement(QListWidget.Static)
|
||||
self.image_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||
self.image_list.setIconSize(QSize(112, 112))
|
||||
self.image_list.setGridSize(QSize(160, 160))
|
||||
self.image_list.setUniformItemSizes(True)
|
||||
self.image_list.setWordWrap(True)
|
||||
self.image_list.setSpacing(4)
|
||||
self.image_list.setObjectName("rpgImageList")
|
||||
self.image_list.setStyleSheet(
|
||||
"QListWidget#rpgImageList{background:#1e1e1e;color:#fff;"
|
||||
"border:1px solid #555;padding:3px;outline:none;}"
|
||||
"QListWidget#rpgImageList::item{border:1px solid transparent;"
|
||||
"padding:3px;background:transparent;}"
|
||||
"QListWidget#rpgImageList::item:selected{background:#264f78;"
|
||||
"border-color:#4b8dc0;color:#fff;}"
|
||||
"QListWidget#rpgImageList::item:hover:!selected{background:#333;"
|
||||
"border-color:#555;}"
|
||||
)
|
||||
self.image_list.itemSelectionChanged.connect(self._selection_changed)
|
||||
self.image_list.currentItemChanged.connect(self._show_preview)
|
||||
self.image_list.setToolTip(
|
||||
"Select images normally. Ctrl-click toggles individual images, Shift-click selects "
|
||||
"a range, and Ctrl+A selects the current page."
|
||||
)
|
||||
splitter.addWidget(self.image_list)
|
||||
|
||||
preview_host = QWidget()
|
||||
preview_layout = QVBoxLayout(preview_host)
|
||||
self.preview_label = QLabel("Select an image to preview it")
|
||||
self.preview_label.setAlignment(Qt.AlignCenter)
|
||||
self.preview_label.setMinimumSize(300, 300)
|
||||
self.preview_label.setStyleSheet(
|
||||
"background:#171717;border:1px solid #3c3c3c;color:#777;"
|
||||
)
|
||||
preview_layout.addWidget(self.preview_label, 1)
|
||||
self.path_label = QLabel()
|
||||
self.path_label.setWordWrap(True)
|
||||
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_label.setStyleSheet("color:#c8c8c8;font-size:12px;padding:5px;")
|
||||
preview_layout.addWidget(self.path_label)
|
||||
splitter.addWidget(preview_host)
|
||||
splitter.setSizes([850, 350])
|
||||
root.addWidget(splitter, 1)
|
||||
|
||||
page_row = QHBoxLayout()
|
||||
select_page = QPushButton("Select page")
|
||||
select_page.clicked.connect(self._select_page)
|
||||
page_row.addWidget(select_page)
|
||||
clear_selection = QPushButton("Clear selection")
|
||||
clear_selection.clicked.connect(self._clear_selection)
|
||||
page_row.addWidget(clear_selection)
|
||||
page_row.addStretch()
|
||||
self.previous_button = QPushButton("← Previous")
|
||||
self.previous_button.clicked.connect(lambda: self._change_page(-1))
|
||||
page_row.addWidget(self.previous_button)
|
||||
self.page_label = QLabel("Page 0 / 0")
|
||||
self.page_label.setAlignment(Qt.AlignCenter)
|
||||
self.page_label.setMinimumWidth(210)
|
||||
page_row.addWidget(self.page_label)
|
||||
self.next_button = QPushButton("Next →")
|
||||
self.next_button.clicked.connect(lambda: self._change_page(1))
|
||||
page_row.addWidget(self.next_button)
|
||||
root.addLayout(page_row)
|
||||
|
||||
action_row = QHBoxLayout()
|
||||
self.decrypt_selected_button = QPushButton("Decrypt selected")
|
||||
self.decrypt_selected_button.clicked.connect(self._decrypt_checked)
|
||||
action_row.addWidget(self.decrypt_selected_button)
|
||||
self.decrypt_all_button = QPushButton("Decrypt all encrypted")
|
||||
self.decrypt_all_button.clicked.connect(self._decrypt_all)
|
||||
action_row.addWidget(self.decrypt_all_button)
|
||||
self.encrypt_button = QPushButton("Encrypt selected")
|
||||
self.encrypt_button.setToolTip(
|
||||
"Rebuild selected encrypted runtime files from their editable PNG copies."
|
||||
)
|
||||
self.encrypt_button.clicked.connect(self._encrypt_checked)
|
||||
action_row.addWidget(self.encrypt_button)
|
||||
self.prepare_button = QPushButton("Encrypt + patch selected")
|
||||
self.prepare_button.setStyleSheet(
|
||||
"QPushButton{border:1px solid #4ec9b0;color:#4ec9b0;font-weight:bold;padding:6px 14px;}"
|
||||
"QPushButton:hover{background:#18352f;}"
|
||||
)
|
||||
self.prepare_button.setToolTip(
|
||||
"Re-encrypt selected edited PNGs, preserve original encrypted files in "
|
||||
".dazedtl/image_backups, and add exact .gitignore allow-rules."
|
||||
)
|
||||
self.prepare_button.clicked.connect(self._prepare_checked)
|
||||
action_row.addWidget(self.prepare_button)
|
||||
action_row.addStretch()
|
||||
root.addLayout(action_row)
|
||||
|
||||
self.status_label = QLabel("Scanning image folders…")
|
||||
self.status_label.setWordWrap(True)
|
||||
self.status_label.setStyleSheet("color:#8fbc8f;padding:4px 0;")
|
||||
root.addWidget(self.status_label)
|
||||
|
||||
def _browse_game_root(self) -> None:
|
||||
start = self.folder_edit.text().strip() or str(Path.home())
|
||||
folder = QFileDialog.getExistingDirectory(self, "Select RPG Maker Game Folder", start)
|
||||
if folder:
|
||||
self.folder_edit.setText(folder)
|
||||
self._load_project()
|
||||
|
||||
def _load_project(self) -> None:
|
||||
raw_path = self.folder_edit.text().strip()
|
||||
if not raw_path:
|
||||
return
|
||||
root = Path(raw_path).expanduser().resolve()
|
||||
if not root.is_dir():
|
||||
QMessageBox.warning(self, "Game Folder", f"Folder not found:\n{root}")
|
||||
return
|
||||
self.game_root = root
|
||||
self.settings.setValue("workflow/last_game_folder", str(root))
|
||||
self.selected_ids.clear()
|
||||
self._load_key()
|
||||
self._start_scan()
|
||||
|
||||
def refresh_game_root_from_settings(self) -> None:
|
||||
"""Load a game newly selected in the RPG Maker Workflow tab."""
|
||||
saved = str(self.settings.value("workflow/last_game_folder", "") or "").strip()
|
||||
if not saved:
|
||||
return
|
||||
resolved = Path(saved).expanduser().resolve()
|
||||
if resolved == self.game_root:
|
||||
return
|
||||
self.folder_edit.setText(str(resolved))
|
||||
self._load_project()
|
||||
|
||||
def _load_key(self) -> None:
|
||||
try:
|
||||
self.key = read_encryption_key(self.game_root)
|
||||
except Exception:
|
||||
self.key = None
|
||||
|
||||
def _start_scan(self) -> None:
|
||||
if self.game_root is None:
|
||||
return
|
||||
self._set_actions_enabled(False)
|
||||
self.status_label.setText("Scanning image folders…")
|
||||
self._scan_generation += 1
|
||||
worker = _ImageScanWorker(self._scan_generation, self.game_root)
|
||||
worker.done.connect(self._scan_done)
|
||||
worker.error.connect(self._scan_error)
|
||||
worker.finished.connect(lambda w=worker: self._forget_scan_worker(w))
|
||||
self._scan_workers.append(worker)
|
||||
self._scan_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _forget_scan_worker(self, worker: _ImageScanWorker) -> None:
|
||||
if worker in self._scan_workers:
|
||||
self._scan_workers.remove(worker)
|
||||
|
||||
def _scan_done(self, generation: int, assets: list[ImageAsset]) -> None:
|
||||
if generation != self._scan_generation:
|
||||
return
|
||||
self._set_actions_enabled(True)
|
||||
self.assets = assets
|
||||
self.assets_by_id = {asset.asset_id: asset for asset in assets}
|
||||
self.selected_ids.intersection_update(self.assets_by_id)
|
||||
self.folder_combo.blockSignals(True)
|
||||
current_folder = self.folder_combo.currentData() or ""
|
||||
self.folder_combo.clear()
|
||||
self.folder_combo.addItem("All folders", "")
|
||||
folders = sorted(
|
||||
{asset.relative_png.parent.as_posix() for asset in assets}, key=str.casefold
|
||||
)
|
||||
for folder in folders:
|
||||
self.folder_combo.addItem(folder, folder)
|
||||
index = self.folder_combo.findData(current_folder)
|
||||
self.folder_combo.setCurrentIndex(max(0, index))
|
||||
self.folder_combo.blockSignals(False)
|
||||
encrypted = sum(asset.has_encrypted for asset in assets)
|
||||
editable = sum(asset.has_plain and asset.has_encrypted for asset in assets)
|
||||
self.status_label.setText(
|
||||
f"Found {len(assets):,} images · {encrypted:,} encrypted · "
|
||||
f"{editable:,} editable PNG copies · {len(self.selected_ids):,} selected"
|
||||
)
|
||||
self._apply_filters()
|
||||
|
||||
def _scan_error(self, generation: int, message: str) -> None:
|
||||
if generation != self._scan_generation:
|
||||
return
|
||||
self._set_actions_enabled(False)
|
||||
self.status_label.setText(f"Image scan failed: {message}")
|
||||
QMessageBox.critical(self, "Image Scan Failed", message)
|
||||
|
||||
def _apply_filters(self) -> None:
|
||||
query = self.search_edit.text().strip().casefold()
|
||||
folder = self.folder_combo.currentData() or ""
|
||||
state = self.state_combo.currentData()
|
||||
filtered: list[ImageAsset] = []
|
||||
for asset in self.assets:
|
||||
if query and query not in asset.asset_id.casefold():
|
||||
continue
|
||||
if folder and asset.relative_png.parent.as_posix() != folder:
|
||||
continue
|
||||
if state == "encrypted" and not asset.has_encrypted:
|
||||
continue
|
||||
if state == "plain" and not (asset.has_plain and asset.has_encrypted):
|
||||
continue
|
||||
if state == "plain_only" and not (asset.has_plain and not asset.has_encrypted):
|
||||
continue
|
||||
filtered.append(asset)
|
||||
self.filtered_assets = filtered
|
||||
self.page = 0
|
||||
self._render_page()
|
||||
|
||||
def _page_assets(self) -> list[ImageAsset]:
|
||||
start = self.page * _PAGE_SIZE
|
||||
return self.filtered_assets[start:start + _PAGE_SIZE]
|
||||
|
||||
def _render_page(self) -> None:
|
||||
if self._thumbnail_worker and self._thumbnail_worker.isRunning():
|
||||
self._thumbnail_worker.requestInterruption()
|
||||
page_count = max(1, (len(self.filtered_assets) + _PAGE_SIZE - 1) // _PAGE_SIZE)
|
||||
self.page = min(self.page, page_count - 1)
|
||||
page_assets = self._page_assets()
|
||||
self.image_list.blockSignals(True)
|
||||
self.image_list.clear()
|
||||
placeholder = QPixmap(112, 112)
|
||||
placeholder.fill(QColor("#292929"))
|
||||
placeholder_icon = QIcon(placeholder)
|
||||
for asset in page_assets:
|
||||
label = asset.relative_png.name
|
||||
item = QListWidgetItem(placeholder_icon, label)
|
||||
item.setData(_ASSET_ID_ROLE, asset.asset_id)
|
||||
kind = "encrypted + PNG" if asset.has_encrypted and asset.has_plain else (
|
||||
"encrypted" if asset.has_encrypted else "PNG"
|
||||
)
|
||||
item.setToolTip(f"{asset.asset_id}\n{kind}")
|
||||
self.image_list.addItem(item)
|
||||
item.setSelected(asset.asset_id in self.selected_ids)
|
||||
self.image_list.blockSignals(False)
|
||||
self.page_label.setText(
|
||||
f"Page {self.page + 1} / {page_count} · {len(self.filtered_assets):,} matches"
|
||||
)
|
||||
self.previous_button.setEnabled(self.page > 0)
|
||||
self.next_button.setEnabled(self.page + 1 < page_count)
|
||||
self._thumbnail_generation += 1
|
||||
worker = _ThumbnailWorker(self._thumbnail_generation, page_assets, self.key)
|
||||
worker.done.connect(self._thumbnails_ready)
|
||||
worker.finished.connect(lambda w=worker: self._forget_thumbnail_worker(w))
|
||||
self._thumbnail_workers.append(worker)
|
||||
self._thumbnail_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _forget_thumbnail_worker(self, worker: _ThumbnailWorker) -> None:
|
||||
if worker in self._thumbnail_workers:
|
||||
self._thumbnail_workers.remove(worker)
|
||||
|
||||
def _thumbnails_ready(self, generation: int, thumbnails: dict[str, bytes]) -> None:
|
||||
if generation != self._thumbnail_generation:
|
||||
return
|
||||
self.image_list.setUpdatesEnabled(False)
|
||||
try:
|
||||
for index in range(self.image_list.count()):
|
||||
item = self.image_list.item(index)
|
||||
data = thumbnails.get(item.data(_ASSET_ID_ROLE), b"")
|
||||
if not data:
|
||||
continue
|
||||
pixmap = QPixmap()
|
||||
if pixmap.loadFromData(data, "PNG"):
|
||||
item.setIcon(QIcon(pixmap))
|
||||
self.image_list.doItemsLayout()
|
||||
finally:
|
||||
self.image_list.setUpdatesEnabled(True)
|
||||
self.image_list.viewport().update()
|
||||
|
||||
def _change_page(self, delta: int) -> None:
|
||||
self.page += delta
|
||||
self._render_page()
|
||||
|
||||
def _selection_changed(self) -> None:
|
||||
page_ids = {
|
||||
self.image_list.item(index).data(_ASSET_ID_ROLE)
|
||||
for index in range(self.image_list.count())
|
||||
}
|
||||
selected_on_page = {
|
||||
item.data(_ASSET_ID_ROLE) for item in self.image_list.selectedItems()
|
||||
}
|
||||
self.selected_ids.difference_update(page_ids)
|
||||
self.selected_ids.update(selected_on_page)
|
||||
self._update_selection_status()
|
||||
|
||||
def _select_page(self) -> None:
|
||||
self.image_list.blockSignals(True)
|
||||
for index in range(self.image_list.count()):
|
||||
item = self.image_list.item(index)
|
||||
item.setSelected(True)
|
||||
self.selected_ids.add(item.data(_ASSET_ID_ROLE))
|
||||
self.image_list.blockSignals(False)
|
||||
self._update_selection_status()
|
||||
|
||||
def _clear_selection(self) -> None:
|
||||
self.selected_ids.clear()
|
||||
self.image_list.clearSelection()
|
||||
self._update_selection_status()
|
||||
|
||||
def _update_selection_status(self) -> None:
|
||||
self.status_label.setText(
|
||||
f"{len(self.filtered_assets):,} matching images · {len(self.selected_ids):,} selected"
|
||||
)
|
||||
|
||||
def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None:
|
||||
if current is None:
|
||||
return
|
||||
asset = self.assets_by_id.get(current.data(_ASSET_ID_ROLE))
|
||||
if asset is None:
|
||||
return
|
||||
try:
|
||||
raw = preview_png_bytes(asset, self.key)
|
||||
pixmap = QPixmap()
|
||||
if not pixmap.loadFromData(raw, "PNG"):
|
||||
raise ValueError("Qt could not decode this PNG")
|
||||
self.preview_label.setPixmap(
|
||||
pixmap.scaled(
|
||||
self.preview_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
self.preview_label.setPixmap(QPixmap())
|
||||
self.preview_label.setText(f"Preview unavailable\n{exc}")
|
||||
details = [asset.asset_id]
|
||||
if asset.has_encrypted:
|
||||
details.append(f"Encrypted: {asset.encrypted_path.name}")
|
||||
details.append("Editable PNG: ready" if asset.has_plain else "Editable PNG: not decrypted")
|
||||
self.path_label.setText("\n".join(details))
|
||||
|
||||
def _selected_assets(self) -> list[ImageAsset]:
|
||||
return [self.assets_by_id[key] for key in self.selected_ids if key in self.assets_by_id]
|
||||
|
||||
def _ensure_key(self) -> bool:
|
||||
if self.key is not None:
|
||||
return True
|
||||
try:
|
||||
self.key = read_encryption_key(self.game_root)
|
||||
return True
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, "Encryption Key Not Found", str(exc))
|
||||
return False
|
||||
|
||||
def _decrypt_checked(self) -> None:
|
||||
assets = [asset for asset in self._selected_assets() if asset.has_encrypted]
|
||||
if not assets:
|
||||
QMessageBox.information(
|
||||
self, "Nothing Selected", "Select one or more encrypted images first."
|
||||
)
|
||||
return
|
||||
if self._ensure_key():
|
||||
self._start_action("decrypt", assets)
|
||||
|
||||
def _decrypt_all(self) -> None:
|
||||
assets = [asset for asset in self.assets if asset.has_encrypted and not asset.has_plain]
|
||||
if not assets:
|
||||
QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.")
|
||||
return
|
||||
if self._ensure_key():
|
||||
self._start_action("decrypt", assets)
|
||||
|
||||
def _prepare_checked(self) -> None:
|
||||
assets = self._selected_assets()
|
||||
if not assets:
|
||||
QMessageBox.information(
|
||||
self, "Nothing Selected", "Select the translated images to include first."
|
||||
)
|
||||
return
|
||||
if any(asset.has_encrypted for asset in assets) and not self._ensure_key():
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Prepare Images for Patch",
|
||||
f"Prepare exactly {len(assets):,} selected image(s)?\n\n"
|
||||
"Encrypted images will be rebuilt from their editable PNG. Their original encrypted "
|
||||
"files are backed up once under .dazedtl/image_backups. Exact allow-rules are then "
|
||||
"added to the applicable .gitignore files.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if answer == QMessageBox.Yes:
|
||||
self._start_action("prepare", assets)
|
||||
|
||||
def _encrypt_checked(self) -> None:
|
||||
assets = [
|
||||
asset for asset in self._selected_assets()
|
||||
if asset.has_encrypted and asset.has_plain
|
||||
]
|
||||
if not assets:
|
||||
QMessageBox.information(
|
||||
self, "Nothing Ready", "Select images with editable PNG copies first."
|
||||
)
|
||||
return
|
||||
if not self._ensure_key():
|
||||
return
|
||||
answer = QMessageBox.question(
|
||||
self,
|
||||
"Encrypt Images",
|
||||
f"Rebuild {len(assets):,} selected encrypted image(s) from their PNG copies?\n\n"
|
||||
"Original encrypted files are backed up once under .dazedtl/image_backups.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No,
|
||||
)
|
||||
if answer == QMessageBox.Yes:
|
||||
self._start_action("encrypt", assets)
|
||||
|
||||
def _start_action(self, action: str, assets: list[ImageAsset]) -> None:
|
||||
self._set_actions_enabled(False)
|
||||
worker = _ImageActionWorker(action, self.game_root, assets, self.key)
|
||||
worker.status.connect(self.status_label.setText)
|
||||
worker.done.connect(self._action_done)
|
||||
worker.error.connect(self._action_error)
|
||||
self._action_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _action_done(self, action: str, result) -> None:
|
||||
if action == "decrypt":
|
||||
summary = f"Decrypted {result.completed:,} image(s); skipped {result.skipped:,}."
|
||||
elif action == "encrypt":
|
||||
summary = f"Encrypted {result.completed:,} image(s)."
|
||||
else:
|
||||
summary = (
|
||||
f"Prepared {result.completed:,} image(s) for the patch and updated "
|
||||
f"{len(result.gitignore_files):,} .gitignore file(s)."
|
||||
)
|
||||
if result.errors:
|
||||
shown = "\n".join(result.errors[:12])
|
||||
if len(result.errors) > 12:
|
||||
shown += f"\n…and {len(result.errors) - 12} more"
|
||||
QMessageBox.warning(self, "Image Action Completed with Errors", f"{summary}\n\n{shown}")
|
||||
else:
|
||||
QMessageBox.information(self, "Image Action Complete", summary)
|
||||
self.status_label.setText(summary)
|
||||
self._start_scan()
|
||||
|
||||
def _action_error(self, message: str) -> None:
|
||||
self.status_label.setText(f"Image action failed: {message}")
|
||||
self._set_actions_enabled(True)
|
||||
QMessageBox.critical(self, "Image Action Failed", message)
|
||||
|
||||
def _set_actions_enabled(self, enabled: bool) -> None:
|
||||
for button in (
|
||||
self.decrypt_selected_button,
|
||||
self.decrypt_all_button,
|
||||
self.encrypt_button,
|
||||
self.prepare_button,
|
||||
):
|
||||
button.setEnabled(enabled)
|
||||
|
||||
def closeEvent(self, event) -> None:
|
||||
running = [
|
||||
worker for worker in (
|
||||
*self._scan_workers, *self._thumbnail_workers, self._action_worker
|
||||
)
|
||||
if worker is not None and worker.isRunning()
|
||||
]
|
||||
action_running = self._action_worker is not None and self._action_worker.isRunning()
|
||||
if action_running:
|
||||
QMessageBox.information(
|
||||
self, "Image Action Running", "Wait for the current decrypt/encrypt action to finish."
|
||||
)
|
||||
event.ignore()
|
||||
return
|
||||
for worker in running:
|
||||
worker.requestInterruption()
|
||||
worker.wait(1000)
|
||||
if any(worker.isRunning() for worker in running):
|
||||
self.status_label.setText("Finishing image scan/preview before closing…")
|
||||
event.ignore()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
|
|
@ -2281,13 +2281,13 @@ class WolfWorkflowTab(QWidget):
|
|||
pass
|
||||
|
||||
if pw and hasattr(pw, "switch_page"):
|
||||
page = getattr(pw, "PAGE_TRANSLATION", 2)
|
||||
page = getattr(pw, "PAGE_TRANSLATION", 3)
|
||||
pw.switch_page(page)
|
||||
elif pw and hasattr(pw, "content_stack"):
|
||||
pw.content_stack.setCurrentIndex(2)
|
||||
pw.content_stack.setCurrentIndex(3)
|
||||
if hasattr(pw, "nav_buttons"):
|
||||
for i, btn in enumerate(pw.nav_buttons):
|
||||
btn.setChecked(i == 2)
|
||||
btn.setChecked(i == 3)
|
||||
|
||||
if auto_start:
|
||||
QTimer.singleShot(
|
||||
|
|
|
|||
|
|
@ -3700,13 +3700,13 @@ class WorkflowTab(QWidget):
|
|||
|
||||
# 5. Navigate to Translation tab
|
||||
if hasattr(pw, "switch_page"):
|
||||
page = getattr(pw, "PAGE_TRANSLATION", 2)
|
||||
page = getattr(pw, "PAGE_TRANSLATION", 3)
|
||||
pw.switch_page(page)
|
||||
elif hasattr(pw, "content_stack"):
|
||||
pw.content_stack.setCurrentIndex(2)
|
||||
pw.content_stack.setCurrentIndex(3)
|
||||
if hasattr(pw, "nav_buttons"):
|
||||
for i, btn in enumerate(pw.nav_buttons):
|
||||
btn.setChecked(i == 2)
|
||||
btn.setChecked(i == 3)
|
||||
|
||||
# 6. Auto-start translation so the user doesn't need an extra click
|
||||
if auto_start:
|
||||
|
|
|
|||
135
tests/test_rpgmaker_image_manager.py
Normal file
135
tests/test_rpgmaker_image_manager.py
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PIL import Image
|
||||
from PyQt5.QtCore import QItemSelectionModel, QSettings, Qt
|
||||
from PyQt5.QtTest import QTest
|
||||
from PyQt5.QtWidgets import QApplication, QAbstractItemView
|
||||
|
||||
from gui.rpgmaker_image_manager import RPGMakerImageManager
|
||||
|
||||
|
||||
class RPGMakerImageManagerSelectionTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.temp.name)
|
||||
self.game_root = root / "Game"
|
||||
image_dir = self.game_root / "img" / "pictures"
|
||||
data_dir = self.game_root / "data"
|
||||
image_dir.mkdir(parents=True)
|
||||
data_dir.mkdir(parents=True)
|
||||
data_dir.joinpath("System.json").write_text(
|
||||
'{"encryptionKey":"00112233445566778899aabbccddeeff"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
for index in range(4):
|
||||
Image.new("RGBA", (24, 24), (index * 40, 10, 20, 255)).save(
|
||||
image_dir / f"image{index}.png"
|
||||
)
|
||||
settings = QSettings(str(root / "settings.ini"), QSettings.IniFormat)
|
||||
self.manager = RPGMakerImageManager(self.game_root, settings=settings)
|
||||
self.manager.resize(1100, 760)
|
||||
self.manager.show()
|
||||
self.manager._scan_worker.wait(5000)
|
||||
self.app.processEvents()
|
||||
|
||||
def tearDown(self):
|
||||
for worker in list(self.manager._thumbnail_workers):
|
||||
worker.wait(5000)
|
||||
self.manager.close()
|
||||
self.app.processEvents()
|
||||
self.temp.cleanup()
|
||||
|
||||
def _click(self, index: int, modifiers=Qt.NoModifier):
|
||||
item = self.manager.image_list.item(index)
|
||||
rect = self.manager.image_list.visualItemRect(item)
|
||||
QTest.mouseClick(
|
||||
self.manager.image_list.viewport(),
|
||||
Qt.LeftButton,
|
||||
modifiers,
|
||||
rect.center(),
|
||||
)
|
||||
self.app.processEvents()
|
||||
|
||||
def test_ctrl_shift_and_ctrl_a_use_standard_extended_selection(self):
|
||||
image_list = self.manager.image_list
|
||||
self.assertEqual(
|
||||
image_list.selectionMode(), QAbstractItemView.ExtendedSelection
|
||||
)
|
||||
|
||||
self._click(0)
|
||||
self._click(2, Qt.ControlModifier)
|
||||
self.assertEqual(len(image_list.selectedItems()), 2)
|
||||
self.assertEqual(len(self.manager.selected_ids), 2)
|
||||
|
||||
self._click(3, Qt.ShiftModifier)
|
||||
selected_names = {item.text() for item in image_list.selectedItems()}
|
||||
self.assertIn("image2.png", selected_names)
|
||||
self.assertIn("image3.png", selected_names)
|
||||
|
||||
image_list.clearSelection()
|
||||
image_list.setFocus()
|
||||
QTest.keyClick(image_list, Qt.Key_A, Qt.ControlModifier)
|
||||
self.app.processEvents()
|
||||
self.assertEqual(len(image_list.selectedItems()), 4)
|
||||
self.assertEqual(len(self.manager.selected_ids), 4)
|
||||
|
||||
def test_selected_tiles_are_restored_after_filtering(self):
|
||||
selection = self.manager.image_list.selectionModel()
|
||||
selection.select(
|
||||
self.manager.image_list.model().index(1, 0),
|
||||
QItemSelectionModel.Select,
|
||||
)
|
||||
selection.select(
|
||||
self.manager.image_list.model().index(3, 0),
|
||||
QItemSelectionModel.Select,
|
||||
)
|
||||
self.app.processEvents()
|
||||
selected_before = set(self.manager.selected_ids)
|
||||
self.assertEqual(len(selected_before), 2)
|
||||
|
||||
self.manager.search_edit.setText("image0")
|
||||
self.app.processEvents()
|
||||
self.manager.search_edit.clear()
|
||||
self.app.processEvents()
|
||||
|
||||
self.assertEqual(self.manager.selected_ids, selected_before)
|
||||
self.assertEqual(len(self.manager.image_list.selectedItems()), 2)
|
||||
|
||||
def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self):
|
||||
for worker in list(self.manager._thumbnail_workers):
|
||||
worker.wait(5000)
|
||||
self.app.processEvents()
|
||||
|
||||
image_list = self.manager.image_list
|
||||
self.assertTrue(image_list.uniformItemSizes())
|
||||
self.assertNotIn("border-bottom", image_list.styleSheet())
|
||||
self.assertEqual(image_list.count(), 4)
|
||||
self.assertTrue(
|
||||
all(not image_list.item(index).icon().isNull() for index in range(4))
|
||||
)
|
||||
rects = [image_list.visualItemRect(image_list.item(index)) for index in range(4)]
|
||||
self.assertEqual(len({(rect.x(), rect.y()) for rect in rects}), 4)
|
||||
|
||||
|
||||
class RPGMakerImageManagerNavigationTests(unittest.TestCase):
|
||||
def test_image_manager_is_a_dedicated_sidebar_page(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
main_source = (root / "gui" / "main.py").read_text(encoding="utf-8")
|
||||
workflow_source = (root / "gui" / "workflow_tab.py").read_text(encoding="utf-8")
|
||||
self.assertIn("PAGE_IMAGES = 2", main_source)
|
||||
self.assertIn("self.image_manager_tab = RPGMakerImageManager", main_source)
|
||||
self.assertIn('create_nav_button("🖼", "Images")', main_source)
|
||||
self.assertNotIn("Open Image Manager", workflow_source)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
154
tests/test_rpgmaker_images.py
Normal file
154
tests/test_rpgmaker_images.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from util.rpgmaker_images import (
|
||||
add_patch_exceptions,
|
||||
decrypt_assets,
|
||||
decrypt_image_bytes,
|
||||
encrypt_assets,
|
||||
encrypt_image_bytes,
|
||||
prepare_assets_for_patch,
|
||||
read_encryption_key,
|
||||
scan_image_assets,
|
||||
)
|
||||
|
||||
|
||||
KEY_HEX = "00112233445566778899aabbccddeeff"
|
||||
KEY = bytes.fromhex(KEY_HEX)
|
||||
|
||||
|
||||
def png_bytes(color: str) -> bytes:
|
||||
output = BytesIO()
|
||||
Image.new("RGBA", (12, 8), color).save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
class RPGMakerImageTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name) / "Game"
|
||||
self.content = self.root / "www"
|
||||
(self.content / "data").mkdir(parents=True)
|
||||
(self.content / "img" / "pictures").mkdir(parents=True)
|
||||
(self.content / "data" / "System.json").write_text(
|
||||
'{"encryptionKey":"' + KEY_HEX + '"}', encoding="utf-8"
|
||||
)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def _encrypted_asset(self, name: str = "001.rpgmvp", color: str = "red") -> Path:
|
||||
path = self.content / "img" / "pictures" / name
|
||||
path.write_bytes(encrypt_image_bytes(png_bytes(color), KEY))
|
||||
return path
|
||||
|
||||
def test_scan_and_decrypt_mv_image_without_touching_encrypted_original(self):
|
||||
encrypted = self._encrypted_asset()
|
||||
original = encrypted.read_bytes()
|
||||
|
||||
assets = scan_image_assets(self.root)
|
||||
self.assertEqual([asset.asset_id for asset in assets], ["img/pictures/001.png"])
|
||||
self.assertFalse(assets[0].has_plain)
|
||||
|
||||
result = decrypt_assets(assets, read_encryption_key(self.root))
|
||||
self.assertEqual(result.completed, 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
self.assertEqual(assets[0].plain_path.read_bytes(), png_bytes("red"))
|
||||
self.assertEqual(encrypted.read_bytes(), original)
|
||||
|
||||
def test_existing_editable_png_is_not_overwritten_by_decrypt_all(self):
|
||||
self._encrypted_asset()
|
||||
plain = self.content / "img" / "pictures" / "001.png"
|
||||
translated = png_bytes("blue")
|
||||
plain.write_bytes(translated)
|
||||
|
||||
result = decrypt_assets(scan_image_assets(self.root), KEY)
|
||||
|
||||
self.assertEqual(result.completed, 0)
|
||||
self.assertEqual(result.skipped, 1)
|
||||
self.assertEqual(plain.read_bytes(), translated)
|
||||
|
||||
def test_prepare_reencrypts_selected_image_backs_up_original_and_updates_ignore(self):
|
||||
encrypted = self._encrypted_asset()
|
||||
original = encrypted.read_bytes()
|
||||
asset = scan_image_assets(self.root)[0]
|
||||
decrypt_assets([asset], KEY)
|
||||
translated = png_bytes("blue")
|
||||
asset.plain_path.write_bytes(translated)
|
||||
self.root.joinpath(".gitignore").write_text("*.*\n", encoding="utf-8")
|
||||
|
||||
result = prepare_assets_for_patch(self.root, [asset], KEY)
|
||||
|
||||
self.assertEqual(result.completed, 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated)
|
||||
backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp"
|
||||
self.assertEqual(backup.read_bytes(), original)
|
||||
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
|
||||
self.assertIn("/.dazedtl/", ignore)
|
||||
self.assertIn("!/www/img/pictures/001.rpgmvp", ignore)
|
||||
self.assertNotIn("!/www/img/pictures/001.png", ignore)
|
||||
|
||||
prepare_assets_for_patch(self.root, [asset], KEY)
|
||||
again = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
|
||||
self.assertEqual(again.count("!/www/img/pictures/001.rpgmvp"), 1)
|
||||
self.assertEqual(backup.read_bytes(), original)
|
||||
|
||||
def test_mz_png_uses_same_crypto_and_logical_png_name(self):
|
||||
encrypted = self._encrypted_asset("title.png_", "green")
|
||||
asset = scan_image_assets(self.root)[0]
|
||||
|
||||
self.assertEqual(asset.asset_id, "img/pictures/title.png")
|
||||
self.assertEqual(asset.encrypted_path, encrypted)
|
||||
result = decrypt_assets([asset], KEY)
|
||||
self.assertEqual(result.completed, 1)
|
||||
self.assertEqual(asset.plain_path.read_bytes(), png_bytes("green"))
|
||||
|
||||
def test_encrypt_only_rebuilds_runtime_file_without_changing_gitignore(self):
|
||||
encrypted = self._encrypted_asset()
|
||||
asset = scan_image_assets(self.root)[0]
|
||||
decrypt_assets([asset], KEY)
|
||||
translated = png_bytes("black")
|
||||
asset.plain_path.write_bytes(translated)
|
||||
|
||||
result = encrypt_assets(self.root, [asset], KEY)
|
||||
|
||||
self.assertEqual(result.completed, 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated)
|
||||
self.assertFalse((self.root / ".gitignore").exists())
|
||||
|
||||
def test_nested_gitignore_receives_relative_exact_exception(self):
|
||||
target = self.content / "img" / "pictures" / "menu image.png"
|
||||
target.write_bytes(png_bytes("purple"))
|
||||
nested = self.content / "img" / ".gitignore"
|
||||
nested.write_text("*\n", encoding="utf-8")
|
||||
|
||||
changed = add_patch_exceptions(self.root, [target])
|
||||
|
||||
self.assertIn(self.root / ".gitignore", changed)
|
||||
self.assertIn(nested, changed)
|
||||
nested_text = nested.read_text(encoding="utf-8")
|
||||
self.assertIn("!/pictures/menu\\ image.png", nested_text)
|
||||
root_text = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
|
||||
self.assertIn("!/www/img/pictures/menu\\ image.png", root_text)
|
||||
|
||||
def test_plain_selected_image_is_added_without_requiring_key(self):
|
||||
plain = self.content / "img" / "pictures" / "menu.png"
|
||||
plain.write_bytes(png_bytes("yellow"))
|
||||
asset = scan_image_assets(self.root)[0]
|
||||
|
||||
result = prepare_assets_for_patch(self.root, [asset], None)
|
||||
|
||||
self.assertEqual(result.completed, 1)
|
||||
self.assertEqual(result.errors, [])
|
||||
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
|
||||
self.assertIn("!/www/img/pictures/menu.png", ignore)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
393
util/rpgmaker_images.py
Normal file
393
util/rpgmaker_images.py
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
"""RPG Maker MV/MZ image encryption and patch-selection helpers.
|
||||
|
||||
RPG Maker encrypts only the first 16 bytes of an asset, prefixes a fixed
|
||||
16-byte header, and changes the file extension. This module intentionally
|
||||
handles images only; audio assets use the same container but are outside the
|
||||
image-translation workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
|
||||
RPGMV_HEADER = bytes(
|
||||
[0x52, 0x50, 0x47, 0x4D, 0x56, 0x00, 0x00, 0x00,
|
||||
0x00, 0x03, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
)
|
||||
KEY_LEN = 16
|
||||
ENCRYPTED_IMAGE_EXTENSIONS = (".rpgmvp", ".png_")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageAsset:
|
||||
"""One logical image and its editable/encrypted representations."""
|
||||
|
||||
asset_id: str
|
||||
relative_png: Path
|
||||
plain_path: Path
|
||||
encrypted_path: Path | None = None
|
||||
|
||||
@property
|
||||
def has_plain(self) -> bool:
|
||||
return self.plain_path.is_file()
|
||||
|
||||
@property
|
||||
def has_encrypted(self) -> bool:
|
||||
return self.encrypted_path is not None and self.encrypted_path.is_file()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageActionResult:
|
||||
completed: int = 0
|
||||
skipped: int = 0
|
||||
errors: list[str] | None = None
|
||||
patch_files: list[Path] | None = None
|
||||
gitignore_files: list[Path] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.errors is None:
|
||||
self.errors = []
|
||||
if self.patch_files is None:
|
||||
self.patch_files = []
|
||||
if self.gitignore_files is None:
|
||||
self.gitignore_files = []
|
||||
|
||||
|
||||
def resolve_content_root(game_root: str | Path) -> Path:
|
||||
"""Return the directory containing ``data/`` and ``img/``."""
|
||||
|
||||
root = Path(game_root).expanduser().resolve()
|
||||
for candidate in (root / "www", root):
|
||||
if (candidate / "img").is_dir():
|
||||
return candidate
|
||||
raise FileNotFoundError(
|
||||
f"Could not find an img folder under {root} or {root / 'www'}."
|
||||
)
|
||||
|
||||
|
||||
def read_encryption_key(game_root: str | Path) -> bytes:
|
||||
"""Read and validate the 16-byte encryption key from System.json."""
|
||||
|
||||
content_root = resolve_content_root(game_root)
|
||||
system_path = content_root / "data" / "System.json"
|
||||
if not system_path.is_file():
|
||||
raise FileNotFoundError(f"System.json not found: {system_path}")
|
||||
text = system_path.read_text(encoding="utf-8-sig", errors="replace")
|
||||
try:
|
||||
raw_key = json.loads(text).get("encryptionKey", "")
|
||||
except json.JSONDecodeError:
|
||||
match = re.search(r'"encryptionKey"\s*:\s*"([a-fA-F0-9]{32})"', text)
|
||||
raw_key = match.group(1) if match else ""
|
||||
normalized = str(raw_key).strip().replace(" ", "")
|
||||
if not re.fullmatch(r"[a-fA-F0-9]{32}", normalized):
|
||||
raise ValueError(
|
||||
"System.json does not contain a valid 32-character encryptionKey."
|
||||
)
|
||||
return bytes.fromhex(normalized)
|
||||
|
||||
|
||||
def _logical_png(path: Path) -> Path:
|
||||
lower = path.name.casefold()
|
||||
if lower.endswith(".rpgmvp"):
|
||||
return path.with_name(path.name[:-7] + ".png")
|
||||
if lower.endswith(".png_"):
|
||||
return path.with_name(path.name[:-5] + ".png")
|
||||
return path
|
||||
|
||||
|
||||
def scan_image_assets(game_root: str | Path) -> list[ImageAsset]:
|
||||
"""Scan ``img/`` and combine matching PNG/encrypted files into records."""
|
||||
|
||||
content_root = resolve_content_root(game_root)
|
||||
image_root = content_root / "img"
|
||||
by_id: dict[str, dict[str, Path]] = {}
|
||||
for path in image_root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
name = path.name.casefold()
|
||||
if not (name.endswith(".png") or name.endswith(ENCRYPTED_IMAGE_EXTENSIONS)):
|
||||
continue
|
||||
logical = _logical_png(path)
|
||||
relative = logical.relative_to(content_root)
|
||||
asset_id = relative.as_posix()
|
||||
entry = by_id.setdefault(asset_id, {"relative": relative, "plain": logical})
|
||||
if name.endswith(ENCRYPTED_IMAGE_EXTENSIONS):
|
||||
entry["encrypted"] = path
|
||||
else:
|
||||
entry["plain"] = path
|
||||
|
||||
assets = [
|
||||
ImageAsset(
|
||||
asset_id=asset_id,
|
||||
relative_png=entry["relative"],
|
||||
plain_path=entry["plain"],
|
||||
encrypted_path=entry.get("encrypted"),
|
||||
)
|
||||
for asset_id, entry in by_id.items()
|
||||
]
|
||||
return sorted(assets, key=lambda asset: asset.asset_id.casefold())
|
||||
|
||||
|
||||
def decrypt_image_bytes(data: bytes, key: bytes) -> bytes:
|
||||
if len(key) != KEY_LEN:
|
||||
raise ValueError("The RPG Maker encryption key must contain 16 bytes.")
|
||||
if len(data) < len(RPGMV_HEADER) + KEY_LEN:
|
||||
raise ValueError("Encrypted image is too small.")
|
||||
if data[: len(RPGMV_HEADER)] != RPGMV_HEADER:
|
||||
raise ValueError("Encrypted image has an invalid RPG Maker header.")
|
||||
start = len(RPGMV_HEADER)
|
||||
block = bytes(value ^ key[index] for index, value in enumerate(data[start:start + KEY_LEN]))
|
||||
return block + data[start + KEY_LEN:]
|
||||
|
||||
|
||||
def encrypt_image_bytes(data: bytes, key: bytes) -> bytes:
|
||||
if len(key) != KEY_LEN:
|
||||
raise ValueError("The RPG Maker encryption key must contain 16 bytes.")
|
||||
if len(data) < KEY_LEN:
|
||||
raise ValueError("PNG image is too small.")
|
||||
block = bytes(value ^ key[index] for index, value in enumerate(data[:KEY_LEN]))
|
||||
return RPGMV_HEADER + block + data[KEY_LEN:]
|
||||
|
||||
|
||||
def preview_png_bytes(asset: ImageAsset, key: bytes | None) -> bytes:
|
||||
"""Return displayable PNG bytes, decrypting in memory when necessary."""
|
||||
|
||||
if asset.has_plain:
|
||||
return asset.plain_path.read_bytes()
|
||||
if not asset.has_encrypted:
|
||||
raise FileNotFoundError(asset.plain_path)
|
||||
if key is None:
|
||||
raise ValueError("The encryption key is required to preview this image.")
|
||||
return decrypt_image_bytes(asset.encrypted_path.read_bytes(), key)
|
||||
|
||||
|
||||
def thumbnail_png_bytes(asset: ImageAsset, key: bytes | None, size: int = 128) -> bytes:
|
||||
"""Create a small PNG thumbnail without writing a decrypted working file."""
|
||||
|
||||
from PIL import Image
|
||||
|
||||
raw = preview_png_bytes(asset, key)
|
||||
with Image.open(io.BytesIO(raw)) as image:
|
||||
image.thumbnail((size, size))
|
||||
if image.mode not in {"RGB", "RGBA"}:
|
||||
image = image.convert("RGBA")
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def _atomic_write(path: Path, data: bytes) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle = tempfile.NamedTemporaryFile(
|
||||
mode="wb", prefix=f".{path.name}.", suffix=".tmp", dir=path.parent, delete=False
|
||||
)
|
||||
temp_path = Path(handle.name)
|
||||
try:
|
||||
with handle:
|
||||
handle.write(data)
|
||||
os.replace(temp_path, path)
|
||||
except Exception:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def decrypt_assets(
|
||||
assets: Iterable[ImageAsset],
|
||||
key: bytes,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> ImageActionResult:
|
||||
"""Create editable PNG copies beside encrypted images."""
|
||||
|
||||
result = ImageActionResult()
|
||||
for asset in assets:
|
||||
if not asset.has_encrypted:
|
||||
result.skipped += 1
|
||||
continue
|
||||
if asset.has_plain and not overwrite:
|
||||
result.skipped += 1
|
||||
continue
|
||||
try:
|
||||
if progress:
|
||||
progress(f"Decrypting {asset.asset_id}")
|
||||
raw = decrypt_image_bytes(asset.encrypted_path.read_bytes(), key)
|
||||
_atomic_write(asset.plain_path, raw)
|
||||
result.completed += 1
|
||||
except Exception as exc: # continue a large batch and report all failures
|
||||
result.errors.append(f"{asset.asset_id}: {exc}")
|
||||
return result
|
||||
|
||||
|
||||
def _path_inside(root: Path, path: Path) -> Path:
|
||||
try:
|
||||
return path.resolve().relative_to(root.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"Image is outside the selected game folder: {path}") from exc
|
||||
|
||||
|
||||
def _escape_gitignore_component(value: str) -> str:
|
||||
if "\n" in value or "\r" in value:
|
||||
raise ValueError("Image paths containing line breaks cannot be added to .gitignore.")
|
||||
escaped = ""
|
||||
for char in value:
|
||||
if char in "\\*?[]!# " or char == "\t":
|
||||
escaped += "\\"
|
||||
escaped += char
|
||||
return escaped
|
||||
|
||||
|
||||
def _gitignore_pattern(relative: Path, *, directory: bool = False) -> str:
|
||||
parts = [_escape_gitignore_component(part) for part in relative.parts]
|
||||
pattern = "!/" + "/".join(parts)
|
||||
if directory:
|
||||
pattern += "/"
|
||||
return pattern
|
||||
|
||||
|
||||
def add_patch_exceptions(
|
||||
game_root: str | Path, targets: Iterable[str | Path]
|
||||
) -> list[Path]:
|
||||
"""Append exact allow-rules to applicable .gitignore files.
|
||||
|
||||
Root and nested ignore files can both affect an asset. Adding a precise
|
||||
exception to each existing file ensures a deeper rule cannot hide a chosen
|
||||
image again. Existing content is preserved and entries are idempotent.
|
||||
"""
|
||||
|
||||
root = Path(game_root).expanduser().resolve()
|
||||
relative_targets = sorted(
|
||||
{_path_inside(root, Path(target)) for target in targets},
|
||||
key=lambda path: path.as_posix().casefold(),
|
||||
)
|
||||
if not relative_targets:
|
||||
return []
|
||||
|
||||
ignore_to_targets: dict[Path, list[Path]] = {root / ".gitignore": relative_targets}
|
||||
for relative in relative_targets:
|
||||
parent = root
|
||||
for part in relative.parts[:-1]:
|
||||
parent /= part
|
||||
nested = parent / ".gitignore"
|
||||
if nested.is_file():
|
||||
ignore_to_targets.setdefault(nested, []).append(
|
||||
Path(*relative.parts[len(parent.relative_to(root).parts):])
|
||||
)
|
||||
|
||||
changed: list[Path] = []
|
||||
for ignore_path, entries in ignore_to_targets.items():
|
||||
existing = (
|
||||
ignore_path.read_text(encoding="utf-8", errors="surrogateescape")
|
||||
if ignore_path.exists()
|
||||
else ""
|
||||
)
|
||||
rules: list[str] = []
|
||||
if ignore_path == root / ".gitignore":
|
||||
rules.append("/.dazedtl/")
|
||||
for relative in entries:
|
||||
for depth in range(1, len(relative.parts)):
|
||||
rules.append(_gitignore_pattern(Path(*relative.parts[:depth]), directory=True))
|
||||
rules.append(_gitignore_pattern(relative))
|
||||
additions = [rule for rule in dict.fromkeys(rules) if rule not in existing.splitlines()]
|
||||
if not additions:
|
||||
continue
|
||||
text = existing
|
||||
if text and not text.endswith("\n"):
|
||||
text += "\n"
|
||||
if "# DazedTL selected image patches" not in text:
|
||||
text += "\n# DazedTL selected image patches\n"
|
||||
text += "\n".join(additions) + "\n"
|
||||
ignore_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_atomic_write(ignore_path, text.encode("utf-8", errors="surrogateescape"))
|
||||
changed.append(ignore_path)
|
||||
return changed
|
||||
|
||||
|
||||
def prepare_assets_for_patch(
|
||||
game_root: str | Path,
|
||||
assets: Iterable[ImageAsset],
|
||||
key: bytes | None,
|
||||
*,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> ImageActionResult:
|
||||
"""Re-encrypt edited PNGs and add only selected runtime assets to gitignore."""
|
||||
|
||||
root = Path(game_root).expanduser().resolve()
|
||||
result = ImageActionResult()
|
||||
targets: list[Path] = []
|
||||
for asset in assets:
|
||||
try:
|
||||
if asset.has_encrypted:
|
||||
if not asset.has_plain:
|
||||
raise FileNotFoundError("decrypt this image before preparing it")
|
||||
if key is None:
|
||||
raise ValueError("the encryption key is required")
|
||||
relative_encrypted = _path_inside(root, asset.encrypted_path)
|
||||
backup = root / ".dazedtl" / "image_backups" / relative_encrypted
|
||||
if not backup.exists():
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(asset.encrypted_path, backup)
|
||||
if progress:
|
||||
progress(f"Encrypting {asset.asset_id}")
|
||||
encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key)
|
||||
_atomic_write(asset.encrypted_path, encrypted)
|
||||
targets.append(asset.encrypted_path)
|
||||
elif asset.has_plain:
|
||||
targets.append(asset.plain_path)
|
||||
else:
|
||||
raise FileNotFoundError("image file no longer exists")
|
||||
result.completed += 1
|
||||
except Exception as exc:
|
||||
result.errors.append(f"{asset.asset_id}: {exc}")
|
||||
|
||||
if targets:
|
||||
if progress:
|
||||
progress("Updating .gitignore image allow-rules")
|
||||
result.patch_files = targets
|
||||
try:
|
||||
result.gitignore_files = add_patch_exceptions(root, targets)
|
||||
except Exception as exc:
|
||||
result.errors.append(f".gitignore: {exc}")
|
||||
return result
|
||||
|
||||
|
||||
def encrypt_assets(
|
||||
game_root: str | Path,
|
||||
assets: Iterable[ImageAsset],
|
||||
key: bytes,
|
||||
*,
|
||||
progress: Callable[[str], None] | None = None,
|
||||
) -> ImageActionResult:
|
||||
"""Re-encrypt editable PNGs in place without changing gitignore files."""
|
||||
|
||||
root = Path(game_root).expanduser().resolve()
|
||||
result = ImageActionResult()
|
||||
for asset in assets:
|
||||
try:
|
||||
if not asset.has_encrypted:
|
||||
raise FileNotFoundError("no matching encrypted runtime image exists")
|
||||
if not asset.has_plain:
|
||||
raise FileNotFoundError("decrypt this image before encrypting it")
|
||||
relative_encrypted = _path_inside(root, asset.encrypted_path)
|
||||
backup = root / ".dazedtl" / "image_backups" / relative_encrypted
|
||||
if not backup.exists():
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(asset.encrypted_path, backup)
|
||||
if progress:
|
||||
progress(f"Encrypting {asset.asset_id}")
|
||||
encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key)
|
||||
_atomic_write(asset.encrypted_path, encrypted)
|
||||
result.patch_files.append(asset.encrypted_path)
|
||||
result.completed += 1
|
||||
except Exception as exc:
|
||||
result.errors.append(f"{asset.asset_id}: {exc}")
|
||||
return result
|
||||
Loading…
Reference in a new issue