feat(rpgmaker): add scoped image patch preparation

- Patch highlighted editable images or all images when none are highlighted
- Skip unchanged images while preserving editable workspace copies
- Open the editable folder matching the current image or folder filter
This commit is contained in:
DazedAnon 2026-07-25 16:29:19 -05:00
parent 3d82203496
commit d82895cc4d
4 changed files with 176 additions and 22 deletions

View file

@ -43,6 +43,7 @@ from util.rpgmaker_images import (
preview_png_bytes, preview_png_bytes,
read_encryption_key, read_encryption_key,
remove_editable_assets, remove_editable_assets,
resolve_content_root,
scan_image_assets, scan_image_assets,
thumbnail_png_bytes, thumbnail_png_bytes,
) )
@ -198,7 +199,8 @@ class RPGMakerImageManager(QWidget):
root.addWidget(title) root.addWidget(title)
intro = QLabel( intro = QLabel(
"Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, " "Select images while browsing, decrypt them into .dazedtl/images, edit the PNGs, "
"then encrypt the editable images when they are ready. " "then highlight the finished images to patch only those, or clear highlights to "
"patch every editable image. "
"Pages keep very large games responsive." "Pages keep very large games responsive."
) )
intro.setWordWrap(True) intro.setWordWrap(True)
@ -291,7 +293,8 @@ class RPGMakerImageManager(QWidget):
action_row.setSpacing(8) action_row.setSpacing(8)
self.open_workspace_button = QPushButton("Open folder") self.open_workspace_button = QPushButton("Open folder")
self.open_workspace_button.setToolTip( self.open_workspace_button.setToolTip(
"Open the game-local .dazedtl/images folder in your file manager." "Open the highlighted image's editable folder, the chosen folder filter, or the "
"editable img root."
) )
self.open_workspace_button.clicked.connect(self._open_editable_folder) self.open_workspace_button.clicked.connect(self._open_editable_folder)
self.decrypt_selected_button = QPushButton("Decrypt highlighted") self.decrypt_selected_button = QPushButton("Decrypt highlighted")
@ -304,14 +307,14 @@ class RPGMakerImageManager(QWidget):
"untouched and can be decrypted again. The Delete key does the same thing." "untouched and can be decrypted again. The Delete key does the same thing."
) )
self.remove_button.clicked.connect(self._remove_highlighted) self.remove_button.clicked.connect(self._remove_highlighted)
self.prepare_button = QPushButton("Encrypt + patch") self.prepare_button = QPushButton("Encrypt all + patch")
self.prepare_button.setStyleSheet( self.prepare_button.setStyleSheet(
"QPushButton{border:1px solid #4ec9b0;color:#4ec9b0;font-weight:bold;padding:6px 14px;}" "QPushButton{border:1px solid #4ec9b0;color:#4ec9b0;font-weight:bold;padding:6px 14px;}"
"QPushButton:hover{background:#18352f;}" "QPushButton:hover{background:#18352f;}"
) )
self.prepare_button.setToolTip( self.prepare_button.setToolTip(
"Re-encrypt every PNG in the editable folder, preserve original encrypted files in " "With highlighted images, patch only their editable PNGs. With no highlights, patch "
".dazedtl/image_backups, and add exact .gitignore allow-rules." "every editable PNG. Editable copies remain in .dazedtl/images until removed."
) )
self.prepare_button.clicked.connect(self._prepare_checked) self.prepare_button.clicked.connect(self._prepare_checked)
action_buttons = ( action_buttons = (
@ -445,6 +448,7 @@ class RPGMakerImageManager(QWidget):
f"Found {len(assets):,} images · {encrypted:,} encrypted · " f"Found {len(assets):,} images · {encrypted:,} encrypted · "
f"{editable:,} editable PNG copies · {len(self.selected_ids):,} highlighted" f"{editable:,} editable PNG copies · {len(self.selected_ids):,} highlighted"
) )
self._update_prepare_scope()
self._apply_filters() self._apply_filters()
def _scan_error(self, generation: int, message: str) -> None: def _scan_error(self, generation: int, message: str) -> None:
@ -549,12 +553,19 @@ class RPGMakerImageManager(QWidget):
self._update_selection_status() self._update_selection_status()
def _update_selection_status(self) -> None: def _update_selection_status(self) -> None:
self._update_prepare_scope()
self.status_label.setText( self.status_label.setText(
f"{len(self.filtered_assets):,} matching images · " f"{len(self.filtered_assets):,} matching images · "
f"{len(self.selected_ids):,} highlighted · " f"{len(self.selected_ids):,} highlighted · "
f"{sum(asset.has_plain for asset in self.assets):,} editable" f"{sum(asset.has_plain for asset in self.assets):,} editable"
) )
def _update_prepare_scope(self) -> None:
if self.selected_ids:
self.prepare_button.setText("Encrypt highlighted + patch")
else:
self.prepare_button.setText("Encrypt all + patch")
def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None: def _show_preview(self, current: QListWidgetItem | None, _previous=None) -> None:
if current is None: if current is None:
return return
@ -595,9 +606,27 @@ class RPGMakerImageManager(QWidget):
return return
try: try:
workspace = ensure_editable_workspace(self.game_root) workspace = ensure_editable_workspace(self.game_root)
if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(workspace))): current = self.image_list.currentItem()
asset = (
self.assets_by_id.get(current.data(_ASSET_ID_ROLE))
if current is not None
else None
)
if asset is not None:
target = asset.plain_path.parent
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
target.resolve().relative_to(workspace.resolve())
target.mkdir(parents=True, exist_ok=True)
if not QDesktopServices.openUrl(QUrl.fromLocalFile(str(target))):
raise RuntimeError("The system file manager could not open the folder.") raise RuntimeError("The system file manager could not open the folder.")
self.status_label.setText(f"Editable images: {workspace}") self.status_label.setText(f"Editable images: {target}")
except Exception as exc: except Exception as exc:
QMessageBox.warning(self, "Editable Image Folder", str(exc)) QMessageBox.warning(self, "Editable Image Folder", str(exc))
@ -618,12 +647,16 @@ class RPGMakerImageManager(QWidget):
return False return False
def _decrypt_checked(self) -> None: def _decrypt_checked(self) -> None:
assets = [asset for asset in self._selected_assets() if asset.has_encrypted] assets = [
asset
for asset in self._selected_assets()
if asset.has_encrypted and not asset.has_plain
]
if not assets: if not assets:
QMessageBox.information( QMessageBox.information(
self, self,
"Nothing Selected", "Nothing to Decrypt",
"Select one or more encrypted images first.", "Highlight one or more encrypted images that are not already editable.",
) )
return return
if self._ensure_key(): if self._ensure_key():
@ -667,24 +700,41 @@ class RPGMakerImageManager(QWidget):
self._start_action("remove", assets) self._start_action("remove", assets)
def _prepare_checked(self) -> None: def _prepare_checked(self) -> None:
assets = self._editable_assets() highlighted = bool(self.selected_ids)
assets = (
[asset for asset in self._selected_assets() if asset.has_plain]
if highlighted
else self._editable_assets()
)
if not assets: if not assets:
message = (
"None of the highlighted images are in the editable folder. Decrypt them first "
"or clear the highlights to patch every editable image."
if highlighted
else "Decrypt images into the editable folder first."
)
QMessageBox.information( QMessageBox.information(
self, self,
"No Editable Images", "No Editable Images",
"Decrypt images into the editable folder first.", message,
) )
return return
if any(asset.has_encrypted for asset in assets) and not self._ensure_key(): if any(asset.has_encrypted for asset in assets) and not self._ensure_key():
return return
scope = (
f"the {len(assets):,} highlighted editable image(s)"
if highlighted
else f"all {len(assets):,} editable image(s)"
)
answer = QMessageBox.question( answer = QMessageBox.question(
self, self,
"Prepare Images for Patch", "Prepare Images for Patch",
f"Encrypt and add all {len(assets):,} editable image(s) to the patch?\n\n" f"Check {scope} and add changed images to the patch?\n\n"
"Encrypted images will be rebuilt from their editable PNG in .dazedtl/images. " "Unchanged editable copies will be skipped. Changed encrypted images will be rebuilt "
"Their original encrypted " "from .dazedtl/images. Their original encrypted "
"files are backed up once under .dazedtl/image_backups. Exact allow-rules are then " "files are backed up once under .dazedtl/image_backups. Exact allow-rules are then "
"added to the applicable .gitignore files.", "added to the applicable .gitignore files. Editable PNG copies will remain available "
"for further changes until you remove them.",
QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes | QMessageBox.No,
QMessageBox.No, QMessageBox.No,
) )
@ -721,7 +771,8 @@ class RPGMakerImageManager(QWidget):
else: else:
summary = ( summary = (
f"Prepared {result.completed:,} image(s) for the patch and updated " f"Prepared {result.completed:,} image(s) for the patch and updated "
f"{len(result.gitignore_files):,} .gitignore file(s)." f"{len(result.gitignore_files):,} .gitignore file(s); "
f"skipped {result.skipped:,} unchanged image(s)."
) )
if result.errors: if result.errors:
shown = "\n".join(result.errors[:12]) shown = "\n".join(result.errors[:12])

