Implements #97, export of adjustments data
This commit is contained in:
parent
30b0c13b33
commit
c63b08694e
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -650,15 +650,14 @@ 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:
|
||||
if not self.hasadjustments:
|
||||
return None
|
||||
|
||||
library = self._db._library_path
|
||||
directory = self._uuid[0] # first char of uuid
|
||||
plist_file = (
|
||||
@ -670,6 +669,17 @@ class PhotoInfo:
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@ -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"""
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user