From ef7a3f8235b6aa41e9038283c1b938cdbb6f0a27 Mon Sep 17 00:00:00 2001 From: DazedAnon Date: Sun, 26 Jul 2026 12:56:46 -0500 Subject: [PATCH] feat(images): add engine-aware image management - Add auto-detected RPG Maker and generic loose-PNG profiles - Make scanning read-only and patch images through validated transactions - Preserve RPG Maker compatibility with backups and legacy migration - Document the workflow and expand image manager coverage - Enable core RPG Maker dialogue and choice codes by default --- data/help/03-workflow-rpg.md | 4 +- data/help/05-other-tabs.md | 15 +- docs/image-manager-engine-plan.md | 90 ++++ gui/image_manager.py | 5 + gui/main.py | 8 +- gui/rpgmaker_image_manager.py | 437 ++++++++++++++--- gui/workflow_tab.py | 8 +- modules/rpgmakermvmz.py | 10 +- tests/test_image_manager.py | 231 +++++++++ tests/test_rpgmaker_image_manager.py | 43 +- util/image_manager.py | 673 +++++++++++++++++++++++++++ util/rpgmaker_images.py | 43 +- 12 files changed, 1441 insertions(+), 126 deletions(-) create mode 100644 docs/image-manager-engine-plan.md create mode 100644 gui/image_manager.py create mode 100644 tests/test_image_manager.py create mode 100644 util/image_manager.py diff --git a/data/help/03-workflow-rpg.md b/data/help/03-workflow-rpg.md index 1e8ff29..d78cc33 100644 --- a/data/help/03-workflow-rpg.md +++ b/data/help/03-workflow-rpg.md @@ -45,8 +45,8 @@ Project Setup's `speakers` block recommends ENABLE / SKIP with evidence. ## Images (Step 6, MV/MZ) -Step 6 keeps the Image Manager on the separate **Images** page, but verifies that you are ready -to use it: +Step 6 keeps the engine-aware Image Manager on the separate **Images** page, selects the RPG Maker +workflow through auto-detection, and verifies that you are ready to use it: - Step 0 points to the actual game root containing `img/` or `www/img/`. - Encrypted projects have a valid key in `System.json`. diff --git a/data/help/05-other-tabs.md b/data/help/05-other-tabs.md index 1ce374c..9c4e727 100644 --- a/data/help/05-other-tabs.md +++ b/data/help/05-other-tabs.md @@ -18,15 +18,24 @@ Workflow jumps here when you start a phase. ## Images -MV/MZ bitmap UI workflow. Workflow Step 6 checks the project setup and opens this page. +Engine-aware bitmap UI workflow. Choose **Auto-detect**, **RPG Maker MV/MZ**, or +**Generic / Loose Images** after selecting the game folder. Auto-detect uses RPG Maker project, +`System.json`, and encrypted-image markers; otherwise it falls back to Generic and asks for the +folder containing loose PNGs. -1. Decrypt selected images (or all images) into `.dazedtl/images/.../img/`. +1. Use **Decrypt** for RPG Maker assets or **Make editable** for ordinary PNGs. Editable copies + mirror their game-relative paths under `.dazedtl/images/`. 2. Click **Copy skill** and paste the scoped bitmap-localization instructions into your coding agent. 3. Review the edited PNGs here. 4. Patch highlighted images, or clear highlights to patch every editable PNG. -The runtime game images remain untouched until you deliberately patch the reviewed copies. +Scanning is read-only. Runtime images remain untouched until you deliberately patch reviewed +copies. Patching checks for external source changes, stages the selected batch, keeps originals +under `.dazedtl/image_backups/`, and adds exact Git/GameUpdate allow-rules. + +The Generic profile supports loose PNG files. Packed engine archives require a dedicated engine +profile and are not silently unpacked or rebuilt. ## Batches diff --git a/docs/image-manager-engine-plan.md b/docs/image-manager-engine-plan.md new file mode 100644 index 0000000..3dcd431 --- /dev/null +++ b/docs/image-manager-engine-plan.md @@ -0,0 +1,90 @@ +# Engine-Agnostic Image Manager Plan + +## Goal + +Turn the RPG Maker MV/MZ Image Manager into an engine-agnostic Images page while +preserving the existing RPG Maker workflow. Engine profiles own discovery, +decoding, and runtime encoding; the shared manager owns browsing, editable +copies, review, selection, and patch delivery. + +## First release + +- `Auto-detect` chooses a profile from game-folder markers. +- `RPG Maker MV/MZ` preserves `img/` and `www/img/`, `.rpgmvp` / `.png_` + decryption, `System.json` keys, and runtime re-encryption. +- `Generic / Loose Images` manages ordinary PNGs below a user-selected folder. +- Unknown and packed engines never silently use an unsafe repacking strategy. + +## Storage contract + +- Editable images: `.dazedtl/images/` +- Manager metadata: `.dazedtl/image_manager/` +- Runtime backups: `.dazedtl/image_backups/` +- Transaction staging: `.dazedtl/image_patch_stage/` + +The existing RPG Maker workspace layout remains valid. Generic images mirror +their path relative to the game root, for example `assets/ui/menu.png` becomes +`.dazedtl/images/assets/ui/menu.png`. + +## Architecture + +An image profile exposes: + +1. Detection evidence and confidence. +2. Default or user-configurable image roots. +3. Asset scanning and logical IDs. +4. Preview decoding. +5. Creation of editable copies. +6. Runtime-file generation for patching. +7. Capabilities and actionable unsupported-operation errors. + +Patch delivery is separate from engine encoding. The initial Git/GameUpdate +backend publishes engine-built files at their game-relative paths and adds exact +`.gitignore` allow-rules, matching existing behavior. The boundary must permit +later apply-only and export-folder backends. + +## Safety requirements + +- Folder detection and scanning are read-only. +- Legacy migration and duplicate cleanup are explicit actions, not load-time + side effects. +- Editable copies are never overwritten by a normal make-editable operation. +- All source, editable, backup, and patch targets are checked to remain inside + their expected roots. +- Symlink escapes, path collisions, corrupt images, missing keys, deleted source + files, and externally changed runtime files produce visible errors. +- Patch writes use staging, backups, atomic replacement, and rollback where a + multi-step publish cannot be completed. + +## UI contract + +- Game folder, engine selector, and detection reason appear together. +- Generic mode exposes an image-folder selector constrained to the game root. +- Action wording follows capability: RPG Maker uses Decrypt for encrypted + assets; generic assets use Make editable. +- The first release keeps the existing All/Editable filters. Baseline metadata + provides the foundation for later Modified/Conflict filters without making + every ordinary filter operation hash thousands of images on the UI thread. +- RPG Maker Workflow Step 6 continues to open the shared Images page. + +## Delivery phases + +1. Add engine-neutral models, profile registry, detection, and Generic scanning. +2. Wrap existing RPG Maker behavior in its profile without changing crypto. +3. Refactor the Images UI to dispatch through the active profile. +4. Remove load-time mutation, add an explicit legacy-workspace migration + affordance, and move side-by-side cleanup behind explicit edit actions. +5. Introduce baseline hashes and transactional patch preflight. +6. Update Workflow integration, Guide content, and compatibility imports. +7. Cover auto-detection, manual override, Generic nested paths, RPG regressions, + path safety, conflicts, and UI capability states with tests. + +## Definition of done + +- Existing RPG Maker image tests continue to pass. +- A folder of loose PNGs can be scanned, copied to the workspace, edited, + previewed, and patched back with a backup and exact Git allow-rule. +- Auto-detection selects RPG Maker for known MV/MZ layouts and otherwise gives a + clear Generic fallback. +- Merely opening or refreshing Images does not move, delete, or rewrite files. +- Help text describes the engine selector, workspace, and patch semantics. diff --git a/gui/image_manager.py b/gui/image_manager.py new file mode 100644 index 0000000..c5e52a2 --- /dev/null +++ b/gui/image_manager.py @@ -0,0 +1,5 @@ +"""Canonical import for the engine-aware Image Manager.""" + +from gui.rpgmaker_image_manager import ImageManager + +__all__ = ["ImageManager"] diff --git a/gui/main.py b/gui/main.py index 633d334..331cb2b 100644 --- a/gui/main.py +++ b/gui/main.py @@ -707,7 +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.image_manager import ImageManager from gui.skills_tab import SkillsTab from gui.batch_tab import BatchTab @@ -965,7 +965,7 @@ class DazedMTLGUI(QMainWindow): # 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.setToolTip("Images - prepare, preview, and patch engine image assets") btn_images.clicked.connect(lambda: self.switch_page(self.PAGE_IMAGES)) sidebar_layout.addWidget(btn_images) self.nav_buttons.append(btn_images) @@ -1056,8 +1056,8 @@ class DazedMTLGUI(QMainWindow): # RPGMaker and Wolf guided panels while keeping a single sidebar button. self.content_stack.addWidget(self._create_workflow_container()) - # RPG Maker MV/MZ Image Manager (index 2) - self.image_manager_tab = RPGMakerImageManager(parent=self) + # Engine-aware Image Manager (index 2) + self.image_manager_tab = ImageManager(parent=self) self.content_stack.addWidget(self.image_manager_tab) # Translation Execution Tab (index 3) diff --git a/gui/rpgmaker_image_manager.py b/gui/rpgmaker_image_manager.py index 1e2d327..5176da2 100644 --- a/gui/rpgmaker_image_manager.py +++ b/gui/rpgmaker_image_manager.py @@ -1,8 +1,9 @@ -"""Visual RPG Maker MV/MZ image decrypt/encrypt and patch manager.""" +"""Engine-aware visual image workspace and patch manager.""" from __future__ import annotations from pathlib import Path +import hashlib from PyQt5.QtCore import ( QPoint, @@ -36,20 +37,28 @@ from PyQt5.QtWidgets import ( from util.paths import APP_NAME, ORG_NAME from util.skills import load_clipboard_skill -from util.rpgmaker_images import ( +from util.image_manager import ( ImageAsset, - clean_runtime_image_duplicates, - decrypt_assets, + PROFILE_AUTO, + PROFILE_GENERIC, + PROFILE_RPGMAKER_MVMZ, + detect_image_engine, editable_workspace_root, ensure_editable_workspace, + make_profile_assets_editable, + normalize_generic_image_root, + prepare_profile_assets_for_patch, + preview_profile_png_bytes, + profile_label, + registered_image_profiles, + scan_profile_assets, + thumbnail_profile_png_bytes, +) +from util.rpgmaker_images import ( migrate_legacy_editable_workspace, - prepare_assets_for_patch, - preview_png_bytes, read_encryption_key, remove_editable_assets, resolve_content_root, - scan_image_assets, - thumbnail_png_bytes, ) @@ -165,14 +174,25 @@ class _ImageScanWorker(QThread): done = pyqtSignal(int, object) error = pyqtSignal(int, str) - def __init__(self, generation: int, game_root: Path): + def __init__( + self, + generation: int, + game_root: Path, + engine_id: str, + image_root: Path | None, + ): super().__init__() self.generation = generation self.game_root = game_root + self.engine_id = engine_id + self.image_root = image_root def run(self) -> None: try: - self.done.emit(self.generation, scan_image_assets(self.game_root)) + self.done.emit( + self.generation, + scan_profile_assets(self.engine_id, self.game_root, self.image_root), + ) except Exception as exc: self.error.emit(self.generation, str(exc)) @@ -180,11 +200,18 @@ class _ImageScanWorker(QThread): class _ThumbnailWorker(QThread): done = pyqtSignal(int, object) - def __init__(self, generation: int, assets: list[ImageAsset], key: bytes | None): + def __init__( + self, + generation: int, + assets: list[ImageAsset], + key: bytes | None, + engine_id: str = PROFILE_RPGMAKER_MVMZ, + ): super().__init__() self.generation = generation self.assets = assets self.key = key + self.engine_id = engine_id def run(self) -> None: thumbnails: dict[str, bytes] = {} @@ -192,7 +219,9 @@ class _ThumbnailWorker(QThread): if self.isInterruptionRequested(): return try: - data = thumbnail_png_bytes(asset, self.key, size=112) + data = thumbnail_profile_png_bytes( + self.engine_id, asset, self.key, size=112 + ) except Exception: data = b"" thumbnails[asset.asset_id] = data @@ -210,21 +239,23 @@ class _ImageActionWorker(QThread): game_root: Path, assets: list[ImageAsset], key: bytes | None, + engine_id: str = PROFILE_RPGMAKER_MVMZ, ): super().__init__() self.action = action self.game_root = game_root self.assets = assets self.key = key + self.engine_id = engine_id def run(self) -> None: try: - if self.action == "decrypt": - result = decrypt_assets( + if self.action in {"decrypt", "make_editable"}: + result = make_profile_assets_editable( + self.engine_id, + self.game_root, self.assets, self.key, - game_root=self.game_root, - overwrite=False, progress=self.status.emit, ) elif self.action == "remove": @@ -232,15 +263,19 @@ class _ImageActionWorker(QThread): self.game_root, self.assets, progress=self.status.emit ) else: - result = prepare_assets_for_patch( - self.game_root, self.assets, self.key, progress=self.status.emit + result = prepare_profile_assets_for_patch( + self.engine_id, + 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): +class ImageManager(QWidget): """Paginated contact sheet for projects containing thousands of images.""" def __init__( @@ -260,6 +295,10 @@ class RPGMakerImageManager(QWidget): self.selected_ids: set[str] = set() self.page = 0 self.key: bytes | None = None + self.engine_id = PROFILE_RPGMAKER_MVMZ + self.engine_detection = None + self.generic_image_root: Path | None = None + self._loading_engine_ui = False self._scan_worker: _ImageScanWorker | None = None self._scan_workers: list[_ImageScanWorker] = [] self._scan_generation = 0 @@ -273,19 +312,18 @@ class RPGMakerImageManager(QWidget): 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.status_label.setText("Select a 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 = QLabel("Image Manager") title.setStyleSheet("font-size:18px;font-weight:bold;color:#e0e0e0;") root.addWidget(title) intro = QLabel( - "Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, " - "then highlight the finished images to patch only those, or clear highlights to " - "patch every editable image. " + "Choose an engine profile, make images editable under .dazedtl/images, edit the " + "PNG copies, then patch highlighted images or every editable image. " "Pages keep very large games responsive." ) intro.setWordWrap(True) @@ -295,7 +333,7 @@ class RPGMakerImageManager(QWidget): 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.setPlaceholderText("Select a game folder…") self.folder_edit.returnPressed.connect(self._load_project) folder_row.addWidget(self.folder_edit, 1) browse_button = QPushButton("Browse…") @@ -306,6 +344,43 @@ class RPGMakerImageManager(QWidget): folder_row.addWidget(load_button) root.addLayout(folder_row) + engine_row = QHBoxLayout() + engine_row.addWidget(QLabel("Engine:")) + self.engine_combo = QComboBox() + self.engine_combo.addItem("Auto-detect", PROFILE_AUTO) + for profile in registered_image_profiles(): + self.engine_combo.addItem(profile.label, profile.engine_id) + self.engine_combo.currentIndexChanged.connect(self._engine_changed) + engine_row.addWidget(self.engine_combo) + self.engine_detection_label = QLabel("Select a game folder to detect its image layout.") + self.engine_detection_label.setWordWrap(True) + self.engine_detection_label.setStyleSheet("color:#9d9d9d;font-size:12px;") + engine_row.addWidget(self.engine_detection_label, 1) + self.migrate_legacy_button = QPushButton("Migrate old workspace") + self.migrate_legacy_button.setToolTip( + "Move images from the former DazedTL_Images folder into .dazedtl/images." + ) + self.migrate_legacy_button.clicked.connect(self._migrate_legacy_workspace) + self.migrate_legacy_button.hide() + engine_row.addWidget(self.migrate_legacy_button) + root.addLayout(engine_row) + + self.generic_root_host = QWidget() + generic_root_row = QHBoxLayout(self.generic_root_host) + generic_root_row.setContentsMargins(0, 0, 0, 0) + generic_root_row.addWidget(QLabel("Image folder:")) + self.generic_root_edit = QLineEdit() + self.generic_root_edit.setPlaceholderText( + "Choose the folder containing loose PNG images…" + ) + self.generic_root_edit.returnPressed.connect(self._generic_root_changed) + generic_root_row.addWidget(self.generic_root_edit, 1) + self.generic_root_button = QPushButton("Browse…") + self.generic_root_button.clicked.connect(self._browse_generic_root) + generic_root_row.addWidget(self.generic_root_button) + root.addWidget(self.generic_root_host) + self.generic_root_host.hide() + filters = QHBoxLayout() self.search_edit = QLineEdit() self.search_edit.setPlaceholderText("Filter by any part of the folder or filename…") @@ -452,11 +527,196 @@ class RPGMakerImageManager(QWidget): 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) + folder = QFileDialog.getExistingDirectory(self, "Select Game Folder", start) if folder: self.folder_edit.setText(folder) self._load_project() + def _project_settings_prefix(self) -> str: + if self.game_root is None: + return "images/projects/none" + digest = hashlib.sha256(str(self.game_root).encode("utf-8")).hexdigest()[:16] + return f"images/projects/{digest}" + + def _load_project_preferences(self) -> None: + prefix = self._project_settings_prefix() + selected = str(self.settings.value(f"{prefix}/engine", PROFILE_AUTO) or PROFILE_AUTO) + if self.engine_combo.findData(selected) < 0: + selected = PROFILE_AUTO + saved_image_root = str( + self.settings.value(f"{prefix}/generic_image_root", "") or "" + ).strip() + self._loading_engine_ui = True + try: + self.engine_combo.setCurrentIndex(self.engine_combo.findData(selected)) + self.generic_root_edit.setText(saved_image_root) + finally: + self._loading_engine_ui = False + + def _save_project_preferences(self) -> None: + if self.game_root is None: + return + prefix = self._project_settings_prefix() + self.settings.setValue(f"{prefix}/engine", self.engine_combo.currentData()) + self.settings.setValue( + f"{prefix}/generic_image_root", self.generic_root_edit.text().strip() + ) + + def _engine_changed(self) -> None: + if self._loading_engine_ui or self.game_root is None: + return + self._save_project_preferences() + self.selected_ids.clear() + self._configure_engine_and_scan() + + def _browse_generic_root(self) -> None: + if self.game_root is None: + QMessageBox.information(self, "Image Folder", "Select a game folder first.") + return + start = self.generic_root_edit.text().strip() or str(self.game_root) + folder = QFileDialog.getExistingDirectory( + self, "Select Loose Image Folder", start + ) + if folder: + self.generic_root_edit.setText(folder) + self._generic_root_changed() + + def _migrate_legacy_workspace(self) -> None: + if self.game_root is None: + return + legacy = self.game_root / "DazedTL_Images" + if not legacy.is_dir(): + self.migrate_legacy_button.hide() + return + answer = QMessageBox.question( + self, + "Migrate Old Editable Images", + "Move editable images from DazedTL_Images into .dazedtl/images?\n\n" + "Conflicting files are preserved under .dazedtl/legacy_image_conflicts. " + "Runtime game images are not changed.", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if answer != QMessageBox.Yes: + return + try: + moved = migrate_legacy_editable_workspace(self.game_root) + except Exception as exc: + QMessageBox.warning(self, "Editable Image Migration", str(exc)) + return + self.migrate_legacy_button.hide() + self.status_label.setText(f"Migrated {moved:,} legacy editable image(s).") + self._start_scan() + + def _generic_root_changed(self) -> None: + if self.game_root is None: + return + try: + chosen = normalize_generic_image_root( + self.game_root, self.generic_root_edit.text().strip() + ) + except Exception as exc: + QMessageBox.warning(self, "Image Folder", str(exc)) + return + self.generic_root_edit.setText(str(chosen)) + self.generic_image_root = chosen + self._save_project_preferences() + self.selected_ids.clear() + self._start_scan() + + def _configure_engine_and_scan(self) -> None: + if self.game_root is None: + return + # Invalidate any scan started for the previous profile before returning + # early for an incomplete Generic configuration. + self._scan_generation += 1 + selected = self.engine_combo.currentData() or PROFILE_AUTO + try: + self.engine_detection = detect_image_engine(self.game_root) + except Exception as exc: + self.engine_detection = None + self.status_label.setText(f"Engine detection failed: {exc}") + self._set_actions_enabled(False) + return + self.engine_id = ( + self.engine_detection.engine_id if selected == PROFILE_AUTO else selected + ) + if selected == PROFILE_AUTO: + detected = profile_label(self.engine_id) + self.engine_detection_label.setText( + f"Auto: {detected} ({self.engine_detection.confidence}) — " + f"{self.engine_detection.reason}" + ) + else: + self.engine_detection_label.setText( + f"Manual: {profile_label(self.engine_id)} — " + f"auto-detection reported {profile_label(self.engine_detection.engine_id)}" + ) + + is_generic = self.engine_id == PROFILE_GENERIC + self.generic_root_host.setVisible(is_generic) + self.migrate_legacy_button.setVisible( + self.engine_id == PROFILE_RPGMAKER_MVMZ + and (self.game_root / "DazedTL_Images").is_dir() + ) + self.generic_image_root = None + if is_generic: + raw_root = self.generic_root_edit.text().strip() + if not raw_root and self.engine_detection.suggested_image_root is not None: + raw_root = str(self.engine_detection.suggested_image_root) + self.generic_root_edit.setText(raw_root) + if raw_root: + try: + self.generic_image_root = normalize_generic_image_root( + self.game_root, raw_root + ) + self.generic_root_edit.setText(str(self.generic_image_root)) + except Exception as exc: + self.status_label.setText(str(exc)) + self.assets = [] + self.assets_by_id = {} + self.filtered_assets = [] + self._render_page() + self._set_actions_enabled(False) + return + else: + self.status_label.setText( + "Choose the folder containing loose PNG images. Scanning is read-only." + ) + self.assets = [] + self.assets_by_id = {} + self.filtered_assets = [] + self._render_page() + self._set_actions_enabled(False) + return + self._load_key() + self._update_profile_actions() + self._save_project_preferences() + self._start_scan() + + def _update_profile_actions(self) -> None: + if self.engine_id == PROFILE_RPGMAKER_MVMZ: + self.decrypt_selected_button.setText("Decrypt") + self.decrypt_all_button.setText("Decrypt all") + self.decrypt_selected_button.setToolTip( + "Decrypt encrypted images or copy ordinary RPG Maker PNGs into the " + "editable workspace." + ) + self.decrypt_all_button.setToolTip( + "Make every encrypted or ordinary RPG Maker PNG editable without " + "overwriting existing work." + ) + else: + self.decrypt_selected_button.setText("Make editable") + self.decrypt_all_button.setText("Make all editable") + self.decrypt_selected_button.setToolTip( + "Copy highlighted loose PNGs into the editable workspace without changing the game." + ) + self.decrypt_all_button.setToolTip( + "Copy every loose PNG into the editable workspace without overwriting " + "existing work." + ) + def _load_project(self) -> None: raw_path = self.folder_edit.text().strip() if not raw_path: @@ -467,18 +727,9 @@ class RPGMakerImageManager(QWidget): return self.game_root = root self.settings.setValue("workflow/last_game_folder", str(root)) - try: - migrate_legacy_editable_workspace(root) - clean_runtime_image_duplicates(root) - except Exception as exc: - QMessageBox.warning( - self, - "Editable Image Cleanup", - f"Could not consolidate editable images under .dazedtl:\n{exc}", - ) self.selected_ids.clear() - self._load_key() - self._start_scan() + self._load_project_preferences() + self._configure_engine_and_scan() def refresh_game_root_from_settings(self) -> None: """Load a game newly selected in the RPG Maker Workflow tab.""" @@ -492,6 +743,9 @@ class RPGMakerImageManager(QWidget): self._load_project() def _load_key(self) -> None: + if self.engine_id != PROFILE_RPGMAKER_MVMZ: + self.key = None + return try: self.key = read_encryption_key(self.game_root) except Exception: @@ -503,7 +757,12 @@ class RPGMakerImageManager(QWidget): 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 = _ImageScanWorker( + self._scan_generation, + self.game_root, + self.engine_id, + self.generic_image_root, + ) worker.done.connect(self._scan_done) worker.error.connect(self._scan_error) worker.finished.connect(lambda w=worker: self._forget_scan_worker(w)) @@ -537,7 +796,8 @@ class RPGMakerImageManager(QWidget): encrypted = sum(asset.has_encrypted for asset in assets) editable = sum(asset.has_plain for asset in assets) self.status_label.setText( - f"Found {len(assets):,} images · {encrypted:,} encrypted · " + f"{profile_label(self.engine_id)} · found {len(assets):,} images · " + f"{encrypted:,} encrypted · " f"{editable:,} editable PNG copies · {len(self.selected_ids):,} highlighted" ) self._update_prepare_scope() @@ -604,7 +864,12 @@ class RPGMakerImageManager(QWidget): 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 = _ThumbnailWorker( + self._thumbnail_generation, + page_assets, + self.key, + self.engine_id, + ) worker.done.connect(self._thumbnails_ready) worker.finished.connect(lambda w=worker: self._forget_thumbnail_worker(w)) self._thumbnail_workers.append(worker) @@ -670,7 +935,7 @@ class RPGMakerImageManager(QWidget): if asset is None: return try: - raw = preview_png_bytes(asset, self.key) + raw = preview_profile_png_bytes(self.engine_id, asset, self.key) pixmap = QPixmap() if not pixmap.loadFromData(raw, "PNG"): raise ValueError("Qt could not decode this PNG") @@ -683,6 +948,7 @@ class RPGMakerImageManager(QWidget): self.preview_label.setPixmap(QPixmap()) self.preview_label.setText(f"Preview unavailable\n{exc}") details = [asset.asset_id] + details.append(f"Engine: {profile_label(self.engine_id)}") if asset.has_encrypted: details.append(f"Runtime encrypted: {asset.encrypted_path}") elif asset.has_runtime_plain: @@ -710,12 +976,23 @@ class RPGMakerImageManager(QWidget): target = highlighted_parents.pop() else: root = Path(self.game_root).expanduser().resolve() - content_relative = resolve_content_root(root).relative_to(root) - folder = self.folder_combo.currentData() or "img" - relative_folder = Path(folder) - if relative_folder.is_absolute() or ".." in relative_folder.parts: - raise ValueError(f"Invalid editable image folder: {folder}") - target = workspace / content_relative / relative_folder + if self.engine_id == PROFILE_GENERIC: + source_root = normalize_generic_image_root( + root, self.generic_image_root + ) + source_relative = source_root.relative_to(root) + folder = self.folder_combo.currentData() or source_relative.as_posix() + relative_folder = Path(folder) + if relative_folder.is_absolute() or ".." in relative_folder.parts: + raise ValueError(f"Invalid editable image folder: {folder}") + target = workspace / relative_folder + else: + content_relative = resolve_content_root(root).relative_to(root) + folder = self.folder_combo.currentData() or "img" + relative_folder = Path(folder) + if relative_folder.is_absolute() or ".." in relative_folder.parts: + raise ValueError(f"Invalid editable image folder: {folder}") + target = workspace / content_relative / relative_folder target.resolve().relative_to(workspace.resolve()) target.mkdir(parents=True, exist_ok=True) if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(target))): @@ -728,6 +1005,9 @@ class RPGMakerImageManager(QWidget): if self.game_root is None: raise ValueError("Select a game folder first.") root = Path(self.game_root).expanduser().resolve() + if self.engine_id == PROFILE_GENERIC: + source_root = normalize_generic_image_root(root, self.generic_image_root) + return editable_workspace_root(root) / source_root.relative_to(root) content_relative = resolve_content_root(root).relative_to(root) return editable_workspace_root(root) / content_relative / "img" @@ -738,7 +1018,7 @@ class RPGMakerImageManager(QWidget): QMessageBox.information( self, "No Editable Images", - "Decrypt one or more images before copying the translation skill.", + "Make one or more images editable before copying the translation skill.", ) return try: @@ -774,6 +1054,8 @@ class RPGMakerImageManager(QWidget): return [asset for asset in self.assets if asset.has_plain] def _ensure_key(self) -> bool: + if self.engine_id != PROFILE_RPGMAKER_MVMZ: + return True if self.key is not None: return True try: @@ -787,33 +1069,46 @@ class RPGMakerImageManager(QWidget): assets = [ asset for asset in self._selected_assets() - if asset.has_encrypted and not asset.has_plain + if (asset.has_encrypted or asset.has_runtime_plain) and not asset.has_plain ] if not assets: QMessageBox.information( self, - "Nothing to Decrypt", - "Highlight one or more encrypted images that are not already editable.", + "Nothing to Make Editable", + "Highlight one or more runtime images that are not already editable.", ) return - if self._ensure_key(): - self._start_action("decrypt", assets) + needs_key = any(asset.has_encrypted for asset in assets) + if not needs_key or self._ensure_key(): + action = "decrypt" if self.engine_id == PROFILE_RPGMAKER_MVMZ else "make_editable" + self._start_action(action, assets) def _decrypt_all(self) -> None: - assets = [asset for asset in self.assets if asset.has_encrypted and not asset.has_plain] + assets = [ + asset + for asset in self.assets + if (asset.has_encrypted or asset.has_runtime_plain) and not asset.has_plain + ] if not assets: - QMessageBox.information(self, "Nothing to Decrypt", "All encrypted images already have PNG copies.") + QMessageBox.information( + self, + "Nothing to Make Editable", + "All runtime images already have editable PNG copies.", + ) return + verb = "Decrypt" if self.engine_id == PROFILE_RPGMAKER_MVMZ else "Make Editable" answer = QMessageBox.question( self, - "Decrypt Every Image", - f"Decrypt all {len(assets):,} encrypted image(s) into .dazedtl/images?\n\n" + f"{verb} Every Image", + f"Make all {len(assets):,} image(s) editable under .dazedtl/images?\n\n" "Existing editable copies will not be overwritten.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) - if answer == QMessageBox.Yes and self._ensure_key(): - self._start_action("decrypt", assets) + needs_key = any(asset.has_encrypted for asset in assets) + if answer == QMessageBox.Yes and (not needs_key or self._ensure_key()): + action = "decrypt" if self.engine_id == PROFILE_RPGMAKER_MVMZ else "make_editable" + self._start_action(action, assets) def _remove_highlighted(self) -> None: assets = [asset for asset in self._selected_assets() if asset.has_plain] @@ -845,10 +1140,11 @@ class RPGMakerImageManager(QWidget): ) if not assets: message = ( - "None of the highlighted images are in the editable folder. Decrypt them first " + "None of the highlighted images are in the editable folder. Make them " + "editable first " "or clear the highlights to patch every editable image." if highlighted - else "Decrypt images into the editable folder first." + else "Make images editable first." ) QMessageBox.information( self, @@ -867,11 +1163,12 @@ class RPGMakerImageManager(QWidget): self, "Prepare Images for Patch", f"Check {scope} and add changed images to the patch?\n\n" - "Unchanged editable copies will be skipped. Changed encrypted images will be rebuilt " - "from .dazedtl/images. Their original encrypted " - "files are backed up once under .dazedtl/image_backups. Exact allow-rules are then " + "Unchanged editable copies will be skipped. Changed images will be staged and rebuilt " + "for the active engine from .dazedtl/images. Current runtime files are backed up once " + "under .dazedtl/image_backups. Exact allow-rules are then " "added to the applicable .gitignore files. Editable PNG copies will remain available " - "for further changes until you remove them.", + "for further changes until you remove them. If a runtime image changed externally, " + "the selected batch is stopped without partially publishing it.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) @@ -880,7 +1177,9 @@ class RPGMakerImageManager(QWidget): 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 = _ImageActionWorker( + action, self.game_root, assets, self.key, self.engine_id + ) worker.status.connect(self.status_label.setText) worker.done.connect(self._action_done) worker.error.connect(self._action_error) @@ -888,7 +1187,7 @@ class RPGMakerImageManager(QWidget): worker.start() def _action_done(self, action: str, result) -> None: - if action == "decrypt": + if action in {"decrypt", "make_editable"}: workspace = editable_workspace_root(self.game_root) summary = ( f"Created {result.completed:,} editable image(s) in {workspace}; " @@ -960,3 +1259,7 @@ class RPGMakerImageManager(QWidget): event.ignore() return super().closeEvent(event) + + +# Backward-compatible import for callers and third-party scripts using the old name. +RPGMakerImageManager = ImageManager diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 92f77a2..e1bbc75 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -2522,8 +2522,9 @@ class WorkflowTab(QWidget): return if self._ace_encrypted or self._ace_rvdata_dir or self._ace_json_dir: label.setText( - "⚠ Image Manager supports RPG Maker MV/MZ " - "projects only; this project is RPG Maker Ace." + "⚠ This guided image step is configured for RPG " + "Maker MV/MZ. The standalone Images page also supports Generic loose PNGs, " + "but no Ace archive profile is available yet." ) if button is not None: button.setEnabled(False) @@ -3082,7 +3083,8 @@ class WorkflowTab(QWidget): "Copy a prompt that audits plugins.js and enabled plugin sources, asks what " "needs translation, then edits approved player-visible strings in place." ) - # Steps 6–7 - Image Manager and playtest tools are MV/MZ only. + # Steps 6–7 in this RPG workflow are MV/MZ only. The standalone Images + # page also supports Generic loose PNG projects. show_mvmz_tools = not is_ace tool_indices = (6, 7) for tool_idx in tool_indices: diff --git a/modules/rpgmakermvmz.py b/modules/rpgmakermvmz.py index 210bf61..a8c6239 100644 --- a/modules/rpgmakermvmz.py +++ b/modules/rpgmakermvmz.py @@ -148,13 +148,13 @@ TLSYSTEMSWITCHES = False JOIN408 = False # Dialogue / Scroll / Choices (Main Codes) -CODE101 = False -CODE401 = False -CODE405 = False -CODE102 = False +CODE101 = True +CODE401 = True +CODE405 = True +CODE102 = True # Optional -CODE408 = False +CODE408 = True # Variables CODE122 = False diff --git a/tests/test_image_manager.py b/tests/test_image_manager.py new file mode 100644 index 0000000..844e1dc --- /dev/null +++ b/tests/test_image_manager.py @@ -0,0 +1,231 @@ +import tempfile +import unittest +from io import BytesIO +from pathlib import Path +from unittest.mock import patch + +from PIL import Image + +from util.image_manager import ( + PROFILE_GENERIC, + PROFILE_RPGMAKER_MVMZ, + copy_plain_assets_to_workspace, + detect_image_engine, + make_profile_assets_editable, + prepare_plain_assets_for_patch, + prepare_profile_assets_for_patch, + registered_image_profiles, + scan_generic_image_assets, + scan_profile_assets, +) +from util.rpgmaker_images import decrypt_image_bytes, encrypt_image_bytes + + +def png_bytes(color: str) -> bytes: + output = BytesIO() + Image.new("RGBA", (10, 8), color).save(output, format="PNG") + return output.getvalue() + + +class GenericImageManagerTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) / "Game" + self.images = self.root / "assets" / "ui" + self.images.mkdir(parents=True) + self.source = self.images / "menu.png" + self.source.write_bytes(png_bytes("red")) + + def tearDown(self): + self.temp.cleanup() + + def test_scan_mirrors_game_relative_path_in_workspace(self): + asset = scan_generic_image_assets(self.root, self.root / "assets")[0] + + self.assertEqual(asset.engine_id, PROFILE_GENERIC) + self.assertEqual(asset.asset_id, "assets/ui/menu.png") + self.assertEqual( + asset.plain_path, + self.root / ".dazedtl" / "images" / "assets" / "ui" / "menu.png", + ) + + def test_make_editable_is_non_destructive_and_does_not_overwrite(self): + asset = scan_generic_image_assets(self.root, self.root / "assets")[0] + original = self.source.read_bytes() + + first = copy_plain_assets_to_workspace(self.root, [asset]) + asset.plain_path.write_bytes(png_bytes("blue")) + second = copy_plain_assets_to_workspace(self.root, [asset]) + + self.assertEqual(first.completed, 1) + self.assertEqual(second.skipped, 1) + self.assertEqual(self.source.read_bytes(), original) + self.assertEqual(asset.plain_path.read_bytes(), png_bytes("blue")) + self.assertTrue((self.root / ".dazedtl" / "image_manager" / "manifest.json").is_file()) + + def test_patch_publishes_backup_and_exact_allow_rule(self): + asset = scan_generic_image_assets(self.root, self.root / "assets")[0] + original = self.source.read_bytes() + copy_plain_assets_to_workspace(self.root, [asset]) + translated = png_bytes("green") + asset.plain_path.write_bytes(translated) + self.root.joinpath(".gitignore").write_text("*.png\n/.dazedtl/\n", encoding="utf-8") + + result = prepare_plain_assets_for_patch(self.root, [asset]) + + self.assertEqual(result.completed, 1) + self.assertEqual(result.errors, []) + self.assertEqual(self.source.read_bytes(), translated) + backup = self.root / ".dazedtl" / "image_backups" / "assets/ui/menu.png" + self.assertEqual(backup.read_bytes(), original) + self.assertIn( + "!/assets/ui/menu.png", + self.root.joinpath(".gitignore").read_text(encoding="utf-8"), + ) + + def test_patch_rejects_source_changed_after_editable_copy(self): + asset = scan_generic_image_assets(self.root, self.root / "assets")[0] + copy_plain_assets_to_workspace(self.root, [asset]) + asset.plain_path.write_bytes(png_bytes("green")) + external = png_bytes("purple") + self.source.write_bytes(external) + + result = prepare_plain_assets_for_patch(self.root, [asset]) + + self.assertEqual(result.completed, 0) + self.assertIn("runtime image changed", result.errors[0]) + self.assertEqual(self.source.read_bytes(), external) + + def test_patch_batch_preflight_is_all_or_nothing(self): + second_source = self.images / "title.png" + second_source.write_bytes(png_bytes("yellow")) + first, second = scan_generic_image_assets(self.root, self.root / "assets") + copy_plain_assets_to_workspace(self.root, [first, second]) + first.plain_path.write_bytes(png_bytes("green")) + second.plain_path.write_bytes(png_bytes("blue")) + second_source.write_bytes(png_bytes("purple")) + first_original = self.source.read_bytes() + + result = prepare_plain_assets_for_patch(self.root, [first, second]) + + self.assertEqual(result.completed, 0) + self.assertEqual(self.source.read_bytes(), first_original) + self.assertIn("runtime image changed", result.errors[0]) + + def test_patch_rolls_back_runtime_if_gitignore_update_fails(self): + asset = scan_generic_image_assets(self.root, self.root / "assets")[0] + original = self.source.read_bytes() + copy_plain_assets_to_workspace(self.root, [asset]) + asset.plain_path.write_bytes(png_bytes("green")) + + with patch( + "util.rpgmaker_images.add_patch_exceptions", + side_effect=OSError("ignore file is read-only"), + ): + result = prepare_plain_assets_for_patch(self.root, [asset]) + + self.assertEqual(result.completed, 0) + self.assertEqual(self.source.read_bytes(), original) + self.assertIn("rolled back", result.errors[0]) + stage = self.root / ".dazedtl" / "image_patch_stage" + self.assertFalse(stage.exists() and any(stage.iterdir())) + + def test_patch_rejects_corrupt_editable_png(self): + asset = scan_generic_image_assets(self.root, self.root / "assets")[0] + original = self.source.read_bytes() + copy_plain_assets_to_workspace(self.root, [asset]) + asset.plain_path.write_bytes(b"not a PNG") + + result = prepare_plain_assets_for_patch(self.root, [asset]) + + self.assertEqual(result.completed, 0) + self.assertIn("not a valid PNG", result.errors[0]) + self.assertEqual(self.source.read_bytes(), original) + + def test_case_only_asset_collision_is_rejected(self): + self.images.joinpath("Menu.png").write_bytes(png_bytes("blue")) + + with self.assertRaisesRegex(ValueError, "differ only by letter case"): + scan_profile_assets(PROFILE_GENERIC, self.root, self.root / "assets") + + def test_generic_root_must_remain_inside_game(self): + outside = Path(self.temp.name) / "Outside" + outside.mkdir() + + with self.assertRaisesRegex(ValueError, "outside the selected game folder"): + scan_generic_image_assets(self.root, outside) + + def test_scan_is_read_only(self): + scan_generic_image_assets(self.root, self.root / "assets") + + self.assertFalse((self.root / ".dazedtl").exists()) + self.assertFalse((self.root / ".gitignore").exists()) + + +class ImageEngineDetectionTests(unittest.TestCase): + def test_registry_exposes_rpgmaker_and_generic_capabilities(self): + profiles = {profile.engine_id: profile for profile in registered_image_profiles()} + + self.assertTrue(profiles[PROFILE_RPGMAKER_MVMZ].supports_encryption) + self.assertFalse(profiles[PROFILE_GENERIC].supports_encryption) + self.assertTrue(profiles[PROFILE_GENERIC].configurable_image_root) + + def test_detects_rpgmaker_from_system_json_and_img(self): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + (root / "data").mkdir() + (root / "img").mkdir() + (root / "data" / "System.json").write_text("{}", encoding="utf-8") + + detection = detect_image_engine(root) + + self.assertEqual(detection.engine_id, PROFILE_RPGMAKER_MVMZ) + self.assertEqual(detection.confidence, "high") + + def test_unknown_layout_falls_back_to_generic_with_suggestion(self): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + images = root / "assets" / "images" + images.mkdir(parents=True) + images.joinpath("title.png").write_bytes(png_bytes("black")) + + detection = detect_image_engine(root) + + self.assertEqual(detection.engine_id, PROFILE_GENERIC) + self.assertEqual(detection.suggested_image_root, images) + + +class RPGMakerImageProfileTests(unittest.TestCase): + def test_profile_preserves_decrypt_edit_reencrypt_workflow(self): + key = bytes.fromhex("00112233445566778899aabbccddeeff") + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + image_root = root / "img" / "pictures" + data_root = root / "data" + image_root.mkdir(parents=True) + data_root.mkdir() + data_root.joinpath("System.json").write_text( + '{"encryptionKey":"00112233445566778899aabbccddeeff"}', + encoding="utf-8", + ) + runtime = image_root / "title.png_" + runtime.write_bytes(encrypt_image_bytes(png_bytes("red"), key)) + asset = scan_profile_assets(PROFILE_RPGMAKER_MVMZ, root)[0] + + made = make_profile_assets_editable( + PROFILE_RPGMAKER_MVMZ, root, [asset], key + ) + translated = png_bytes("blue") + asset.plain_path.write_bytes(translated) + patched = prepare_profile_assets_for_patch( + PROFILE_RPGMAKER_MVMZ, root, [asset], key + ) + + self.assertEqual(made.completed, 1) + self.assertEqual(patched.completed, 1) + self.assertEqual(patched.errors, []) + self.assertEqual(decrypt_image_bytes(runtime.read_bytes(), key), translated) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rpgmaker_image_manager.py b/tests/test_rpgmaker_image_manager.py index b9629d1..3c02fd7 100644 --- a/tests/test_rpgmaker_image_manager.py +++ b/tests/test_rpgmaker_image_manager.py @@ -13,6 +13,7 @@ from PyQt5.QtWidgets import QApplication, QAbstractItemView, QMainWindow, QMessa from gui.rpgmaker_image_manager import RPGMakerImageManager, _PAGE_SIZE from gui.workflow_tab import _inspect_image_workflow +from util.image_manager import PROFILE_AUTO, PROFILE_GENERIC, PROFILE_RPGMAKER_MVMZ class RPGMakerImageManagerSelectionTests(unittest.TestCase): @@ -295,6 +296,12 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase): self.assertTrue(self.manager.copy_translation_button.isEnabled()) + def test_loading_and_scanning_project_is_read_only(self): + self.assertEqual(self.manager.engine_combo.currentData(), PROFILE_AUTO) + self.assertEqual(self.manager.engine_id, PROFILE_RPGMAKER_MVMZ) + self.assertFalse((self.game_root / ".dazedtl").exists()) + self.assertFalse((self.game_root / ".gitignore").exists()) + def test_translation_skill_copies_project_specific_editable_folder(self): asset = self.manager.assets[0] asset.plain_path.parent.mkdir(parents=True, exist_ok=True) @@ -400,12 +407,46 @@ class RPGMakerImageManagerNavigationTests(unittest.TestCase): 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("from gui.image_manager import ImageManager", main_source) + self.assertIn("self.image_manager_tab = ImageManager", main_source) self.assertIn('create_nav_button("🖼", "Images")', main_source) self.assertIn('(\"6 Images\", self._build_step6_images)', workflow_source) self.assertIn('self._open_images_btn = _make_btn("🖼 Open Images"', workflow_source) +class GenericImageManagerUITests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = QApplication.instance() or QApplication([]) + + def test_auto_detects_generic_root_and_uses_make_editable_actions(self): + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + game_root = root / "Game" + image_root = game_root / "assets" / "images" / "ui" + image_root.mkdir(parents=True) + Image.new("RGBA", (20, 12), "green").save(image_root / "menu.png") + settings = QSettings(str(root / "settings.ini"), QSettings.IniFormat) + manager = RPGMakerImageManager(game_root, settings=settings) + manager.resize(1000, 700) + manager.show() + manager._scan_worker.wait(5000) + self.app.processEvents() + try: + self.assertEqual(manager.engine_combo.currentData(), PROFILE_AUTO) + self.assertEqual(manager.engine_id, PROFILE_GENERIC) + self.assertTrue(manager.generic_root_host.isVisible()) + self.assertEqual(manager.decrypt_selected_button.text(), "Make editable") + self.assertEqual(manager.image_list.count(), 1) + self.assertEqual(manager.assets[0].asset_id, "assets/images/ui/menu.png") + self.assertFalse((game_root / ".dazedtl").exists()) + finally: + for worker in list(manager._thumbnail_workers): + worker.wait(5000) + manager.close() + self.app.processEvents() + + class RPGMakerWorkflowImageStepTests(unittest.TestCase): @classmethod def setUpClass(cls): diff --git a/util/image_manager.py b/util/image_manager.py new file mode 100644 index 0000000..6ead55c --- /dev/null +++ b/util/image_manager.py @@ -0,0 +1,673 @@ +"""Engine-neutral image workspace, profile registry, and loose-image support.""" + +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path +from typing import Callable, Iterable +import hashlib +import json +import os +import shutil +import tempfile +import uuid + + +PROFILE_AUTO = "auto" +PROFILE_RPGMAKER_MVMZ = "rpgmaker_mvmz" +PROFILE_GENERIC = "generic" +SUPPORTED_PLAIN_EXTENSIONS = (".png",) +_EXCLUDED_SCAN_DIRS = {".dazedtl", ".git", "__pycache__"} + + +@dataclass(frozen=True) +class ImageAsset: + """One logical image and its editable/runtime representations.""" + + asset_id: str + relative_png: Path + plain_path: Path + encrypted_path: Path | None = None + runtime_plain_path: Path | None = None + engine_id: str = PROFILE_RPGMAKER_MVMZ + + @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() + + @property + def has_runtime_plain(self) -> bool: + return self.runtime_plain_path is not None and self.runtime_plain_path.is_file() + + @property + def runtime_path(self) -> Path | None: + if self.has_encrypted: + return self.encrypted_path + return self.runtime_plain_path + + +@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 = [] + + +@dataclass(frozen=True) +class ImageEngineDetection: + engine_id: str + confidence: str + reason: str + suggested_image_root: Path | None = None + + +@dataclass(frozen=True) +class ImageEngineProfile: + """Discoverable capabilities for an image-engine backend.""" + + engine_id: str + label: str + configurable_image_root: bool + supports_encryption: bool + supported_extensions: tuple[str, ...] = SUPPORTED_PLAIN_EXTENSIONS + + +IMAGE_ENGINE_PROFILES = ( + ImageEngineProfile( + PROFILE_RPGMAKER_MVMZ, + "RPG Maker MV/MZ", + configurable_image_root=False, + supports_encryption=True, + ), + ImageEngineProfile( + PROFILE_GENERIC, + "Generic / Loose Images", + configurable_image_root=True, + supports_encryption=False, + ), +) + + +def registered_image_profiles() -> tuple[ImageEngineProfile, ...]: + return IMAGE_ENGINE_PROFILES + + +def get_image_profile(engine_id: str) -> ImageEngineProfile: + for profile in IMAGE_ENGINE_PROFILES: + if profile.engine_id == engine_id: + return profile + raise ValueError(f"Unsupported image engine profile: {engine_id}") + + +def _resolved_root(game_root: str | Path) -> Path: + root = Path(game_root).expanduser().resolve() + if not root.is_dir(): + raise FileNotFoundError(f"Game folder not found: {root}") + return root + + +def _inside(root: Path, path: Path, *, label: str = "Image path") -> Path: + try: + return path.resolve().relative_to(root.resolve()) + except ValueError as exc: + raise ValueError(f"{label} is outside the selected game folder: {path}") from exc + + +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 editable_workspace_root(game_root: str | Path) -> Path: + return Path(game_root).expanduser().resolve() / ".dazedtl" / "images" + + +def image_manager_state_root(game_root: str | Path) -> Path: + return Path(game_root).expanduser().resolve() / ".dazedtl" / "image_manager" + + +def _ensure_workspace_ignore(root: Path) -> None: + ignore_path = root / ".gitignore" + existing = ( + ignore_path.read_text(encoding="utf-8", errors="surrogateescape") + if ignore_path.exists() + else "" + ) + if "/.dazedtl/" in existing.splitlines(): + return + text = existing + if text and not text.endswith("\n"): + text += "\n" + if "# DazedTL image manager working files" not in text: + text += "\n# DazedTL image manager working files\n" + text += "/.dazedtl/\n" + _atomic_write(ignore_path, text.encode("utf-8", errors="surrogateescape")) + + +def ensure_editable_workspace(game_root: str | Path) -> Path: + """Create the workspace without migrating or touching runtime assets.""" + + root = _resolved_root(game_root) + workspace = editable_workspace_root(root) + workspace.mkdir(parents=True, exist_ok=True) + _ensure_workspace_ignore(root) + return workspace + + +def normalize_generic_image_root( + game_root: str | Path, image_root: str | Path | None +) -> Path: + root = _resolved_root(game_root) + if image_root is None or not str(image_root).strip(): + raise ValueError("Choose an image folder for the Generic / Loose Images profile.") + candidate = Path(image_root).expanduser() + if not candidate.is_absolute(): + candidate = root / candidate + candidate = candidate.resolve() + _inside(root, candidate, label="Generic image folder") + if not candidate.is_dir(): + raise FileNotFoundError(f"Image folder not found: {candidate}") + return candidate + + +def _iter_plain_images(root: Path) -> Iterable[Path]: + for current, dirnames, filenames in os.walk(root, followlinks=False): + current_path = Path(current) + kept: list[str] = [] + for name in dirnames: + child = current_path / name + if name in _EXCLUDED_SCAN_DIRS or child.is_symlink(): + continue + kept.append(name) + dirnames[:] = kept + for name in filenames: + path = current_path / name + if path.is_symlink() or path.suffix.casefold() not in SUPPORTED_PLAIN_EXTENSIONS: + continue + yield path + + +def scan_generic_image_assets( + game_root: str | Path, image_root: str | Path | None +) -> list[ImageAsset]: + root = _resolved_root(game_root) + source_root = normalize_generic_image_root(root, image_root) + source_relative = _inside(root, source_root, label="Generic image folder") + workspace = editable_workspace_root(root) + workspace_source = workspace / source_relative + by_id: dict[str, dict[str, Path]] = {} + + for runtime in _iter_plain_images(source_root): + relative = _inside(root, runtime) + asset_id = relative.as_posix() + by_id[asset_id] = { + "relative": relative, + "runtime": runtime, + "plain": workspace / relative, + } + + if workspace_source.is_dir(): + for editable in _iter_plain_images(workspace_source): + relative_under_source = editable.relative_to(workspace_source) + relative = source_relative / relative_under_source + asset_id = relative.as_posix() + by_id.setdefault( + asset_id, + { + "relative": relative, + "runtime": root / relative, + "plain": editable, + }, + )["plain"] = editable + + return sorted( + ( + ImageAsset( + asset_id=asset_id, + relative_png=entry["relative"], + plain_path=entry["plain"], + runtime_plain_path=entry["runtime"], + engine_id=PROFILE_GENERIC, + ) + for asset_id, entry in by_id.items() + ), + key=lambda asset: asset.asset_id.casefold(), + ) + + +def _sha256_path(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _validate_png_bytes(data: bytes, asset_id: str) -> None: + from PIL import Image + + try: + with Image.open(BytesIO(data)) as image: + if image.format != "PNG": + raise ValueError(f"expected PNG data, found {image.format or 'unknown'}") + image.verify() + except Exception as exc: + raise ValueError(f"editable image is not a valid PNG: {asset_id}: {exc}") from exc + + +def _validate_asset_collisions(assets: list[ImageAsset]) -> list[ImageAsset]: + seen: dict[str, str] = {} + for asset in assets: + folded = asset.asset_id.casefold() + previous = seen.get(folded) + if previous is not None and previous != asset.asset_id: + raise ValueError( + "Image paths differ only by letter case and cannot be patched safely on " + f"Windows: {previous} and {asset.asset_id}" + ) + seen[folded] = asset.asset_id + return assets + + +def _manifest_path(game_root: str | Path) -> Path: + return image_manager_state_root(game_root) / "manifest.json" + + +def load_image_manifest(game_root: str | Path) -> dict: + path = _manifest_path(game_root) + if not path.is_file(): + return {"version": 1, "assets": {}} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {"version": 1, "assets": {}} + if not isinstance(data, dict) or not isinstance(data.get("assets"), dict): + return {"version": 1, "assets": {}} + return data + + +def save_image_manifest(game_root: str | Path, manifest: dict) -> None: + payload = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + _atomic_write(_manifest_path(game_root), payload.encode("utf-8")) + + +def _manifest_key(asset: ImageAsset) -> str: + return f"{asset.engine_id}:{asset.asset_id}" + + +def record_asset_baseline(game_root: str | Path, asset: ImageAsset) -> None: + record_asset_baselines(game_root, [asset]) + + +def record_asset_baselines( + game_root: str | Path, assets: Iterable[ImageAsset] +) -> None: + root = Path(game_root).expanduser().resolve() + manifest = load_image_manifest(root) + changed = False + for asset in assets: + runtime = asset.runtime_path + if runtime is None or not runtime.is_file(): + continue + manifest["assets"][_manifest_key(asset)] = { + "engine": asset.engine_id, + "asset_id": asset.asset_id, + "runtime_path": runtime.resolve().relative_to(root).as_posix(), + "baseline_sha256": _sha256_path(runtime), + } + changed = True + if changed: + save_image_manifest(root, manifest) + + +def asset_source_conflict(game_root: str | Path, asset: ImageAsset) -> bool: + runtime = asset.runtime_path + if runtime is None or not runtime.is_file(): + return False + entry = load_image_manifest(game_root)["assets"].get(_manifest_key(asset), {}) + baseline = entry.get("baseline_sha256") + return bool(baseline and baseline != _sha256_path(runtime)) + + +def copy_plain_assets_to_workspace( + game_root: str | Path, + assets: Iterable[ImageAsset], + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + root = _resolved_root(game_root) + workspace = ensure_editable_workspace(root) + result = ImageActionResult() + for asset in assets: + try: + if asset.has_plain: + result.skipped += 1 + continue + runtime = asset.runtime_plain_path + if runtime is None or not runtime.is_file(): + raise FileNotFoundError("runtime PNG no longer exists") + _inside(root, runtime) + _inside(workspace, asset.plain_path, label="Editable image target") + if progress: + progress(f"Making {asset.asset_id} editable") + _atomic_write(asset.plain_path, runtime.read_bytes()) + record_asset_baseline(root, asset) + result.completed += 1 + except Exception as exc: + result.errors.append(f"{asset.asset_id}: {exc}") + return result + + +def prepare_plain_assets_for_patch( + game_root: str | Path, + assets: Iterable[ImageAsset], + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + """Publish loose PNG edits through the transactional patch path.""" + + return _prepare_assets_transaction( + PROFILE_GENERIC, game_root, assets, None, progress=progress + ) + + +def _prepare_assets_transaction( + engine_id: str, + game_root: str | Path, + assets: Iterable[ImageAsset], + key: bytes | None, + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + """Preflight, stage, and atomically publish a set of runtime image files.""" + + from util.rpgmaker_images import ( + add_patch_exceptions, + decrypt_image_bytes, + encrypt_image_bytes, + ) + + root = _resolved_root(game_root) + result = ImageActionResult() + candidates: list[tuple[ImageAsset, Path, Path, bytes]] = [] + transaction_root = ( + root / ".dazedtl" / "image_patch_stage" / uuid.uuid4().hex + ) + + for asset in assets: + try: + if not asset.has_plain: + raise FileNotFoundError("make this image editable before patching it") + runtime = asset.runtime_path + if runtime is None or not runtime.is_file(): + raise FileNotFoundError("runtime image no longer exists") + relative = _inside(root, runtime) + if asset_source_conflict(root, asset): + raise RuntimeError( + "runtime image changed after the editable copy was created; " + "remove/recreate the editable copy or resolve the conflict manually" + ) + editable = asset.plain_path.read_bytes() + _validate_png_bytes(editable, asset.asset_id) + current = runtime.read_bytes() + if asset.has_encrypted: + if engine_id != PROFILE_RPGMAKER_MVMZ: + raise ValueError("the active engine profile cannot encode this image") + if key is None: + raise ValueError("the RPG Maker encryption key is required") + if decrypt_image_bytes(current, key) == editable: + result.skipped += 1 + continue + output = encrypt_image_bytes(editable, key) + else: + if current == editable: + result.skipped += 1 + continue + output = editable + staged_new = transaction_root / "new" / relative + staged_old = transaction_root / "old" / relative + _atomic_write(staged_new, output) + _atomic_write(staged_old, current) + candidates.append((asset, runtime, relative, output)) + except Exception as exc: + result.errors.append(f"{asset.asset_id}: {exc}") + + # Treat a selected batch as one transaction. A conflict or invalid image + # must not leave the other selected files partially published. + if result.errors: + if transaction_root.exists(): + shutil.rmtree(transaction_root) + return result + if not candidates: + if transaction_root.exists(): + shutil.rmtree(transaction_root) + return result + + published: list[tuple[Path, Path]] = [] + targets = [runtime for _asset, runtime, _relative, _output in candidates] + try: + for asset, runtime, relative, output in candidates: + backup = root / ".dazedtl" / "image_backups" / relative + if not backup.exists(): + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(transaction_root / "old" / relative, backup) + if progress: + action = "Encrypting" if asset.has_encrypted else "Publishing" + progress(f"{action} {asset.asset_id}") + _atomic_write(runtime, output) + published.append((runtime, transaction_root / "old" / relative)) + + if progress: + progress("Updating .gitignore image allow-rules") + result.gitignore_files = add_patch_exceptions(root, targets) + record_asset_baselines( + root, (asset for asset, _runtime, _relative, _output in candidates) + ) + except Exception as exc: + rollback_errors: list[str] = [] + for runtime, staged_old in reversed(published): + try: + _atomic_write(runtime, staged_old.read_bytes()) + except Exception as rollback_exc: + rollback_errors.append(f"{runtime}: {rollback_exc}") + result.errors.append(f"Patch transaction rolled back: {exc}") + result.errors.extend(f"Rollback failed: {error}" for error in rollback_errors) + return result + finally: + if transaction_root.exists(): + shutil.rmtree(transaction_root) + + result.completed = len(candidates) + result.patch_files = targets + return result + + +def detect_image_engine(game_root: str | Path) -> ImageEngineDetection: + root = _resolved_root(game_root) + markers = ( + root / "Game.rpgproject", + root / "game.rmmzproject", + root / "www" / "data" / "System.json", + root / "data" / "System.json", + ) + for content in (root / "www", root): + image_root = content / "img" + if not image_root.is_dir(): + continue + encrypted = next( + ( + path + for pattern in ("*.rpgmvp", "*.png_") + for path in image_root.rglob(pattern) + ), + None, + ) + if encrypted is not None or any(marker.exists() for marker in markers): + evidence = encrypted.name if encrypted is not None else "System/project markers" + return ImageEngineDetection( + PROFILE_RPGMAKER_MVMZ, + "high", + f"RPG Maker MV/MZ detected from {evidence} and {image_root}", + image_root, + ) + + candidates = ( + root / "images", + root / "image", + root / "assets" / "images", + root / "assets", + root / "game" / "images", + root / "img", + ) + suggested = next( + ( + candidate + for candidate in candidates + if candidate.is_dir() and next(_iter_plain_images(candidate), None) is not None + ), + None, + ) + reason = ( + f"No RPG Maker encryption markers found; loose PNGs found under {suggested}" + if suggested + else "No supported engine markers found; choose the folder containing loose PNGs" + ) + return ImageEngineDetection(PROFILE_GENERIC, "fallback", reason, suggested) + + +def profile_label(engine_id: str) -> str: + try: + return get_image_profile(engine_id).label + except ValueError: + return engine_id + + +def scan_profile_assets( + engine_id: str, + game_root: str | Path, + image_root: str | Path | None = None, +) -> list[ImageAsset]: + if engine_id == PROFILE_RPGMAKER_MVMZ: + from util.rpgmaker_images import scan_image_assets + + return _validate_asset_collisions(scan_image_assets(game_root)) + if engine_id == PROFILE_GENERIC: + return _validate_asset_collisions( + scan_generic_image_assets(game_root, image_root) + ) + raise ValueError(f"Unsupported image engine profile: {engine_id}") + + +def make_profile_assets_editable( + engine_id: str, + game_root: str | Path, + assets: Iterable[ImageAsset], + key: bytes | None, + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + assets = list(assets) + if engine_id == PROFILE_RPGMAKER_MVMZ: + from util.rpgmaker_images import decrypt_assets + + encrypted = [asset for asset in assets if asset.has_encrypted] + plain = [asset for asset in assets if not asset.has_encrypted] + result = ImageActionResult() + if encrypted: + if key is None: + result.errors.append("RPG Maker encryption key is required.") + else: + before = {asset.asset_id: asset.has_plain for asset in encrypted} + decrypted = decrypt_assets( + encrypted, key, game_root=game_root, overwrite=False, progress=progress + ) + result.completed += decrypted.completed + result.skipped += decrypted.skipped + result.errors.extend(decrypted.errors) + for asset in encrypted: + if not before[asset.asset_id] and asset.plain_path.is_file(): + record_asset_baseline(game_root, asset) + copied = copy_plain_assets_to_workspace(game_root, plain, progress=progress) + result.completed += copied.completed + result.skipped += copied.skipped + result.errors.extend(copied.errors) + return result + if engine_id == PROFILE_GENERIC: + return copy_plain_assets_to_workspace(game_root, assets, progress=progress) + raise ValueError(f"Unsupported image engine profile: {engine_id}") + + +def prepare_profile_assets_for_patch( + engine_id: str, + game_root: str | Path, + assets: Iterable[ImageAsset], + key: bytes | None, + *, + progress: Callable[[str], None] | None = None, +) -> ImageActionResult: + assets = list(assets) + if engine_id == PROFILE_GENERIC: + return prepare_plain_assets_for_patch(game_root, assets, progress=progress) + if engine_id == PROFILE_RPGMAKER_MVMZ: + return _prepare_assets_transaction( + PROFILE_RPGMAKER_MVMZ, + game_root, + assets, + key, + progress=progress, + ) + raise ValueError(f"Unsupported image engine profile: {engine_id}") + + +def preview_profile_png_bytes( + engine_id: str, asset: ImageAsset, key: bytes | None +) -> bytes: + if engine_id == PROFILE_RPGMAKER_MVMZ: + from util.rpgmaker_images import preview_png_bytes + + return preview_png_bytes(asset, key) + if asset.has_plain: + return asset.plain_path.read_bytes() + if asset.has_runtime_plain: + return asset.runtime_plain_path.read_bytes() + raise FileNotFoundError(asset.runtime_plain_path or asset.plain_path) + + +def thumbnail_profile_png_bytes( + engine_id: str, asset: ImageAsset, key: bytes | None, size: int = 128 +) -> bytes: + from PIL import Image + + raw = preview_profile_png_bytes(engine_id, asset, key) + with Image.open(BytesIO(raw)) as image: + image.thumbnail((size, size)) + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA") + output = BytesIO() + image.save(output, format="PNG") + return output.getvalue() diff --git a/util/rpgmaker_images.py b/util/rpgmaker_images.py index 2319016..1c2b321 100644 --- a/util/rpgmaker_images.py +++ b/util/rpgmaker_images.py @@ -8,7 +8,6 @@ image-translation workflow. from __future__ import annotations -from dataclasses import dataclass from pathlib import Path from typing import Callable, Iterable import io @@ -18,6 +17,8 @@ import re import shutil import tempfile +from util.image_manager import ImageActionResult, ImageAsset + RPGMV_HEADER = bytes( [0x52, 0x50, 0x47, 0x4D, 0x56, 0x00, 0x00, 0x00, @@ -29,46 +30,6 @@ EDITABLE_WORKSPACE_NAME = "images" LEGACY_EDITABLE_WORKSPACE_NAME = "DazedTL_Images" -@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 - runtime_plain_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() - - @property - def has_runtime_plain(self) -> bool: - return self.runtime_plain_path is not None and self.runtime_plain_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/``."""