View file

@ -195,6 +195,46 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase):
self.assertEqual(self.manager.image_list.count(), 0) self.assertEqual(self.manager.image_list.count(), 0)
self.assertEqual(self.manager.remove_button.text(), "Remove highlighted") self.assertEqual(self.manager.remove_button.text(), "Remove highlighted")
def test_prepare_uses_only_highlighted_editable_images(self):
highlighted = self.manager.assets[0]
not_highlighted = self.manager.assets[1]
for asset in (highlighted, not_highlighted):
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes())
self._click(0)
with (
patch.object(QMessageBox, "question", return_value=QMessageBox.Yes),
patch.object(self.manager, "_start_action") as start_action,
):
self.manager._prepare_checked()
action, assets = start_action.call_args.args
self.assertEqual(action, "prepare")
self.assertEqual(assets, [highlighted])
self.assertTrue(highlighted.plain_path.exists())
self.assertTrue(not_highlighted.plain_path.exists())
self.assertEqual(
self.manager.prepare_button.text(), "Encrypt highlighted + patch"
)
def test_prepare_uses_all_editable_images_without_highlights(self):
editable = self.manager.assets[:2]
for asset in editable:
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(asset.runtime_plain_path.read_bytes())
with (
patch.object(QMessageBox, "question", return_value=QMessageBox.Yes),
patch.object(self.manager, "_start_action") as start_action,
):
self.manager._prepare_checked()
action, assets = start_action.call_args.args
self.assertEqual(action, "prepare")
self.assertEqual(assets, editable)
self.assertEqual(self.manager.prepare_button.text(), "Encrypt all + patch")
def test_bottom_controls_share_one_row_and_action_width(self): def test_bottom_controls_share_one_row_and_action_width(self):
action_buttons = ( action_buttons = (
self.manager.open_workspace_button, self.manager.open_workspace_button,
@ -212,6 +252,21 @@ class RPGMakerImageManagerSelectionTests(unittest.TestCase):
self.manager.open_workspace_button.geometry().top(), self.manager.open_workspace_button.geometry().top(),
) )
def test_open_folder_uses_highlighted_images_editable_parent(self):
self._click(0)
asset_id = self.manager.image_list.currentItem().data(Qt.UserRole + 1)
expected = self.manager.assets_by_id[asset_id].plain_path.parent
with patch(
"gui.rpgmaker_image_manager.QDesktopServices.openUrl",
return_value=True,
) as open_url:
self.manager._open_editable_folder()
opened_url = open_url.call_args.args[0]
self.assertEqual(Path(opened_url.toLocalFile()), expected)
self.assertTrue(expected.is_dir())
def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self): def test_thumbnail_batch_keeps_one_stable_tile_per_asset(self):
for worker in list(self.manager._thumbnail_workers): for worker in list(self.manager._thumbnail_workers):
worker.wait(5000) worker.wait(5000)

