From c63b08694efbb1c82ab4f63df38bf156a22f53e2 Mon Sep 17 00:00:00 2001 From: dvdkon Date: Sat, 15 Jul 2023 16:16:56 +0200 Subject: [PATCH] Add .AAE adjustment export (fix #97) (#1113) Implements #97, export of adjustments data --- osxphotos/cli/export.py | 17 ++++++++++++ osxphotos/photoexporter.py | 54 ++++++++++++++++++++++++++++++++++++++ osxphotos/photoinfo.py | 48 +++++++++++++++++++-------------- tests/test_cli.py | 54 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+), 19 deletions(-) diff --git a/osxphotos/cli/export.py b/osxphotos/cli/export.py index 9e40a599..796806c2 100644 --- a/osxphotos/cli/export.py +++ b/osxphotos/cli/export.py @@ -297,6 +297,14 @@ from .verbose import get_verbose_console, verbose_print "Note: --download-missing does not currently export all burst images; " "only the primary photo will be exported--associated burst images will be skipped.", ) +@click.option( + "--export-aae", + is_flag=True, + help="Also export an adjustments file detailing edits made to the original. " + "The resulting file is named photoname.AAE. " + "Note that to import these files back to Photos succesfully, you also need to " + "export the edited photo and match the filename format Photos.app expects: " + "--filename 'IMG_{edited_version?E,}{id:04d}' --edited-suffix ''") @click.option( "--sidecar", default=None, @@ -852,6 +860,7 @@ def export( screenshot, selfie, shared, + export_aae, sidecar, sidecar_drop_ext, skip_bursts, @@ -1076,6 +1085,7 @@ def export( selected = cfg.selected selfie = cfg.selfie shared = cfg.shared + export_aae = cfg.export_aae sidecar = cfg.sidecar sidecar_drop_ext = cfg.sidecar_drop_ext skip_bursts = cfg.skip_bursts @@ -1645,6 +1655,7 @@ def export( + results.exif_updated + results.touched + results.converted_to_jpeg + + results.aae_written + results.sidecar_json_written + results.sidecar_json_skipped + results.sidecar_exiftool_written @@ -1702,6 +1713,7 @@ def export_photo( dest=None, verbose=None, export_by_date=None, + export_aae=None, sidecar=None, sidecar_drop_ext=False, update=None, @@ -1790,6 +1802,7 @@ def export_photo( preview_suffix: str, template to use as suffix for preview images replace_keywords: if True, --keyword-template replaces keywords instead of adding keywords retry: retry up to retry # of times if there's an error + export_aae: bool; if True, will also save adjustments sidecar_drop_ext: bool; if True, drops photo extension from sidecar name sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export skip_original_if_edited: bool; if True does not export original if photo has been edited @@ -1956,6 +1969,7 @@ def export_photo( preview_suffix=rendered_preview_suffix, replace_keywords=replace_keywords, retry=retry, + export_aae=export_aae, sidecar_drop_ext=sidecar_drop_ext, sidecar_flags=sidecar_flags, touch_file=touch_file, @@ -2072,6 +2086,7 @@ def export_photo( preview_suffix=rendered_preview_suffix, replace_keywords=replace_keywords, retry=retry, + export_aae=export_aae, sidecar_drop_ext=sidecar_drop_ext, sidecar_flags=sidecar_flags if not export_original else 0, touch_file=touch_file, @@ -2159,6 +2174,7 @@ def export_photo_to_directory( preview_suffix, replace_keywords, retry, + export_aae, sidecar_drop_ext, sidecar_flags, touch_file, @@ -2223,6 +2239,7 @@ def export_photo_to_directory( render_options=render_options, replace_keywords=replace_keywords, rich=True, + export_aae=export_aae, sidecar=sidecar_flags, sidecar_drop_ext=sidecar_drop_ext, tmpdir=tmpdir, diff --git a/osxphotos/photoexporter.py b/osxphotos/photoexporter.py index bfe7d643..907e59e5 100644 --- a/osxphotos/photoexporter.py +++ b/osxphotos/photoexporter.py @@ -135,6 +135,7 @@ class ExportOptions: render_options (RenderOptions): t.Optional osxphotos.phototemplate.RenderOptions instance to specify options for rendering templates replace_keywords (bool): if True, keyword_template replaces any keywords, otherwise it's additive rich (bool): if True, will use rich markup with verbose output + export_aae (bool): if True, also exports adjustments as .AAE file sidecar_drop_ext (bool, default=False): if True, drops the photo's extension from sidecar filename (e.g. 'IMG_1234.json' instead of 'IMG_1234.JPG.json') sidecar: bit field (int): set to one or more of `SIDECAR_XMP`, `SIDECAR_JSON`, `SIDECAR_EXIFTOOL` - SIDECAR_JSON: if set will write a json sidecar with data in format readable by exiftool sidecar filename will be dest/filename.json; @@ -187,6 +188,7 @@ class ExportOptions: render_options: t.Optional[RenderOptions] = None replace_keywords: bool = False rich: bool = False + export_aae: bool = False sidecar_drop_ext: bool = False sidecar: int = 0 strip: bool = False @@ -281,6 +283,7 @@ class ExportResults: "missing", "missing_album", "new", + "aae_written", "sidecar_exiftool_skipped", "sidecar_exiftool_written", "sidecar_json_skipped", @@ -311,6 +314,7 @@ class ExportResults: missing=None, missing_album=None, new=None, + aae_written=None, sidecar_exiftool_skipped=None, sidecar_exiftool_written=None, sidecar_json_skipped=None, @@ -350,6 +354,7 @@ class ExportResults: + self.exif_updated + self.touched + self.converted_to_jpeg + + self.aae_written + self.sidecar_json_written + self.sidecar_json_skipped + self.sidecar_exiftool_written @@ -605,6 +610,8 @@ class PhotoExporter: f"Skipping missing preview photo for {self._filename(self.photo.original_filename)} ({self._uuid(self.photo.uuid)})" ) + if options.export_aae: + all_results += self._write_aae_file(dest=dest, options=options) all_results += self._write_sidecar_files(dest=dest, options=options) return all_results @@ -1404,6 +1411,53 @@ class PhotoExporter: exported_paths.append(str(dest_new)) return exported_paths + def _write_aae_file( + self, + dest: pathlib.Path, + options: ExportOptions, + ) -> ExportResults: + """Write AAE file for the photo.""" + + # AAE files describe adjustments to originals, so they don't make sense + # for edited files + if options.edited: + return ExportResults() + + verbose = options.verbose or self._verbose + + aae_src = self.photo.adjustments_path + if aae_src is None: + return ExportResults() + aae_dest = dest.with_suffix(".AAE") + + if options.export_as_hardlink: + try: + if aae_dest.exists() and any( + [options.overwrite, options.update, options.force_update]): + try: + options.fileutil.unlink(aae_dest) + except Exception as e: + raise ExportError( + f"Error removing file {aae_dest}: {e} (({lineno(__file__)})" + ) from e + options.fileutil.hardlink(aae_src, aae_dest) + except Exception as e: + raise ExportError( + f"Error hardlinking {aae_src} to {aae_dest}: {e} ({lineno(__file__)})" + ) from e + else: + try: + options.fileutil.copy(aae_src, aae_dest) + verbose( + f"Exported adjustments of {self._filename(self.photo.original_filename)} to {self._filepath(normalize_fs_path(aae_dest))}" + ) + except Exception as e: + raise ExportError( + f"Error copying file {aae_src} to {aae_dest}: {e} ({lineno(__file__)})" + ) from e + + return ExportResults(aae_written=[aae_dest]) + def _write_sidecar_files( self, dest: pathlib.Path, diff --git a/osxphotos/photoinfo.py b/osxphotos/photoinfo.py index 7ef0350b..02a5477c 100644 --- a/osxphotos/photoinfo.py +++ b/osxphotos/photoinfo.py @@ -650,28 +650,38 @@ class PhotoInfo: return self._info["hasAdjustments"] == 1 @property - def adjustments(self): - """Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only""" + def adjustments_path(self): + """Returns path to adjustments file or none if file doesn't exist""" if self._db._db_version <= _PHOTOS_4_VERSION: return None - if self.hasadjustments: - try: - return self._adjustmentinfo - except AttributeError: - library = self._db._library_path - directory = self._uuid[0] # first char of uuid - plist_file = ( - pathlib.Path(library) - / "resources" - / "renders" - / directory - / f"{self._uuid}.plist" - ) - if not plist_file.is_file(): - return None - self._adjustmentinfo = AdjustmentsInfo(plist_file) - return self._adjustmentinfo + if not self.hasadjustments: + return None + + library = self._db._library_path + directory = self._uuid[0] # first char of uuid + plist_file = ( + pathlib.Path(library) + / "resources" + / "renders" + / directory + / f"{self._uuid}.plist" + ) + if not plist_file.is_file(): + return None + return plist_file + + @property + def adjustments(self): + """Returns AdjustmentsInfo class for adjustment data or None if no adjustments; Photos 5+ only""" + try: + return self._adjustmentinfo + except AttributeError: + plist_file = self.adjustments_path + if plist_file is None: + return None + self._adjustmentinfo = AdjustmentsInfo(plist_file) + return self._adjustmentinfo @property def external_edit(self): diff --git a/tests/test_cli.py b/tests/test_cli.py index 941f6623..fab13e7f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -604,6 +604,13 @@ CLI_EXPORT_SIDECAR_DROP_EXT_FILENAMES = _normalize_fs_paths( ] ) +CLI_EXPORT_AAE_UUID = "E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51" +CLI_EXPORT_AAE_FILENAMES = [ + "wedding.jpg", + "wedding.AAE", + "wedding_edited.jpeg", +] + CLI_EXPORT_LIVE = [ "51F2BEF7-431A-4D31-8AC1-3284A57826AE.jpeg", "51F2BEF7-431A-4D31-8AC1-3284A57826AE.mov", @@ -3357,6 +3364,53 @@ def test_query_deleted_4(): assert json_got[0]["intrash"] +def test_export_aae(): + """Test export with --export-aae""" + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with runner.isolated_filesystem(): + result = runner.invoke( + cli_main, + [ + "export", + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "--export-aae", + f"--uuid={CLI_EXPORT_AAE_UUID}", + "-V", + ], + ) + assert result.exit_code == 0 + files = glob.glob("*.*") + assert sorted(files) == sorted(CLI_EXPORT_AAE_FILENAMES) + +def test_export_aae_as_hardlink(): + """Test export with --export-aae and --export-as-hardlink""" + + runner = CliRunner() + cwd = os.getcwd() + # pylint: disable=not-context-manager + with isolated_filesystem_here(): + result = runner.invoke( + cli_main, + [ + "export", + "--db", + os.path.join(cwd, CLI_PHOTOS_DB), + ".", + "--export-aae", + "--export-as-hardlink", + f"--uuid={CLI_EXPORT_AAE_UUID}", + "-V", + ], + ) + assert result.exit_code == 0 + files = glob.glob("*.*") + assert sorted(files) == sorted(CLI_EXPORT_AAE_FILENAMES) + def test_export_sidecar(): """test --sidecar"""