Add .AAE adjustment export (fix #97) (#1113)

Implements #97, export of adjustments data
This commit is contained in:
dvdkon 2023-07-15 16:16:56 +02:00 committed by GitHub
parent 30b0c13b33
commit c63b08694e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 154 additions and 19 deletions

View File

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

View File

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

View File

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

View File

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