Added XMP sidecar to export
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
99
osxphotos/templates/xmp_sidecar.mako
Normal file
99
osxphotos/templates/xmp_sidecar.mako
Normal 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>
|
||||||
@@ -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
|
||||||
2
setup.py
2
setup.py
@@ -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"]},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user