Added XMP sidecar to export

This commit is contained in:
Rhet Turnbull
2020-01-26 09:34:53 -08:00
parent ba224af3fb
commit 4dfb131a21
6 changed files with 180 additions and 20 deletions

View File

@@ -622,13 +622,17 @@ def query(
) )
@click.option( @click.option(
"--sidecar", "--sidecar",
is_flag=True, default=None,
help="Create JSON sidecar for each photo exported " multiple=True,
f"in format useable by exiftool ({_EXIF_TOOL_URL}) " metavar="FORMAT",
type=click.Choice(['xmp', 'json'], case_sensitive=False),
help="Create sidecar for each photo exported; valid FORMAT values: xmp, json; "
f"--sidecar json: create JSON sidecar useable by exiftool ({_EXIF_TOOL_URL}) "
"The sidecar file can be used to apply metadata to the file with exiftool, for example: " "The sidecar file can be used to apply metadata to the file with exiftool, for example: "
'"exiftool -j=photoname.jpg.json photoname.jpg" ' '"exiftool -j=photoname.jpg.json photoname.jpg" '
"The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg). " "The sidecar file is named in format photoname.ext.json where ext is extension of the photo (e.g. jpg). "
"Note: this does not create an XMP sidecar as used by Lightroom, etc.", "--sidecar xmp: create XMP sidecar used by Adobe Lightroom, etc."
"The sidecar file is named in format photoname.ext.xmp where ext is extension of the photo (e.g. jpg). "
) )
@click.option( @click.option(
"--download-missing", "--download-missing",
@@ -1060,7 +1064,7 @@ def export_photo(
dest: destination path as string dest: destination path as string
verbose: boolean; print verbose output verbose: boolean; print verbose output
export_by_date: boolean; create export folder in form dest/YYYY/MM/DD export_by_date: boolean; create export folder in form dest/YYYY/MM/DD
sidecar: boolean; create json sidecar file with export sidecar: list zero, 1 or 2 of ["json","xmp"] of sidecar variety to export
overwrite: boolean; overwrite dest file if it already exists overwrite: boolean; overwrite dest file if it already exists
original_name: boolean; use original filename instead of current filename original_name: boolean; use original filename instead of current filename
export_live: boolean; also export live video component if photo is a live photo export_live: boolean; also export live video component if photo is a live photo
@@ -1100,10 +1104,18 @@ def export_photo(
date_created = photo.date.timetuple() date_created = photo.date.timetuple()
dest = create_path_by_date(dest, date_created) dest = create_path_by_date(dest, date_created)
sidecar = [s.lower() for s in sidecar]
sidecar_json = sidecar_xmp = False
if "json" in sidecar:
sidecar_json = True
if "xmp" in sidecar:
sidecar_xmp = True
photo_path = photo.export( photo_path = photo.export(
dest, dest,
filename, filename,
sidecar=sidecar, sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
overwrite=overwrite, overwrite=overwrite,
use_photos_export=download_missing, use_photos_export=download_missing,
) )
@@ -1119,7 +1131,8 @@ def export_photo(
photo.export( photo.export(
dest, dest,
edited_name, edited_name,
sidecar=sidecar, sidecar_json=sidecar_json,
sidecar_xmp=sidecar_xmp,
overwrite=overwrite, overwrite=overwrite,
edited=True, edited=True,
use_photos_export=download_missing, use_photos_export=download_missing,

View File

@@ -2,6 +2,7 @@
Constants used by osxphotos Constants used by osxphotos
""" """
import os.path
# which Photos library database versions have been tested # which Photos library database versions have been tested
# Photos 2.0 (10.12.6) == 2622 # Photos 2.0 (10.12.6) == 2622
@@ -30,3 +31,6 @@ _PHOTOS_5_SHARED_PHOTO_PATH = "resources/cloudsharing/data"
_PHOTO_TYPE = 0 _PHOTO_TYPE = 0
_MOVIE_TYPE = 1 _MOVIE_TYPE = 1
# Name of XMP template file
_TEMPLATE_DIR = os.path.join(os.path.dirname(__file__), "templates")
_XMP_TEMPLATE_NAME = "xmp_sidecar.mako"

View File

@@ -15,18 +15,21 @@ from datetime import timedelta, timezone
from pprint import pformat from pprint import pformat
import yaml import yaml
from mako.template import Template
from ._constants import ( from ._constants import (
_MOVIE_TYPE, _MOVIE_TYPE,
_PHOTO_TYPE, _PHOTO_TYPE,
_PHOTOS_5_SHARED_PHOTO_PATH, _PHOTOS_5_SHARED_PHOTO_PATH,
_PHOTOS_5_VERSION, _PHOTOS_5_VERSION,
_TEMPLATE_DIR,
_XMP_TEMPLATE_NAME,
) )
from .utils import ( from .utils import (
_copy_file, _copy_file,
_export_photo_uuid_applescript,
_get_resource_loc, _get_resource_loc,
dd_to_dms_str, dd_to_dms_str,
_export_photo_uuid_applescript,
) )
# TODO: check pylint output # TODO: check pylint output
@@ -458,7 +461,8 @@ class PhotoInfo:
edited=False, edited=False,
overwrite=False, overwrite=False,
increment=True, increment=True,
sidecar=False, sidecar_json=False,
sidecar_xmp=False,
use_photos_export=False, use_photos_export=False,
timeout=120, timeout=120,
): ):
@@ -470,8 +474,10 @@ class PhotoInfo:
overwrite: (boolean, default=False); if True will overwrite files if they alreay exist overwrite: (boolean, default=False); if True will overwrite files if they alreay exist
increment: (boolean, default=True); if True, will increment file name until a non-existant name is found increment: (boolean, default=True); if True, will increment file name until a non-existant name is found
if overwrite=False and increment=False, export will fail if destination file already exists if overwrite=False and increment=False, export will fail if destination file already exists
sidecar: (boolean, default = False); if True will also write a json sidecar with EXIF data in format readable by exiftool sidecar_json: (boolean, default = False); if True will also write a json sidecar with IPTC data in format readable by exiftool
sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg) sidecar filename will be dest/filename.ext.json where ext is suffix of the image file (e.g. jpeg or jpg)
sidecar_xmp: (boolean, default = False); if True will also write a XMP sidecar with IPTC data
sidecar filename will be dest/filename.ext.xmp where ext is suffix of the image file (e.g. jpeg or jpg)
use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos use_photos_export: (boolean, default=False); if True will attempt to export photo via applescript interaction with Photos
timeout: (int, default=120) timeout in seconds used with use_photos_export timeout: (int, default=120) timeout in seconds used with use_photos_export
returns the full path to the exported file """ returns the full path to the exported file """
@@ -589,20 +595,47 @@ class PhotoInfo:
if exported is None: if exported is None:
logging.warning(f"Error exporting photo {self.uuid} to {dest}") logging.warning(f"Error exporting photo {self.uuid} to {dest}")
if sidecar: if sidecar_json:
logging.debug("writing exiftool_json_sidecar") logging.debug("writing exiftool_json_sidecar")
sidecar_filename = f"{dest}.json" sidecar_filename = f"{dest}.json"
json_sidecar_str = self._exiftool_json_sidecar() sidecar_str = self._exiftool_json_sidecar()
try: try:
self._write_sidecar_car(sidecar_filename, json_sidecar_str) self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e: except Exception as e:
logging.critical(f"Error writing json sidecar to {sidecar_filename}") logging.warning(f"Error writing json sidecar to {sidecar_filename}")
raise e
if sidecar_xmp:
logging.debug("writing xmp_sidecar")
sidecar_filename = f"{dest}.xmp"
sidecar_str = self._xmp_sidecar()
try:
self._write_sidecar(sidecar_filename, sidecar_str)
except Exception as e:
logging.warning(f"Error writing xmp sidecar to {sidecar_filename}")
raise e raise e
return str(dest) return str(dest)
def _exiftool_json_sidecar(self): def _exiftool_json_sidecar(self):
""" return json string of EXIF details in exiftool sidecar format """ """ return json string of EXIF details in exiftool sidecar format
Does not include all the EXIF fields as those are likely already in the image
Exports the following:
FileName
ImageDescription
Description
Title
TagsList
Keywords
Subject
PersonInImage
GPSLatitude, GPSLongitude
GPSPosition
GPSLatitudeRef, GPSLongitudeRef
DateTimeOriginal
OffsetTimeOriginal
ModifyDate """
exif = {} exif = {}
exif["FileName"] = self.filename exif["FileName"] = self.filename
@@ -658,17 +691,27 @@ class PhotoInfo:
json_str = json.dumps([exif]) json_str = json.dumps([exif])
return json_str return json_str
def _write_sidecar_car(self, filename, json_str): def _xmp_sidecar(self):
if not filename and not json_str: """ returns string for XMP sidecar """
xmp_template = Template(
filename=os.path.join(_TEMPLATE_DIR, _XMP_TEMPLATE_NAME)
)
xmp_str = xmp_template.render(photo=self)
return xmp_str
def _write_sidecar(self, filename, sidecar_str):
""" write sidecar_str to filename
used for exporting sidecar info """
if not filename and not sidecar_str:
raise ( raise (
ValueError( ValueError(
f"filename {filename} and json_str {json_str} must not be None" f"filename {filename} and sidecar_str {sidecar_str} must not be None"
) )
) )
# TODO: catch exception? # TODO: catch exception?
f = open(filename, "w") f = open(filename, "w")
f.write(json_str) f.write(sidecar_str)
f.close() f.close()
@property @property

View File

@@ -0,0 +1,99 @@
<!-- Created with osxphotos https://github.com/RhetTbull/osxphotos -->
<%def name="dc_description(desc)">
% if desc is None:
<dc:description></dc:description>
% else:
<dc:description>${desc}</dc:description>
% endif
</%def>
<%def name="dc_title(title)">
% if title is None:
<dc:title></dc:title>
% else:
<dc:title>${title}</dc:title>
% endif
</%def>
<%def name="dc_subject(subject)">
% if subject:
<!-- keywords and persons listed in <dc:subject> as Photos does -->
<dc:subject>
<rdf:Seq>
% for subj in subject:
<rdf:li>${subj}</rdf:li>
% endfor
</rdf:Seq>
</dc:subject>
% endif
</%def>
<%def name="dc_datecreated(date)">
% if date is not None:
<photoshop:DateCreated>${date.isoformat()}</photoshop:DateCreated>
% endif
</%def>
<%def name="iptc_personinimage(persons)">
% if persons:
<Iptc4xmpExt:PersonInImage>
<rdf:Bag>
% for person in persons:
<rdf:li>${person}</rdf:li>
% endfor
</rdf:Bag>
</Iptc4xmpExt:PersonInImage>
% endif
</%def>
<%def name="dk_tagslist(keywords)">
% if keywords:
<digiKam:TagsList>
<rdf:Seq>
% for keyword in keywords:
<rdf:li>${keyword}</rdf:li>
% endfor
</rdf:Seq>
</digiKam:TagsList>
% endif
</%def>
<%def name="adobe_createdate(date)">
% if date is not None:
<xmp:CreateDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:CreateDate>
% endif
</%def>
<%def name="adobe_modifydate(date)">
% if date is not None:
<xmp:ModifyDate>${date.strftime("%Y-%m-%dT%H:%M:%S")}</xmp:ModifyDate>
% endif
</%def>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="XMP Core 5.4.0">
<!-- mirrors Photos 5 "Export IPTC as XMP" option -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/">
${dc_description(photo.description)}
${dc_title(photo.title)}
${dc_subject(photo.keywords + photo.persons)}
${dc_datecreated(photo.date)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:Iptc4xmpExt='http://iptc.org/std/Iptc4xmpExt/2008-02-29/'>
${iptc_personinimage(photo.persons)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:digiKam='http://www.digikam.org/ns/1.0/'>
${dk_tagslist(photo.keywords)}
</rdf:Description>
<rdf:Description rdf:about=''
xmlns:xmp='http://ns.adobe.com/xap/1.0/'>
${adobe_createdate(photo.date)}
${adobe_modifydate(photo.date)}
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>

View File

@@ -143,3 +143,4 @@ termcolor==1.1.0
wcwidth==0.1.7 wcwidth==0.1.7
wrapt==1.11.1 wrapt==1.11.1
zipp==0.5.2 zipp==0.5.2
Mako==1.1.1

View File

@@ -61,6 +61,6 @@ setup(
"Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.6",
"Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Software Development :: Libraries :: Python Modules",
], ],
install_requires=["pyobjc>=6.0.1", "Click>=7", "PyYAML>=5.1.2"], install_requires=["pyobjc>=6.0.1", "Click>=7", "PyYAML>=5.1.2", "Mako>=1.1.1"],
entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]}, entry_points={"console_scripts": ["osxphotos=osxphotos.__main__:cli"]},
) )