View file

@ -116,6 +116,7 @@ class RPGMakerImageTests(unittest.TestCase):
self.assertEqual(result.completed, 1) self.assertEqual(result.completed, 1)
self.assertEqual(result.errors, []) self.assertEqual(result.errors, [])
self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated) self.assertEqual(decrypt_image_bytes(encrypted.read_bytes(), KEY), translated)
self.assertEqual(asset.plain_path.read_bytes(), translated)
backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp" backup = self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp"
self.assertEqual(backup.read_bytes(), original) self.assertEqual(backup.read_bytes(), original)
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8") ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
@ -123,11 +124,47 @@ class RPGMakerImageTests(unittest.TestCase):
self.assertIn("!/www/img/pictures/001.rpgmvp", ignore) self.assertIn("!/www/img/pictures/001.rpgmvp", ignore)
self.assertNotIn("!/www/img/pictures/001.png", ignore) self.assertNotIn("!/www/img/pictures/001.png", ignore)
prepare_assets_for_patch(self.root, [asset], KEY) second = prepare_assets_for_patch(self.root, [asset], KEY)
again = self.root.joinpath(".gitignore").read_text(encoding="utf-8") again = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertEqual(second.completed, 0)
self.assertEqual(second.skipped, 1)
self.assertEqual(again.count("!/www/img/pictures/001.rpgmvp"), 1) self.assertEqual(again.count("!/www/img/pictures/001.rpgmvp"), 1)
self.assertEqual(backup.read_bytes(), original) self.assertEqual(backup.read_bytes(), original)
def test_prepare_skips_unchanged_decrypted_image(self):
encrypted = self._encrypted_asset()
original = encrypted.read_bytes()
asset = scan_image_assets(self.root)[0]
decrypt_assets([asset], KEY, game_root=self.root)
result = prepare_assets_for_patch(self.root, [asset], KEY)
self.assertEqual(result.completed, 0)
self.assertEqual(result.skipped, 1)
self.assertEqual(result.patch_files, [])
self.assertEqual(encrypted.read_bytes(), original)
self.assertFalse(
(self.root / ".dazedtl" / "image_backups" / "www/img/pictures/001.rpgmvp").exists()
)
ignore = self.root.joinpath(".gitignore").read_text(encoding="utf-8")
self.assertNotIn("!/www/img/pictures/001.rpgmvp", ignore)
def test_prepare_skips_unchanged_unencrypted_workspace_copy(self):
runtime = self.content / "img" / "pictures" / "menu.png"
original = png_bytes("yellow")
runtime.write_bytes(original)
asset = scan_image_assets(self.root)[0]
asset.plain_path.parent.mkdir(parents=True, exist_ok=True)
asset.plain_path.write_bytes(original)
result = prepare_assets_for_patch(self.root, [asset], None)
self.assertEqual(result.completed, 0)
self.assertEqual(result.skipped, 1)
self.assertEqual(result.patch_files, [])
self.assertEqual(runtime.read_bytes(), original)
self.assertFalse((self.root / ".dazedtl" / "image_backups").exists())
def test_mz_png_uses_same_crypto_and_logical_png_name(self): def test_mz_png_uses_same_crypto_and_logical_png_name(self):
encrypted = self._encrypted_asset("title.png_", "green") encrypted = self._encrypted_asset("title.png_", "green")
asset = scan_image_assets(self.root)[0] asset = scan_image_assets(self.root)[0]

