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
This commit is contained in:
parent
f4d5e48874
commit
ef7a3f8235
12 changed files with 1441 additions and 126 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
90
docs/image-manager-engine-plan.md
Normal file
90
docs/image-manager-engine-plan.md
Normal file
|
|
@ -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/<game-relative logical path>`
|
||||
- Manager metadata: `.dazedtl/image_manager/`
|
||||
- Runtime backups: `.dazedtl/image_backups/<game-relative runtime path>`
|
||||
- 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.
|
||||
5
gui/image_manager.py
Normal file
5
gui/image_manager.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Canonical import for the engine-aware Image Manager."""
|
||||
|
||||
from gui.rpgmaker_image_manager import ImageManager
|
||||
|
||||
__all__ = ["ImageManager"]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2522,8 +2522,9 @@ class WorkflowTab(QWidget):
|
|||
return
|
||||
if self._ace_encrypted or self._ace_rvdata_dir or self._ace_json_dir:
|
||||
label.setText(
|
||||
"<span style='color:#e9a12a'>⚠ Image Manager supports RPG Maker MV/MZ "
|
||||
"projects only; this project is RPG Maker Ace.</span>"
|
||||
"<span style='color:#e9a12a'>⚠ 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.</span>"
|
||||
)
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
231
tests/test_image_manager.py
Normal file
231
tests/test_image_manager.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
673
util/image_manager.py
Normal file
673
util/image_manager.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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/``."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue