From e47f73ba56fcc54a2d7b1c99c5bb8b4bda5509b9 Mon Sep 17 00:00:00 2001 From: Pierre Banwarth Date: Fri, 3 Jul 2026 01:15:34 +0200 Subject: [PATCH 1/2] Add incremental GB Camera export handling in GUI and CLI, and document the feature in README --- FlashGBX/FlashGBX_CLI.py | 77 ++++++++++++++++--- FlashGBX/PocketCameraWindow.py | 132 +++++++++++++++++++++++++++------ README.md | 2 + 3 files changed, 178 insertions(+), 33 deletions(-) diff --git a/FlashGBX/FlashGBX_CLI.py b/FlashGBX/FlashGBX_CLI.py index 84272e9..60bc9db 100644 --- a/FlashGBX/FlashGBX_CLI.py +++ b/FlashGBX/FlashGBX_CLI.py @@ -74,6 +74,28 @@ def _GetAutoPlatformMode(self, conn, supported_modes=None): return mode return None + def _GetNextExportIndex(self, directory, prefix, ext, digits=2, minimum=1): + existing_index = minimum - 1 + try: + for fn in os.listdir(directory): + if not fn.lower().endswith(ext.lower()): + continue + name = fn[:-len(ext)] + if not name.startswith(os.path.basename(prefix)): + continue + suffix = name[len(os.path.basename(prefix)):] + if len(suffix) != digits or not suffix.isdigit(): + continue + idx = int(suffix) + if idx > existing_index: + existing_index = idx + return existing_index + 1 + except OSError: + return minimum + + def _IsPhotoCustomRom(self, path): + return os.path.isfile(path) and os.path.getsize(path) == 0x100000 + def run(self): sys.stdout = Logger() config_ret = self.ARGS["config_ret"] @@ -174,19 +196,53 @@ def run(self): print(__("Canceled.")) return 0 + if not os.path.isfile(args.path): + print("\n" + ANSI.RED + __("Couldn’t open the save data file at “{path}”.", path=os.path.abspath(args.path)) + ANSI.RESET) + return 1 + + folder = os.path.splitext(args.path)[0] + if os.path.isfile(folder): + print("\n" + ANSI.RED + __("Can’t save pictures at location “{path}”.", path=os.path.abspath(folder)) + ANSI.RESET) + return 1 + if not os.path.isdir(folder): + os.makedirs(folder) + + if self._IsPhotoCustomRom(args.path): + full_file = None + try: + with open(args.path, "rb") as f: + full_file = bytearray(f.read()) + except OSError: + print("\n" + ANSI.RED + __("Couldn’t read the save data file at “{path}”.", path=os.path.abspath(args.path)) + ANSI.RESET) + return 1 + + for roll in range(1, 9): + pc = PocketCamera() + with open(args.path, "rb") as f: + f.seek(0x20000 * (roll - 1)) + roll_data = bytearray(f.read(0x20000)) + if pc.LoadFile(roll_data) == False: + continue + pc.SetPalette(PocketCamera.PALETTE_NAMES.index(args.gbcamera_palette)) + prefix = os.path.join(folder, "IMG_P{:d}".format(roll)) + ext = "." + args.gbcamera_outfile_format + start = 1 if args.overwrite else self._GetNextExportIndex(folder, prefix, ext) + for i in range(0, 32): + file = prefix + "{:02d}".format(start + i) + ext + pc.ExportPicture(i, file, scale=1) + print(__("The pictures from “{save_file}” were extracted to “{destination}”.", save_file=os.path.abspath(args.path), destination=os.path.abspath(folder) + os.sep + "IMG_P**.{:s}".format(args.gbcamera_outfile_format))) + return 0 + pc = PocketCamera() if pc.LoadFile(args.path) != False: pc.SetPalette(PocketCamera.PALETTE_NAMES.index(args.gbcamera_palette)) - file = os.path.splitext(args.path)[0] + os.sep + "IMG_PC00.png" - if os.path.isfile(os.path.dirname(file)): - print("\n" + ANSI.RED + __("Can’t save pictures at location “{path}”.", path=os.path.abspath(os.path.dirname(file))) + ANSI.RESET) - return 1 - if not os.path.isdir(os.path.dirname(file)): - os.makedirs(os.path.dirname(file)) + prefix = os.path.join(folder, "IMG_PC") + ext = "." + args.gbcamera_outfile_format + start = 1 if args.overwrite else self._GetNextExportIndex(folder, prefix, ext) for i in range(0, 32): - file = os.path.splitext(args.path)[0] + os.sep + "IMG_PC{:02d}".format(i+1) + "." + args.gbcamera_outfile_format + file = prefix + "{:02d}".format(start + i) + ext pc.ExportPicture(i, file, scale=1) - print(__("The pictures from “{save_file}” were extracted to “{destination}”.", save_file=os.path.abspath(args.path), destination=os.path.abspath(os.path.dirname(file)) + os.sep + "IMG_PC**.{:s}".format(args.gbcamera_outfile_format))) + print(__("The pictures from “{save_file}” were extracted to “{destination}”.", save_file=os.path.abspath(args.path), destination=os.path.abspath(folder) + os.sep + "IMG_PC**.{:s}".format(args.gbcamera_outfile_format))) else: print("\n" + ANSI.RED + __("Couldn’t parse the save data file.") + ANSI.RESET) return 0 @@ -503,8 +559,11 @@ def FinishOperation(self): f.seek(0x20000 * (roll - 1)) roll_data = bytearray(f.read(0x20000)) if pc.LoadFile(roll_data) != False: + prefix = base + os.sep + "IMG_P{:d}".format(roll) + ext = "." + self.ARGS["argparsed"].gbcamera_outfile_format + start = 1 if self.ARGS["argparsed"].overwrite else self._GetNextExportIndex(base, prefix, ext) for i in range(0, 32): - file = base + os.sep + "IMG_P{:1d}{:02d}.{}".format(roll, i, self.ARGS["argparsed"].gbcamera_outfile_format) + file = prefix + "{:02d}".format(start + i) + ext pc.ExportPicture(i, file, scale=1) else: file = self.CONN.INFO["last_path"] diff --git a/FlashGBX/PocketCameraWindow.py b/FlashGBX/PocketCameraWindow.py index 5c3017f..d6e9e6a 100644 --- a/FlashGBX/PocketCameraWindow.py +++ b/FlashGBX/PocketCameraWindow.py @@ -17,6 +17,9 @@ class PocketCameraWindow(QtWidgets.QDialog): CUR_INDEX = 0 CUR_BICUBIC = False CUR_FILE = "" + CUR_FILE_PATH = None + CUR_FULL_FILE = None + CUR_PHOTO_CUSTOM_ROLL = None CUR_EXPORT_PATH = "." CUR_PC = None CUR_PALETTE = 3 @@ -236,6 +239,9 @@ def SetColors(self): def OpenFile(self, file): if isinstance(file, bytearray) and len(file) == 0x100000 or isinstance(file, str) and os.path.getsize(file) == 0x100000: + self.CUR_FILE_PATH = file if isinstance(file, str) else None + self.CUR_FULL_FILE = file if isinstance(file, bytearray) else None + self.CUR_PHOTO_CUSTOM_ROLL = None dlg_args = { "title":"Photo!", "intro":__("A “Photo!” save file was detected. Please select the roll of pictures that you would like to load."), @@ -249,12 +255,20 @@ def OpenFile(self, file): index = result["index"].currentIndex() if isinstance(file, str): with open(file, "rb") as f: - file = bytearray(f.read()) - file = file[0x20000 * index:][:0x20000] + full_file = bytearray(f.read()) + else: + full_file = file + self.CUR_FULL_FILE = full_file + self.CUR_PHOTO_CUSTOM_ROLL = index + file = full_file[0x20000 * index:][:0x20000] else: self.CUR_PC = None return False + self.CUR_FILE_PATH = file if isinstance(file, str) else self.CUR_FILE_PATH + if not isinstance(file, str): + self.CUR_FILE_PATH = self.CUR_FILE_PATH + self.CUR_PC = None try: self.CUR_PC = PocketCamera() if self.CUR_PC.LoadFile(file) == False: @@ -298,24 +312,105 @@ def btnShowLastSeen_Clicked(self, event): self.UpdateViewer(31) self.CUR_INDEX = 31 + def _IsPhotoCustomRom(self): + if self.CUR_FULL_FILE is not None and len(self.CUR_FULL_FILE) == 0x100000: + return True + if self.CUR_FILE_PATH is not None and os.path.isfile(self.CUR_FILE_PATH) and os.path.getsize(self.CUR_FILE_PATH) == 0x100000: + return True + return False + + def _GetExportPrefix(self, path, digits=2): + prefix = os.path.splitext(path)[0] + dirname = os.path.dirname(prefix) + basename = os.path.basename(prefix) + if len(basename) > digits and basename[-digits:].isdigit(): + basename = basename[:-digits] + return os.path.join(dirname, basename) + + def _GetNextExportIndex(self, directory, prefix, ext, digits=2): + prefix_base = os.path.basename(prefix) + existing_index = -1 + try: + for fn in os.listdir(directory): + if not fn.lower().endswith(ext.lower()): + continue + name = fn[:-len(ext)] + if not name.startswith(prefix_base): + continue + suffix = name[len(prefix_base):] + if len(suffix) != digits or not suffix.isdigit(): + continue + idx = int(suffix) + if idx > existing_index: + existing_index = idx + return existing_index + 1 + except OSError: + return 0 + + def _GetPhotoRomSource(self): + if self.CUR_FULL_FILE is not None and len(self.CUR_FULL_FILE) == 0x100000: + return self.CUR_FULL_FILE + if self.CUR_FILE_PATH is not None and os.path.isfile(self.CUR_FILE_PATH) and os.path.getsize(self.CUR_FILE_PATH) == 0x100000: + try: + with open(self.CUR_FILE_PATH, "rb") as f: + return bytearray(f.read()) + except OSError: + return None + return None + + def _ExportPicture(self, cam, index, path): + frame = False + if self.chkFrame.isChecked(): + frame = True + own_frame = self.CONFIG_PATH + os.sep + "pc_frame.png" + if not os.path.exists(own_frame): + shutil.copy(self.APP_PATH + os.sep + os.path.join("res", "pc_frame.png"), own_frame) + with open(own_frame, "rb") as f: + frame = f.read() + if index == 31: + frame = False + cam.ExportPicture(index=index, path=path, scale=self.spnZoom.value(), frame=frame) + + def _ExportAllPhotoRomRolls(self): + full_file = self._GetPhotoRomSource() + if full_file is None: + QtWidgets.QMessageBox.critical(self, AppInfo.NAME, __("Unable to retrieve the full Photo ROM source file."), QtWidgets.QMessageBox.Ok) + return + for roll in range(1, 9): + roll_data = full_file[0x20000 * (roll - 1):0x20000 * roll] + pc = PocketCamera() + if pc.LoadFile(roll_data) == False: + continue + prefix = os.path.join(self.CUR_EXPORT_PATH, "IMG_P{:d}".format(roll)) + ext = ".png" + start = self._GetNextExportIndex(self.CUR_EXPORT_PATH, prefix, ext) + for i in range(0, 32): + file = prefix + "{:02d}".format(start + i) + ext + self._ExportPicture(pc, i, file) + QtWidgets.QMessageBox.information(self, AppInfo.NAME, __("The pictures were extracted."), QtWidgets.QMessageBox.Ok) + def btnSaveAll_Clicked(self, event): if self.CUR_PC is None: return + if self._IsPhotoCustomRom(): + directory = QtWidgets.QFileDialog.getExistingDirectory(self, __("Export all pictures"), self.CUR_EXPORT_PATH) + if directory == "": + return + self.CUR_EXPORT_PATH = directory + self._ExportAllPhotoRomRolls() + return + path = self.CUR_EXPORT_PATH + os.sep + "IMG_PC.png" path = QtWidgets.QFileDialog.getSaveFileName(self, __("Export all pictures"), path, __("PNG files") + " (*.png);;" + __("BMP files") + " (*.bmp);;" + __("GIF files") + " (*.gif);;" + __("JPEG files") + " (*.jpg);;" + __("All files") + " (*.*)")[0] if path == "": return self.CUR_EXPORT_PATH = os.path.dirname(path) + prefix = self._GetExportPrefix(path) + ext = os.path.splitext(path)[1] + start = self._GetNextExportIndex(self.CUR_EXPORT_PATH, prefix, ext) + if start < 1: + start = 1 for i in range(0, 32): - file = os.path.splitext(path)[0] + "{:02d}".format(i) + os.path.splitext(path)[1] - if os.path.exists(file): - answer = QtWidgets.QMessageBox.warning(self, AppInfo.NAME, __("There are already pictures that use the same file names. If you continue, these files will be overwritten."), QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel) - if answer == QtWidgets.QMessageBox.Ok: - break - elif answer == QtWidgets.QMessageBox.Cancel: - return - - for i in range(0, 32): - file = os.path.splitext(path)[0] + "{:02d}".format(i+1) + os.path.splitext(path)[1] + file = prefix + "{:02d}".format(start + i) + ext self.SavePicture(i, path=file) def btnSavePhoto_Clicked(self, event): @@ -377,18 +472,7 @@ def SavePicture(self, index, path=""): if path != "": self.CUR_EXPORT_PATH = os.path.dirname(path) if path == "": return - cam = self.CUR_PC - - frame = False - if self.chkFrame.isChecked(): - frame = True - own_frame = self.CONFIG_PATH + os.sep + "pc_frame.png" - if not os.path.exists(own_frame): - shutil.copy(self.APP_PATH + os.sep + os.path.join("res", "pc_frame.png"), own_frame) - with open(own_frame, "rb") as f: frame = f.read() - - if index == 31: frame = False # last seen image - cam.ExportPicture(index=index, path=path, scale=self.spnZoom.value(), frame=frame) + self._ExportPicture(self.CUR_PC, index, path) def dragEnterEvent(self, e): if self._dragEventHover(e): diff --git a/README.md b/README.md index ab69ef9..7958e49 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ for Windows, Linux, macOS (→ [Download](#downloads)) - Many reproduction cartridges and flash cartridges can be auto-detected - A flash chip query (including Common Flash Interface information) can be performed for flash cartridges - Decode and extract Game Boy Camera photos from save data +- Extract all Game Boy Camera photos from a single custom Photo ROM save file in one pass +- Automatically increment exported file suffixes when prior pictures already exist to avoid overwriting - Generate ROM dump reports for game preservation purposes - Supported interface languages: English, German From 3455a23d2cc87211f8b11d38469c26a9e9d0885c Mon Sep 17 00:00:00 2001 From: Pierre Banwarth Date: Fri, 3 Jul 2026 02:25:37 +0200 Subject: [PATCH 2/2] some fixes --- FlashGBX/PocketCameraWindow.py | 196 +++++++++++++++++++++++++++------ README.md | 3 + 2 files changed, 165 insertions(+), 34 deletions(-) diff --git a/FlashGBX/PocketCameraWindow.py b/FlashGBX/PocketCameraWindow.py index d6e9e6a..76e3912 100644 --- a/FlashGBX/PocketCameraWindow.py +++ b/FlashGBX/PocketCameraWindow.py @@ -92,6 +92,16 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): self.rowOptions1.addWidget(self.chkFrame) grpOptionsLayout.addLayout(self.rowOptions1) + # Export prefix option (default filled from settings later) + self.rowOptions2 = QtWidgets.QHBoxLayout() + self.rowOptions2.setSpacing(6) + self.lblExportPrefix = QtWidgets.QLabel(__("Export Filename Prefix:")) + self.txtExportPrefix = QtWidgets.QLineEdit() + self.txtExportPrefix.setMaximumWidth(160) + self.rowOptions2.addWidget(self.lblExportPrefix) + self.rowOptions2.addWidget(self.txtExportPrefix) + self.rowOptions2.addStretch(1) + grpOptionsLayout.addLayout(self.rowOptions2) self.grpOptions.setLayout(grpOptionsLayout) self.layout_options1.addWidget(self.grpOptions) @@ -118,6 +128,10 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): self.lblPhotoViewer.setStyleSheet("border-top: 1px solid #adadad; border-left: 1px solid #adadad; border-bottom: 1px solid #ffffff; border-right: 1px solid #ffffff;") self.lblPhotoViewer.mousePressEvent = self.lblPhotoViewer_Clicked self.grpPhotoViewLayout.addWidget(self.lblPhotoViewer) + self.lblPhotoInfo = QtWidgets.QLabel("") + self.lblPhotoInfo.setWordWrap(True) + self.lblPhotoInfo.setStyleSheet("color: #606060; font-size: 11px;") + self.grpPhotoViewLayout.addWidget(self.lblPhotoInfo) # Actions below Viewer rowActionsGeneral2 = QtWidgets.QHBoxLayout() @@ -125,9 +139,10 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): self.btnSavePhoto.setStyleSheet("padding: 5px 10px;") self.btnSavePhoto.clicked.connect(self.btnSavePhoto_Clicked) rowActionsGeneral2.addWidget(self.btnSavePhoto) - self.btnSaveAll = QtWidgets.QPushButton(c__("Button (& = Keyboard Shortcut)", "Save &All Pictures")) + self.btnSaveAll = QtWidgets.QPushButton(c__("Button (& = Keyboard Shortcut)", "Save / Extract &All Pictures")) self.btnSaveAll.setStyleSheet("padding: 5px 10px;") self.btnSaveAll.clicked.connect(self.btnSaveAll_Clicked) + self.btnSaveAll.setToolTip(__("Save the current slot or, for a full 1 MiB Photo ROM, extract all rolls into one folder.")) rowActionsGeneral2.addWidget(self.btnSaveAll) self.grpPhotoViewLayout.addLayout(rowActionsGeneral2) @@ -138,6 +153,7 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): self.grpPhotoThumbsLayout = QtWidgets.QVBoxLayout() self.grpPhotoThumbsLayout.setSpacing(2) self.grpPhotoThumbsLayout.setContentsMargins(-1, 3, -1, -1) + # Static 30 thumbnails (kept for single-roll mode) self.lblPhoto = [] rowsPhotos = [] for row in range(0, 5): @@ -154,6 +170,16 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): rowsPhotos[row].addWidget(self.lblPhoto[len(self.lblPhoto)-1]) self.grpPhotoThumbsLayout.addLayout(rowsPhotos[row]) + # Scrollable area for multi-roll (240) thumbnails + self.scrollThumbs = QtWidgets.QScrollArea() + self.scrollThumbs.setWidgetResizable(True) + self.scrollThumbsInner = QtWidgets.QWidget() + self.scrollThumbsLayout = QtWidgets.QGridLayout(self.scrollThumbsInner) + self.scrollThumbsLayout.setSpacing(2) + self.scrollThumbsInner.setLayout(self.scrollThumbsLayout) + self.scrollThumbs.setWidget(self.scrollThumbsInner) + self.scrollThumbs.hide() + rowActionsGeneral3 = QtWidgets.QHBoxLayout() self.btnShowGameFace = QtWidgets.QPushButton(c__("Button (& = Keyboard Shortcut)", "&Game Face")) self.btnShowGameFace.setStyleSheet("padding: 5px 10px;") @@ -170,12 +196,12 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): self.grpPhotoThumbs.setLayout(self.grpPhotoThumbsLayout) self.layout_photos.addWidget(self.grpPhotoThumbs) + self.layout_photos.addWidget(self.scrollThumbs) self.layout_photos.addWidget(self.grpPhotoView) self.layout.addLayout(self.layout_options1, 0, 0) - self.layout.addLayout(self.layout_options2, 1, 0) - self.layout.addLayout(self.layout_photos, 2, 0) - self.layout.addLayout(self.layout_options3, 3, 0) + self.layout.addLayout(self.layout_photos, 1, 0) + self.layout.addLayout(self.layout_options3, 2, 0) self.setLayout(self.layout) self.APP = app @@ -203,6 +229,9 @@ def __init__(self, app, file=None, icon=None, config_path=".", app_path="."): self.PALETTES.append(palette) self.CUR_PALETTE = len(self.PALETTES) - 1 + # Always default the export prefix to IMG_PC on startup + self.txtExportPrefix.setText("IMG_PC") + if self.CUR_FILE is not None: if self.OpenFile(self.CUR_FILE) is False: self.FORCE_EXIT = True @@ -238,15 +267,16 @@ def SetColors(self): self.UpdateViewer(self.CUR_INDEX) def OpenFile(self, file): + original_file_path = file if isinstance(file, str) else self.CUR_FILE_PATH if isinstance(file, bytearray) and len(file) == 0x100000 or isinstance(file, str) and os.path.getsize(file) == 0x100000: - self.CUR_FILE_PATH = file if isinstance(file, str) else None + self.CUR_FILE_PATH = file if isinstance(file, str) else self.CUR_FILE_PATH self.CUR_FULL_FILE = file if isinstance(file, bytearray) else None self.CUR_PHOTO_CUSTOM_ROLL = None dlg_args = { "title":"Photo!", "intro":__("A “Photo!” save file was detected. Please select the roll of pictures that you would like to load."), "params": [ - [ "index", "cmb", __("Film roll:"), [ __("Current Save Data") ] + [ __("“{flash_directory}” Slot {number}", flash_directory="Flash Directory", number="{:d}".format(l)) for l in range(1, 8) ], 0 ], + [ "index", "cmb", __("Film roll:"), [ __("Current Save Data") , __("Current Save Data and All Slots") ] + [ __("“{flash_directory}” Slot {number}", flash_directory="Flash Directory", number="{:d}".format(l)) for l in range(1, 8) ], 1 ], ] } dlg = UserInputDialog(self, icon=self.windowIcon(), args=dlg_args) @@ -259,15 +289,17 @@ def OpenFile(self, file): else: full_file = file self.CUR_FULL_FILE = full_file - self.CUR_PHOTO_CUSTOM_ROLL = index - file = full_file[0x20000 * index:][:0x20000] + if index == 1: + self.CUR_PHOTO_CUSTOM_ROLL = -1 + file = full_file[0x20000 * 0:0x20000 * 1] + else: + self.CUR_PHOTO_CUSTOM_ROLL = index - 1 if index > 1 else index + file = full_file[0x20000 * self.CUR_PHOTO_CUSTOM_ROLL:0x20000 * (self.CUR_PHOTO_CUSTOM_ROLL + 1)] else: self.CUR_PC = None return False - self.CUR_FILE_PATH = file if isinstance(file, str) else self.CUR_FILE_PATH - if not isinstance(file, str): - self.CUR_FILE_PATH = self.CUR_FILE_PATH + self.CUR_FILE_PATH = original_file_path if isinstance(original_file_path, str) else self.CUR_FILE_PATH self.CUR_PC = None try: self.CUR_PC = PocketCamera() @@ -275,11 +307,18 @@ def OpenFile(self, file): self.CUR_PC = None QtWidgets.QMessageBox.critical(self, AppInfo.NAME, __("The save data file couldn’t be loaded."), QtWidgets.QMessageBox.Ok) return False - self.CUR_FILE = file - if self.CUR_EXPORT_PATH == "": + self.CUR_FILE = original_file_path if isinstance(original_file_path, str) else "" + if self.CUR_EXPORT_PATH == "" and self.CUR_FILE != "": self.CUR_EXPORT_PATH = os.path.dirname(self.CUR_FILE) self.UpdateViewer(0) self.SetColors() + if self._IsPhotoCustomRom(): + if self.CUR_PHOTO_CUSTOM_ROLL == -1: + self.lblPhotoInfo.setText(__("1 MiB Photo save file detected. The current roll is shown while all slots are available for export.")) + else: + self.lblPhotoInfo.setText(__("1 MiB Photo save file detected. Use Save / Extract All Pictures to export all rolls.")) + else: + self.lblPhotoInfo.setText("") return True except: self.CUR_PC = None @@ -358,6 +397,22 @@ def _GetPhotoRomSource(self): return None return None + def _BuildAllRollImages(self): + # Build a flat list of (PIL Image RGBA, deleted_flag) for all 8 rolls (240 images) + full = self._GetPhotoRomSource() + if full is None: return None + all_images = [] + for roll in range(0, 8): + roll_data = full[0x20000 * roll:0x20000 * (roll + 1)] + pc = PocketCamera() + if pc.LoadFile(roll_data) == False: + continue + for i in range(0, 30): + img = pc.GetPicture(i).convert("RGBA") + is_deleted = pc.IsDeleted(i) + all_images.append((img, is_deleted)) + return all_images + def _ExportPicture(self, cam, index, path): frame = False if self.chkFrame.isChecked(): @@ -381,7 +436,16 @@ def _ExportAllPhotoRomRolls(self): pc = PocketCamera() if pc.LoadFile(roll_data) == False: continue - prefix = os.path.join(self.CUR_EXPORT_PATH, "IMG_P{:d}".format(roll)) + # Use user-defined export prefix if available + base_prefix = "IMG_PC" + try: + if hasattr(self, 'txtExportPrefix'): + val = str(self.txtExportPrefix.text()).strip() + if val != "": + base_prefix = val + except: + base_prefix = "IMG_PC" + prefix = os.path.join(self.CUR_EXPORT_PATH, "{}_{:d}".format(base_prefix, roll)) ext = ".png" start = self._GetNextExportIndex(self.CUR_EXPORT_PATH, prefix, ext) for i in range(0, 32): @@ -399,7 +463,13 @@ def btnSaveAll_Clicked(self, event): self._ExportAllPhotoRomRolls() return - path = self.CUR_EXPORT_PATH + os.sep + "IMG_PC.png" + # default filename uses export prefix field when present + default_name = "IMG_PC" + if hasattr(self, 'txtExportPrefix'): + val = str(self.txtExportPrefix.text()).strip() + if val != "": + default_name = val + path = self.CUR_EXPORT_PATH + os.sep + default_name + ".png" path = QtWidgets.QFileDialog.getSaveFileName(self, __("Export all pictures"), path, __("PNG files") + " (*.png);;" + __("BMP files") + " (*.bmp);;" + __("GIF files") + " (*.gif);;" + __("JPEG files") + " (*.jpg);;" + __("All files") + " (*.*)")[0] if path == "": return self.CUR_EXPORT_PATH = os.path.dirname(path) @@ -429,31 +499,83 @@ def hideEvent(self, event): self.APP.activateWindow() def BuildPhotoList(self): - cam = self.CUR_PC - self.CUR_THUMBS = [None] * 30 - for i in range(0, 30): - pic = cam.GetPicture(i).convert("RGBA") - self.lblPhoto[i].setToolTip("") - if cam.IsEmpty(i): - pass - elif cam.IsDeleted(i): - draw_bg = Image.new("RGBA", pic.size) - draw = ImageDraw.Draw(draw_bg) - draw.line([0, 0, 128, 112], fill=(255, 0, 0, 192), width=8) - draw.line([0, 112, 128, 0], fill=(255, 0, 0, 192), width=8) - pic.paste(draw_bg, mask=draw_bg) - self.lblPhoto[i].setToolTip(__("This picture was marked as “deleted” and may be overwritten when you take new pictures.")) - self.CUR_THUMBS[i] = ImageQt(pic.resize((47, 41), Image.Resampling.HAMMING)) - qpixmap = QtGui.QPixmap.fromImage(self.CUR_THUMBS[i]) - self.lblPhoto[i].setPixmap(qpixmap) + # Single-roll mode + if not (self.CUR_PHOTO_CUSTOM_ROLL == -1): + cam = self.CUR_PC + self.CUR_THUMBS = [None] * 30 + for i in range(0, 30): + pic = cam.GetPicture(i).convert("RGBA") + self.lblPhoto[i].setToolTip("") + if cam.IsEmpty(i): + pass + elif cam.IsDeleted(i): + draw_bg = Image.new("RGBA", pic.size) + draw = ImageDraw.Draw(draw_bg) + draw.line([0, 0, 128, 112], fill=(255, 0, 0, 192), width=8) + draw.line([0, 112, 128, 0], fill=(255, 0, 0, 192), width=8) + pic.paste(draw_bg, mask=draw_bg) + self.lblPhoto[i].setToolTip(__("This picture was marked as “deleted” and may be overwritten when you take new pictures.")) + self.CUR_THUMBS[i] = ImageQt(pic.resize((47, 41), Image.Resampling.HAMMING)) + qpixmap = QtGui.QPixmap.fromImage(self.CUR_THUMBS[i]) + self.lblPhoto[i].setPixmap(qpixmap) + # ensure static thumbs visible, hide scroll area + self.grpPhotoThumbs.show() + self.scrollThumbs.hide() + return + + # Multi-roll mode: build and show 240 thumbnails in scroll area + all_images = self._BuildAllRollImages() + if all_images is None: + self.grpPhotoThumbs.show() + self.scrollThumbs.hide() + return + self.CUR_ALL_IMAGES = all_images + # clear existing widgets in scrollThumbsLayout + for i in reversed(range(self.scrollThumbsLayout.count())): + w = self.scrollThumbsLayout.itemAt(i).widget() + if w is not None: + w.setParent(None) + + cols = 10 + for idx, (img, deleted) in enumerate(self.CUR_ALL_IMAGES): + thumb = img.resize((47, 41), Image.Resampling.HAMMING) + qthumb = QtGui.QPixmap.fromImage(ImageQt(thumb)) + lbl = QtWidgets.QLabel(self.scrollThumbsInner) + lbl.setPixmap(qthumb) + lbl.setMinimumSize(49, 43) + lbl.setMaximumSize(49, 43) + lbl.setAlignment(QtCore.Qt.AlignCenter) + lbl.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + lbl.mousePressEvent = functools.partial(self.lblPhoto_Clicked, index=idx) + if deleted: + lbl.setToolTip(__("This picture was marked as “deleted” and may be overwritten when you take new pictures.")) + self.scrollThumbsLayout.addWidget(lbl, idx // cols, idx % cols) + + self.grpPhotoThumbs.hide() + self.scrollThumbs.show() def UpdateViewer(self, index, scale_factor=4): resampler = Image.Resampling.NEAREST if self.CUR_BICUBIC or index == 31: resampler = Image.Resampling.BICUBIC if resampler == Image.Resampling.BICUBIC: scale_factor = 0.5 + # If multi-roll mode, use prebuilt images + if self.CUR_PHOTO_CUSTOM_ROLL == -1 and hasattr(self, 'CUR_ALL_IMAGES'): + if index < 0 or index >= len(self.CUR_ALL_IMAGES): + return + img = self.CUR_ALL_IMAGES[index][0] + resized = img.resize((int(256 * scale_factor), int(224 * scale_factor)), resampler) + self.CUR_PIC = ImageQt(resized) + qpixmap = QtGui.QPixmap.fromImage(self.CUR_PIC) + qpixmap.setDevicePixelRatio(scale_factor) + self.lblPhotoViewer.setPixmap(qpixmap) + # update selection highlight for static thumbs if visible + for i in range(0, len(self.lblPhoto)): + self.lblPhoto[i].setStyleSheet("border-top: 1px solid #adadad; border-left: 1px solid #adadad; border-bottom: 1px solid #ffffff; border-right: 1px solid #ffffff;") + return + + # single-roll mode cam = self.CUR_PC if cam is None: return - for i in range(0, 30): self.lblPhoto[i].setStyleSheet("border-top: 1px solid #adadad; border-left: 1px solid #adadad; border-bottom: 1px solid #ffffff; border-right: 1px solid #ffffff;") @@ -467,7 +589,13 @@ def UpdateViewer(self, index, scale_factor=4): def SavePicture(self, index, path=""): if path == "": - path = self.CUR_EXPORT_PATH + os.sep + "IMG_PC{:02d}.png".format(index+1) + # Use export prefix from UI when present + default_name = "IMG_PC" + if hasattr(self, 'txtExportPrefix'): + val = str(self.txtExportPrefix.text()).strip() + if val != "": + default_name = val + path = self.CUR_EXPORT_PATH + os.sep + default_name + "{:02d}.png".format(index+1) path = QtWidgets.QFileDialog.getSaveFileName(self, __("Save Photo"), path, __("PNG files") + " (*.png);;" + __("BMP files") + " (*.bmp);;" + __("GIF files") + " (*.gif);;" + __("JPEG files") + " (*.jpg);;" + __("All files") + " (*.*)")[0] if path != "": self.CUR_EXPORT_PATH = os.path.dirname(path) if path == "": return diff --git a/README.md b/README.md index 7958e49..4424df1 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ for Windows, Linux, macOS (→ [Download](#downloads)) - A flash chip query (including Common Flash Interface information) can be performed for flash cartridges - Decode and extract Game Boy Camera photos from save data - Extract all Game Boy Camera photos from a single custom Photo ROM save file in one pass +- In the GUI, use "Save / Extract All Pictures" to export all rolls from a 1 MiB Photo ROM without overwriting existing files +- Add an "Export Filename Prefix" option in the GB Camera Album Viewer Options panel. This prefix is applied to single-picture saves, group exports, and full roll extraction within the current session. +- The export prefix defaults to `IMG_PC` on each application launch and is used to build consistent export file names. - Automatically increment exported file suffixes when prior pictures already exist to avoid overwriting - Generate ROM dump reports for game preservation purposes - Supported interface languages: English, German