View file

@ -518,6 +518,13 @@ def prepare_assets_for_patch(
raise FileNotFoundError("decrypt this image before preparing it") raise FileNotFoundError("decrypt this image before preparing it")
if key is None: if key is None:
raise ValueError("the encryption key is required") raise ValueError("the encryption key is required")
if progress:
progress(f"Checking {asset.asset_id}")
plain_bytes = asset.plain_path.read_bytes()
runtime_bytes = asset.encrypted_path.read_bytes()
if decrypt_image_bytes(runtime_bytes, key) == plain_bytes:
result.skipped += 1
continue
relative_encrypted = _path_inside(root, asset.encrypted_path) relative_encrypted = _path_inside(root, asset.encrypted_path)
backup = root / ".dazedtl" / "image_backups" / relative_encrypted backup = root / ".dazedtl" / "image_backups" / relative_encrypted
if not backup.exists(): if not backup.exists():
@ -525,7 +532,7 @@ def prepare_assets_for_patch(
shutil.copy2(asset.encrypted_path, backup) shutil.copy2(asset.encrypted_path, backup)
if progress: if progress:
progress(f"Encrypting {asset.asset_id}") progress(f"Encrypting {asset.asset_id}")
encrypted = encrypt_image_bytes(asset.plain_path.read_bytes(), key) encrypted = encrypt_image_bytes(plain_bytes, key)
_atomic_write(asset.encrypted_path, encrypted) _atomic_write(asset.encrypted_path, encrypted)
targets.append(asset.encrypted_path) targets.append(asset.encrypted_path)
elif asset.has_runtime_plain: elif asset.has_runtime_plain:
@ -533,6 +540,12 @@ def prepare_assets_for_patch(
# If the user made a workspace copy, publish it back there and # If the user made a workspace copy, publish it back there and
# preserve the original once, just like encrypted assets. # preserve the original once, just like encrypted assets.
if asset.has_plain: if asset.has_plain:
if progress:
progress(f"Checking {asset.asset_id}")
plain_bytes = asset.plain_path.read_bytes()
if asset.runtime_plain_path.read_bytes() == plain_bytes:
result.skipped += 1
continue
relative_plain = _path_inside(root, asset.runtime_plain_path) relative_plain = _path_inside(root, asset.runtime_plain_path)
backup = root / ".dazedtl" / "image_backups" / relative_plain backup = root / ".dazedtl" / "image_backups" / relative_plain
if not backup.exists(): if not backup.exists():
@ -540,9 +553,7 @@ def prepare_assets_for_patch(
shutil.copy2(asset.runtime_plain_path, backup) shutil.copy2(asset.runtime_plain_path, backup)
if progress: if progress:
progress(f"Publishing {asset.asset_id}") progress(f"Publishing {asset.asset_id}")
_atomic_write( _atomic_write(asset.runtime_plain_path, plain_bytes)
asset.runtime_plain_path, asset.plain_path.read_bytes()
)
targets.append(asset.runtime_plain_path) targets.append(asset.runtime_plain_path)
else: else:
raise FileNotFoundError("image file no longer exists") raise FileNotFoundError("image file no longer exists")