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:
DazedAnon 2026-07-26 12:56:46 -05:00
parent f4d5e48874
commit ef7a3f8235
12 changed files with 1441 additions and 126 deletions

View file

@ -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`.

View file

@ -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

View 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
View file

@ -0,0 +1,5 @@
"""Canonical import for the engine-aware Image Manager."""
from gui.rpgmaker_image_manager import ImageManager
__all__ = ["ImageManager"]

View file

@ -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)

View file

@ -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

View file

@ -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 67 - Image Manager and playtest tools are MV/MZ only.
# Steps 67 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:

View file

@ -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
View 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()

View file

@ -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
View 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()

View file

@ -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/``."""