Compare commits

..

5 Commits

Author SHA1 Message Date
Rhet Turnbull
4d924d0826 Completed implementation of --jpeg-ext, fixed --dry-run, closes #330, #346 2021-01-11 06:45:35 -08:00
Rhet Turnbull
55c088eea2 Added --jpeg-ext, implements #330 2021-01-10 09:44:42 -08:00
Rhet Turnbull
ee2750224a Updated CHANGELOG.md, [skip ci] 2021-01-09 18:03:21 -08:00
Rhet Turnbull
db1947dd1e Fixed leaky memory in PhotoKit, issue #276 2021-01-09 17:24:06 -08:00
Rhet Turnbull
248fdbcf02 Updated CHANGELOG.md, [skip ci] 2021-01-09 10:32:32 -08:00
13 changed files with 683 additions and 361 deletions

View File

@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. Dates are d
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
#### [v0.39.13](https://github.com/RhetTbull/osxphotos/compare/v0.39.12...v0.39.13)
> 10 January 2021
- Fixed leaky memory in PhotoKit, issue #276 [`db1947d`](https://github.com/RhetTbull/osxphotos/commit/db1947dd1e3d47a487eeb68a5ceb5f7098f1df10)
#### [v0.39.12](https://github.com/RhetTbull/osxphotos/compare/v0.39.11...v0.39.12)
> 9 January 2021
- Force cleanup of objects in write_jpeg (fix memory leak) [`#344`](https://github.com/RhetTbull/osxphotos/pull/344)
- doc: Recorded screencast and updated of readme [skip ci] [`#328`](https://github.com/RhetTbull/osxphotos/pull/328)
- Added PhotoInfo.visible, PhotoInfo.date_trashed, closes #333, #334 [`#333`](https://github.com/RhetTbull/osxphotos/issues/333)
- Force cleanup of objects with autorelease pool [`b67f11a`](https://github.com/RhetTbull/osxphotos/commit/b67f11a3bb95c08a39a185b6d884092870e949f2)
- Add @Rott-Apple as a contributor [`71cb015`](https://github.com/RhetTbull/osxphotos/commit/71cb01572d2d946df18dd7b36f95b2f2e5b48f86)
- Updated README [skip ci] [`804e13e`](https://github.com/RhetTbull/osxphotos/commit/804e13efff921ab51b996493d659b32102807a8a)
#### [v0.39.11](https://github.com/RhetTbull/osxphotos/compare/v0.39.10...v0.39.11)
> 8 January 2021
@@ -30,8 +47,8 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
> 4 January 2021
- Added test for Big Sur 16.0.1 database changes [`7deac58`](https://github.com/RhetTbull/osxphotos/commit/7deac581b1f1fb3dc59885b6e1ab9a63b382408d)
- Updated all-contributors [`2bf83e4`](https://github.com/RhetTbull/osxphotos/commit/2bf83e4b1fcfadb664ba8988bca4fef7e4c7da12)
- Added additional warning to _photoinfo_export [`fb5fb8e`](https://github.com/RhetTbull/osxphotos/commit/fb5fb8ebc73f96548975432333dfdf01c4794d51)
- Create terminalizer-demo.yml [`5dc2eea`](https://github.com/RhetTbull/osxphotos/commit/5dc2eeaf9a7265873c81db23bbc86d3023189a26)
- doc: Recorded screencast and updated of readme [`658e8ac`](https://github.com/RhetTbull/osxphotos/commit/658e8ac096d141fce48483dbfc1426bea317d806)
#### [v0.39.8](https://github.com/RhetTbull/osxphotos/compare/v0.39.7...v0.39.8)

View File

@@ -295,6 +295,10 @@ Options:
--export-as-hardlink Hardlink files instead of copying them.
Cannot be used with --exiftool which creates
copies of the files with embedded EXIF data.
Note: on APFS volumes, files are cloned when
exporting giving many of the same advantages
as hardlinks without having to use --export-
as-hardlink.
--touch-file Sets the file's modification time to match
photo date.
--overwrite Overwrite existing files. Default behavior
@@ -489,6 +493,15 @@ Options:
do not include an extension in the FILENAME
template. See below for additional details
on templating system.
--jpeg-ext EXTENSION Specify file extension for JPEG files.
Photos uses .jpeg for edited images but many
images are imported with .jpg or .JPG which
can result in multiple different extensions
used for JPEG files upon export. Use --jpg-
ext to specify a single extension to use for
all exported JPEG images. Valid values are
jpeg, jpg, JPEG, JPG; e.g. '--jpg-ext jpg'
to use '.jpg' for all JPEGs.
--strip Optionally strip leading and trailing
whitespace from any rendered templates. For
example, if --filename template is "{title,}

View File

@@ -47,6 +47,7 @@ from .path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath
from .photoinfo import ExportResults
from .photokit import check_photokit_authorization, request_photokit_authorization
from .phototemplate import TEMPLATE_SUBSTITUTIONS, TEMPLATE_SUBSTITUTIONS_MULTI_VALUED
from .utils import get_preferred_uti_extension
# global variable to control verbose output
# set via --verbose/-V
@@ -1588,6 +1589,16 @@ def query(
"File extension will be added automatically--do not include an extension in the FILENAME template. "
"See below for additional details on templating system.",
)
@click.option(
"--jpeg-ext",
multiple=False,
metavar="EXTENSION",
type=click.Choice(["jpeg", "jpg", "JPEG", "JPG"], case_sensitive=True),
help="Specify file extension for JPEG files. Photos uses .jpeg for edited images but many images "
"are imported with .jpg or .JPG which can result in multiple different extensions used for JPEG files "
"upon export. Use --jpg-ext to specify a single extension to use for all exported JPEG images. "
"Valid values are jpeg, jpg, JPEG, JPG; e.g. '--jpg-ext jpg' to use '.jpg' for all JPEGs.",
)
@click.option(
"--strip",
is_flag=True,
@@ -1759,6 +1770,7 @@ def export(
has_raw,
directory,
filename_template,
jpeg_ext,
strip,
edited_suffix,
original_suffix,
@@ -1898,6 +1910,7 @@ def export(
has_raw = cfg.has_raw
directory = cfg.directory
filename_template = cfg.filename_template
jpeg_ext = cfg.jpeg_ext
strip = cfg.strip
edited_suffix = cfg.edited_suffix
original_suffix = cfg.original_suffix
@@ -2265,6 +2278,7 @@ def export(
use_photokit=use_photokit,
exiftool_option=exiftool_option,
strip=strip,
jpeg_ext=jpeg_ext,
)
results += export_results
@@ -2839,6 +2853,7 @@ def export_photo(
use_photokit=False,
exiftool_option=None,
strip=False,
jpeg_ext=None,
):
"""Helper function for export that does the actual export
@@ -2876,6 +2891,7 @@ def export_photo(
exiftool_option: optional list flags (e.g. ["-m", "-F"]) to pass to exiftool
exiftool_merge_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
exiftool_merge_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
jpeg_ext: if not None, specify the extension to use for all JPEG images on export
Returns:
list of path(s) of exported photo or None if photo was missing
@@ -2933,6 +2949,7 @@ def export_photo(
photo, filename_template, original_name, strip=strip
)
for filename in filenames:
rendered_suffix = ""
if original_suffix:
try:
rendered_suffix, unmatched = photo.render_template(
@@ -2955,14 +2972,17 @@ def export_photo(
)
rendered_suffix = rendered_suffix[0]
original_filename = pathlib.Path(filename)
original_filename = (
original_filename.parent
/ f"{original_filename.stem}{rendered_suffix}{original_filename.suffix}"
)
original_filename = str(original_filename)
else:
original_filename = filename
original_filename = pathlib.Path(filename)
file_ext = (
"." + jpeg_ext
if jpeg_ext and photo.uti == "public.jpeg"
else original_filename.suffix
)
original_filename = (
original_filename.parent
/ f"{original_filename.stem}{rendered_suffix}{file_ext}"
)
original_filename = str(original_filename)
verbose_(
f"Exporting {photo.original_filename} ({photo.filename}) as {original_filename}"
@@ -3046,6 +3066,7 @@ def export_photo(
use_photokit=use_photokit,
verbose=verbose_,
exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext,
)
results += export_results
for warning_ in export_results.exiftool_warning:
@@ -3087,13 +3108,15 @@ def export_photo(
# verify the photo has adjustments and valid path to avoid raising an exception
if export_edited and photo.hasadjustments:
edited_filename = pathlib.Path(filename)
# check for correct edited suffix
if photo.path_edited is not None:
edited_ext = pathlib.Path(photo.path_edited).suffix
else:
# use filename suffix which might be wrong,
# will be corrected by use_photos_export
edited_ext = pathlib.Path(photo.filename).suffix
edited_ext = (
"." + jpeg_ext
if jpeg_ext and photo.uti_edited == "public.jpeg"
else "." + get_preferred_uti_extension(photo.uti_edited)
if photo.uti_edited
else pathlib.Path(photo.path_edited).suffix
if photo.path_edited
else pathlib.Path(photo.filename).suffix
)
if edited_suffix:
try:
@@ -3128,7 +3151,9 @@ def export_photo(
)
if missing_edited:
space = " " if not verbose else ""
verbose_(f"{space}Skipping missing edited photo for {edited_filename}")
verbose_(
f"{space}Skipping missing edited photo for {edited_filename}"
)
results.missing.append(
str(pathlib.Path(dest_path) / edited_filename)
)
@@ -3140,7 +3165,7 @@ def export_photo(
f"{space}Skipping missing deleted photo {photo.original_filename} ({photo.uuid})"
)
results.missing.append(
str(pathlib.Path(dest_path) / edited_filename )
str(pathlib.Path(dest_path) / edited_filename)
)
else:
@@ -3173,6 +3198,7 @@ def export_photo(
use_photokit=use_photokit,
verbose=verbose_,
exiftool_flags=exiftool_option,
jpeg_ext=jpeg_ext,
)
results += export_results_edited
for warning_ in export_results_edited.exiftool_warning:

View File

@@ -1,5 +1,3 @@
""" version info """
__version__ = "0.39.12"
__version__ = "0.39.15"

View File

@@ -60,6 +60,11 @@ class FileUtilABC(ABC):
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
pass
@classmethod
@abstractmethod
def rename(cls, src, dest):
pass
class FileUtilMacOS(FileUtilABC):
""" Various file utilities """
@@ -201,6 +206,21 @@ class FileUtilMacOS(FileUtilABC):
src_file, dest_file, compression_quality=compression_quality
)
@classmethod
def rename(cls, src, dest):
""" Copy src to dest
Args:
src: path to source file
dest: path to destination file
Returns:
Name of renamed file (dest)
"""
os.rename(str(src), str(dest))
return dest
@staticmethod
def _sig(st):
""" return tuple of (mode, size, mtime) of file based on os.stat
@@ -266,3 +286,7 @@ class FileUtilNoOp(FileUtil):
@classmethod
def convert_to_jpeg(cls, src_file, dest_file, compression_quality=1.0):
cls.verbose(f"convert_to_jpeg: {src_file}, {dest_file}, {compression_quality}")
@classmethod
def rename(cls, src, dest):
cls.verbose(f"rename: {src}, {dest}")

View File

@@ -49,7 +49,7 @@ from ..photokit import (
PhotoKitFetchFailed,
PhotoLibrary,
)
from ..utils import dd_to_dms_str, findfiles, noop
from ..utils import dd_to_dms_str, findfiles, noop, get_preferred_uti_extension
class ExportError(Exception):
@@ -311,6 +311,34 @@ def _check_export_suffix(src, dest, edited):
)
# not a class method, don't import into PhotoInfo
def rename_jpeg_files(files, jpeg_ext, fileutil):
""" rename any jpeg files in files so that extension matches jpeg_ext
Args:
files: list of file paths
jpeg_ext: extension to use for jpeg files found in files, e.g. "jpg"
fileutil: a FileUtil object
Returns:
list of files with updated names
Note: If non-jpeg files found, they will be ignore and returned in the return list
"""
jpeg_ext = "." + jpeg_ext
jpegs = [".jpeg", ".jpg"]
new_files = []
for file in files:
path = pathlib.Path(file)
if path.suffix.lower() in jpegs and path.suffix != jpeg_ext:
new_file = path.parent / (path.stem + jpeg_ext)
fileutil.rename(file, new_file)
new_files.append(new_file)
else:
new_files.append(file)
return new_files
def export(
self,
dest,
@@ -437,6 +465,7 @@ def export2(
exiftool_flags=None,
merge_exif_keywords=False,
merge_exif_persons=False,
jpeg_ext=None,
):
"""export photo, like export but with update and dry_run options
dest: must be valid destination path or exception raised
@@ -488,6 +517,7 @@ def export2(
exiftool_flags: optional list of flags to pass to exiftool when using exiftool option, e.g ["-m", "-F"]
merge_exif_keywords: boolean; if True, merged keywords found in file's exif data (requires exiftool)
merge_exif_persons: boolean; if True, merged persons found in file's exif data (requires exiftool)
jpeg_ext: if set, will use this value for extension on jpegs converted to jpeg with convert_to_jpeg; if not set, uses jpeg; do not include the leading "."
Returns: ExportResults class
ExportResults has attributes:
@@ -576,7 +606,8 @@ def export2(
if convert_to_jpeg and self.isphoto and uti != "public.jpeg":
# not a jpeg but will convert to jpeg upon export so fix file extension
fname_new = pathlib.Path(fname)
fname = str(fname_new.parent / f"{fname_new.stem}.jpeg")
ext = "." + jpeg_ext if jpeg_ext else ".jpeg"
fname = str(fname_new.parent / f"{fname_new.stem}{ext}")
else:
# nothing to convert
convert_to_jpeg = False
@@ -746,6 +777,8 @@ def export2(
)
all_results += results
else:
# TODO: move this big if/else block to separate functions
# e.g. _export_with_photos_export or such
# use_photo_export
# export live_photo .mov file?
live_photo = True if live_photo and self.live_photo else False
@@ -760,7 +793,10 @@ def export2(
else:
# didn't get passed a filename, add _edited
filestem = f"{dest.stem}{edited_identifier}"
dest = dest.parent / f"{filestem}.jpeg"
uti = self.uti_edited if edited and self.uti_edited else self.uti
ext = get_preferred_uti_extension(uti)
dest = dest.parent / f"{filestem}{ext}"
if use_photokit:
photolib = PhotoLibrary()
photo = None
@@ -783,13 +819,17 @@ def export2(
)
)
if photo:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append((str(dest), e))
if not dry_run:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_CURRENT
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append((str(dest), e))
else:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = _export_photo_uuid_applescript(
@@ -824,13 +864,17 @@ def export2(
photo = [p for p in bursts if p.uuid.startswith(self.uuid)]
photo = photo[0] if photo else None
if photo:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append((str(dest), e))
if not dry_run:
try:
exported = photo.export(
dest.parent, dest.name, version=PHOTOS_VERSION_ORIGINAL
)
all_results.exported.extend(exported)
except Exception as e:
all_results.error.append((str(dest), e))
else:
# dry_run, don't actually export
all_results.exported.append(str(dest))
else:
try:
exported = _export_photo_uuid_applescript(
@@ -848,6 +892,13 @@ def export2(
except ExportError as e:
all_results.error.append((str(dest), e))
if all_results.exported:
if jpeg_ext:
# use_photos_export (both PhotoKit and AppleScript) don't use the
# file extension provided (instead they use extension for UTI)
# so if jpeg_ext is set, rename any non-conforming jpegs
all_results.exported = rename_jpeg_files(
all_results.exported, jpeg_ext, fileutil
)
if touch_file:
for exported_file in all_results.exported:
all_results.touched.append(exported_file)
@@ -856,9 +907,6 @@ def export2(
if update:
all_results.new.extend(all_results.exported)
# else:
# all_results.error.append((str(dest), f"Error exporting photo {self.uuid} to {dest} with use_photos_export"))
# export metadata
sidecars = []
sidecar_json_files_skipped = []
@@ -1766,3 +1814,4 @@ def _write_sidecar(self, filename, sidecar_str):
f = open(filename, "w")
f.write(sidecar_str)
f.close()

View File

@@ -19,6 +19,7 @@
# add original=False to export instead of version= (and maybe others like path())
# make burst/live methods get uuid from self instead of passing as arg
import copy
import pathlib
import threading
import time
@@ -169,12 +170,14 @@ class ImageData:
requestImageDataAndOrientationForAsset_options_resultHandler_
"""
def __init__(self):
self.metadata = None
self.uti = None
self.image_data = None
self.info = None
self.orientation = None
def __init__(
self, metadata=None, uti=None, image_data=None, info=None, orientation=None
):
self.metadata = metadata
self.uti = uti
self.image_data = image_data
self.info = info
self.orientation = orientation
class AVAssetData:
@@ -475,44 +478,48 @@ class PhotoAsset:
# if self.live:
# raise NotImplementedError("Live photos not implemented yet")
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
with objc.autorelease_pool():
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = None
if self.isphoto:
imagedata = self._request_image_data(version=version)
ext = get_preferred_uti_extension(imagedata.uti)
output_file = None
if self.isphoto:
imagedata = self._request_image_data(version=version)
ext = get_preferred_uti_extension(imagedata.uti)
output_file = dest / f"{filename.stem}.{ext}"
output_file = dest / f"{filename.stem}.{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
with open(output_file, "wb") as fd:
fd.write(imagedata.image_data)
elif self.ismovie:
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
with open(output_file, "wb") as fd:
fd.write(imagedata.image_data)
del imagedata
elif self.ismovie:
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
FileUtil.copy(path, output_file)
FileUtil.copy(path, output_file)
return [str(output_file)]
return [str(output_file)]
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" Request image data and metadata for self._phasset
@@ -529,50 +536,56 @@ class PhotoAsset:
# reference: https://developer.apple.com/documentation/photokit/phimagemanager/3237282-requestimagedataandorientationfo?language=objc
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
with objc.autorelease_pool():
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
# pylint: disable=no-member
options_request = Photos.PHImageRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setSynchronous_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHImageRequestOptionsDeliveryModeHighQualityFormat
)
requestdata = ImageData()
event = threading.Event()
def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
nonlocal requestdata
options = {}
# pylint: disable=no-member
options[Quartz.kCGImageSourceShouldCache] = Foundation.kCFBooleanFalse
imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options)
requestdata.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex(
imgSrc, 0, options
options_request = Photos.PHImageRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setSynchronous_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHImageRequestOptionsDeliveryModeHighQualityFormat
)
requestdata.uti = dataUTI
requestdata.orientation = orientation
requestdata.info = info
requestdata.image_data = imageData
requestdata = ImageData()
event = threading.Event()
event.set()
def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
self._imagedata = requestdata
return requestdata
nonlocal requestdata
options = {}
# pylint: disable=no-member
options[Quartz.kCGImageSourceShouldCache] = Foundation.kCFBooleanFalse
imgSrc = Quartz.CGImageSourceCreateWithData(imageData, options)
requestdata.metadata = Quartz.CGImageSourceCopyPropertiesAtIndex(
imgSrc, 0, options
)
requestdata.uti = dataUTI
requestdata.orientation = orientation
requestdata.info = info
requestdata.image_data = imageData
event.set()
self._manager.requestImageDataAndOrientationForAsset_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
# options_request.dealloc()
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata)
del requestdata
return data
def _make_result_handle_(self, data):
""" Make handler function and threading event to use with
@@ -634,37 +647,41 @@ class SlowMoVideoExporter(NSObject):
Returns:
path to exported file
"""
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
)
exporter.setOutputURL_(self.url)
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
exporter.setShouldOptimizeForNetworkUse_(True)
self.done = False
with objc.autorelease_pool():
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
)
exporter.setOutputURL_(self.url)
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
exporter.setShouldOptimizeForNetworkUse_(True)
def handler():
""" result handler for exportAsynchronouslyWithCompletionHandler """
self.done = True
self.done = False
exporter.exportAsynchronouslyWithCompletionHandler_(handler)
# wait for export to complete
# would be more elegant to use a dispatch queue, notification, or thread event to wait
# but I can't figure out how to make that work and this does work
while True:
status = exporter.status()
if status == AVFoundation.AVAssetExportSessionStatusCompleted:
break
elif status not in (
AVFoundation.AVAssetExportSessionStatusWaiting,
AVFoundation.AVAssetExportSessionStatusExporting,
):
raise PhotoKitExportError(
f"Error encountered during exportAsynchronouslyWithCompletionHandler: status = {status}"
)
time.sleep(MIN_SLEEP)
def handler():
""" result handler for exportAsynchronouslyWithCompletionHandler """
self.done = True
return NSURL_to_path(exporter.outputURL())
exporter.exportAsynchronouslyWithCompletionHandler_(handler)
# wait for export to complete
# would be more elegant to use a dispatch queue, notification, or thread event to wait
# but I can't figure out how to make that work and this does work
while True:
status = exporter.status()
if status == AVFoundation.AVAssetExportSessionStatusCompleted:
break
elif status not in (
AVFoundation.AVAssetExportSessionStatusWaiting,
AVFoundation.AVAssetExportSessionStatusExporting,
):
raise PhotoKitExportError(
f"Error encountered during exportAsynchronouslyWithCompletionHandler: status = {status}"
)
time.sleep(MIN_SLEEP)
exported_path = NSURL_to_path(exporter.outputURL())
# exporter.dealloc()
return exported_path
def __del__(self):
self.avasset = None
@@ -701,39 +718,43 @@ class VideoAsset(PhotoAsset):
ValueError if dest is not a valid directory
"""
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [
self._export_slow_mo(
dest, filename=filename, version=version, overwrite=overwrite
)
]
with objc.autorelease_pool():
if self.slow_mo and version == PHOTOS_VERSION_CURRENT:
return [
self._export_slow_mo(
dest, filename=filename, version=version, overwrite=overwrite
)
]
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = None
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
output_file = None
videodata = self._request_video_data(version=version)
if videodata.asset is None:
raise PhotoKitExportError("Could not get video for asset")
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
url = videodata.asset.URL()
path = pathlib.Path(NSURL_to_path(url))
del videodata
if not path.is_file():
raise FileNotFoundError("Could not get path to video file")
ext = path.suffix
output_file = dest / f"{filename.stem}{ext}"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
FileUtil.copy(path, output_file)
FileUtil.copy(path, output_file)
return [str(output_file)]
return [str(output_file)]
def _export_slow_mo(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
@@ -752,33 +773,38 @@ class VideoAsset(PhotoAsset):
Raises:
ValueError if dest is not a valid directory
"""
if not self.slow_mo:
raise PhotoKitMediaTypeError("Not a slow-mo video")
with objc.autorelease_pool():
if not self.slow_mo:
raise PhotoKitMediaTypeError("Not a slow-mo video")
videodata = self._request_video_data(version=version)
if (
not isinstance(videodata.asset, AVFoundation.AVComposition)
or len(videodata.asset.tracks()) != 2
):
raise PhotoKitMediaTypeError("Does not appear to be slow-mo video")
videodata = self._request_video_data(version=version)
if (
not isinstance(videodata.asset, AVFoundation.AVComposition)
or len(videodata.asset.tracks()) != 2
):
raise PhotoKitMediaTypeError("Does not appear to be slow-mo video")
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
output_file = dest / f"{filename.stem}.mov"
output_file = dest / f"{filename.stem}.mov"
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
if not overwrite:
output_file = pathlib.Path(increment_filename(output_file))
exporter = SlowMoVideoExporter.alloc().initWithAVAsset_path_(
videodata.asset, output_file
)
return exporter.exportSlowMoVideo()
exporter = SlowMoVideoExporter.alloc().initWithAVAsset_path_(
videodata.asset, output_file
)
video = exporter.exportSlowMoVideo()
# exporter.dealloc()
return video
# todo: rewrite this with NotificationCenter and App event loop?
def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL):
@@ -793,38 +819,43 @@ class VideoAsset(PhotoAsset):
Raises:
ValueError if passed invalid value for version
"""
with objc.autorelease_pool():
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
if version not in [
PHOTOS_VERSION_CURRENT,
PHOTOS_VERSION_ORIGINAL,
PHOTOS_VERSION_UNADJUSTED,
]:
raise ValueError("Invalid value for version")
options_request = Photos.PHVideoRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
requestdata = AVAssetData()
event = threading.Event()
options_request = Photos.PHVideoRequestOptions.alloc().init()
options_request.setNetworkAccessAllowed_(True)
options_request.setVersion_(version)
options_request.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
requestdata = AVAssetData()
event = threading.Event()
def handler(asset, audiomix, info):
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """
nonlocal requestdata
def handler(asset, audiomix, info):
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """
nonlocal requestdata
requestdata.asset = asset
requestdata.audiomix = audiomix
requestdata.info = info
requestdata.asset = asset
requestdata.audiomix = audiomix
requestdata.info = info
event.set()
event.set()
self._manager.requestAVAssetForVideo_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
self._manager.requestAVAssetForVideo_options_resultHandler_(
self.phasset, options_request, handler
)
event.wait()
return requestdata
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata)
del requestdata
return data
class LivePhotoRequest(NSObject):
@@ -843,47 +874,54 @@ class LivePhotoRequest(NSObject):
def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT):
""" return the photos and video components of a live video as [PHAssetResource] """
options = Photos.PHLivePhotoRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
options.setVersion_(version)
options.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
delegate = PhotoKitNotificationDelegate.alloc().init()
self.nc.addObserver_selector_name_object_(
delegate, "liveNotification:", None, None
)
self.live_photo = None
def handler(result, info):
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """
if not info["PHImageResultIsDegradedKey"]:
self.live_photo = result
self.info = info
self.nc.postNotificationName_object_(
PHOTOKIT_NOTIFICATION_FINISHED_REQUEST, self
)
try:
self.manager.requestLivePhotoForAsset_targetSize_contentMode_options_resultHandler_(
self.asset,
Photos.PHImageManagerMaximumSize,
Photos.PHImageContentModeDefault,
options,
handler,
with objc.autorelease_pool():
options = Photos.PHLivePhotoRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
options.setVersion_(version)
options.setDeliveryMode_(
Photos.PHVideoRequestOptionsDeliveryModeHighQualityFormat
)
AppHelper.runConsoleEventLoop(installInterrupt=True)
except KeyboardInterrupt:
AppHelper.stopEventLoop()
finally:
pass
delegate = PhotoKitNotificationDelegate.alloc().init()
asset_resources = Photos.PHAssetResource.assetResourcesForLivePhoto_(
self.live_photo
)
return asset_resources
self.nc.addObserver_selector_name_object_(
delegate, "liveNotification:", None, None
)
self.live_photo = None
def handler(result, info):
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """
if not info["PHImageResultIsDegradedKey"]:
self.live_photo = result
self.info = info
self.nc.postNotificationName_object_(
PHOTOKIT_NOTIFICATION_FINISHED_REQUEST, self
)
try:
self.manager.requestLivePhotoForAsset_targetSize_contentMode_options_resultHandler_(
self.asset,
Photos.PHImageManagerMaximumSize,
Photos.PHImageContentModeDefault,
options,
handler,
)
AppHelper.runConsoleEventLoop(installInterrupt=True)
except KeyboardInterrupt:
AppHelper.stopEventLoop()
finally:
pass
asset_resources = Photos.PHAssetResource.assetResourcesForLivePhoto_(
self.live_photo
)
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(asset_resources)
del asset_resources
return data
def __del__(self):
self.manager = None
@@ -923,88 +961,99 @@ class LivePhotoAsset(PhotoAsset):
ValueError if dest is not a valid directory
PhotoKitExportError if error during export
"""
filename = (
pathlib.Path(filename) if filename else pathlib.Path(self.original_filename)
)
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
request = LivePhotoRequest.alloc().initWithManager_Asset_(
self._manager, self.phasset
)
resources = request.requestLivePhotoResources(version=version)
video_resource = None
photo_resource = None
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
video_resource = resource
elif resource.type() == Photos.PHAssetMediaTypeImage:
photo_resource = resource
if not video_resource or not photo_resource:
raise PhotoKitExportError(
"Did not find photo/video resources for live photo"
with objc.autorelease_pool():
filename = (
pathlib.Path(filename)
if filename
else pathlib.Path(self.original_filename)
)
photo_ext = get_preferred_uti_extension(photo_resource.uniformTypeIdentifier())
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
video_ext = get_preferred_uti_extension(video_resource.uniformTypeIdentifier())
video_output_file = dest / f"{filename.stem}.{video_ext}"
dest = pathlib.Path(dest)
if not dest.is_dir():
raise ValueError("dest must be a valid directory: {dest}")
if not overwrite:
photo_output_file = pathlib.Path(increment_filename(photo_output_file))
video_output_file = pathlib.Path(increment_filename(video_output_file))
request = LivePhotoRequest.alloc().initWithManager_Asset_(
self._manager, self.phasset
)
resources = request.requestLivePhotoResources(version=version)
# def handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
video_resource = None
photo_resource = None
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypePairedVideo:
video_resource = resource
elif resource.type() == Photos.PHAssetMediaTypeImage:
photo_resource = resource
# resource_manager = Photos.PHAssetResourceManager.defaultManager()
# options = Photos.PHAssetResourceRequestOptions.alloc().init()
# options.setNetworkAccessAllowed_(True)
# exported = []
# Note: Tried writeDataForAssetResource_toFile_options_completionHandler_ which works
# but sets quarantine flag and for reasons I can't determine (maybe quarantine flag)
# causes pathlib.Path().is_file() to fail in tests
if not video_resource or not photo_resource:
raise PhotoKitExportError(
"Did not find photo/video resources for live photo"
)
# if photo:
# photo_output_url = path_to_NSURL(photo_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# photo_resource, photo_output_url, options, handler
# )
# exported.append(str(photo_output_file))
photo_ext = get_preferred_uti_extension(
photo_resource.uniformTypeIdentifier()
)
photo_output_file = dest / f"{filename.stem}.{photo_ext}"
video_ext = get_preferred_uti_extension(
video_resource.uniformTypeIdentifier()
)
video_output_file = dest / f"{filename.stem}.{video_ext}"
# if video:
# video_output_url = path_to_NSURL(video_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# video_resource, video_output_url, options, handler
# )
# exported.append(str(video_output_file))
if not overwrite:
photo_output_file = pathlib.Path(increment_filename(photo_output_file))
video_output_file = pathlib.Path(increment_filename(video_output_file))
# def completion_handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
# def handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
# would be nice to be able to usewriteDataForAssetResource_toFile_options_completionHandler_
# but it sets quarantine flags that cause issues so instead, request the data and write the files directly
# resource_manager = Photos.PHAssetResourceManager.defaultManager()
# options = Photos.PHAssetResourceRequestOptions.alloc().init()
# options.setNetworkAccessAllowed_(True)
# exported = []
# Note: Tried writeDataForAssetResource_toFile_options_completionHandler_ which works
# but sets quarantine flag and for reasons I can't determine (maybe quarantine flag)
# causes pathlib.Path().is_file() to fail in tests
exported = []
if photo:
data = self._request_resource_data(photo_resource)
# image_data = self.request_image_data(version=version)
with open(photo_output_file, "wb") as fd:
fd.write(data)
exported.append(str(photo_output_file))
if video:
data = self._request_resource_data(video_resource)
with open(video_output_file, "wb") as fd:
fd.write(data)
exported.append(str(video_output_file))
# if photo:
# photo_output_url = path_to_NSURL(photo_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# photo_resource, photo_output_url, options, handler
# )
# exported.append(str(photo_output_file))
return exported
# if video:
# video_output_url = path_to_NSURL(video_output_file)
# resource_manager.writeDataForAssetResource_toFile_options_completionHandler_(
# video_resource, video_output_url, options, handler
# )
# exported.append(str(video_output_file))
# def completion_handler(error):
# if error:
# raise PhotoKitExportError(f"writeDataForAssetResource error: {error}")
# would be nice to be able to usewriteDataForAssetResource_toFile_options_completionHandler_
# but it sets quarantine flags that cause issues so instead, request the data and write the files directly
exported = []
if photo:
data = self._request_resource_data(photo_resource)
# image_data = self.request_image_data(version=version)
with open(photo_output_file, "wb") as fd:
fd.write(data)
exported.append(str(photo_output_file))
del data
if video:
data = self._request_resource_data(video_resource)
with open(video_output_file, "wb") as fd:
fd.write(data)
exported.append(str(video_output_file))
del data
request.dealloc()
return exported
def _request_resource_data(self, resource):
""" Request asset resource data (either photo or video component)
@@ -1015,33 +1064,40 @@ class LivePhotoAsset(PhotoAsset):
Raises:
"""
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
with objc.autorelease_pool():
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
requestdata = PHAssetResourceData()
event = threading.Event()
requestdata = PHAssetResourceData()
event = threading.Event()
def handler(data):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
def handler(data):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """
nonlocal requestdata
nonlocal requestdata
requestdata.data += data
requestdata.data += data
def completion_handler(error):
if error:
raise PhotoKitExportError("Error requesting data for asset resource")
event.set()
def completion_handler(error):
if error:
raise PhotoKitExportError(
"Error requesting data for asset resource"
)
event.set()
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
resource, options, handler, completion_handler
)
resource_manager.requestDataForAssetResource_options_dataReceivedHandler_completionHandler_(
resource, options, handler, completion_handler
)
event.wait()
options.dealloc()
return requestdata.data
event.wait()
# not sure why this is needed -- some weird ref count thing maybe
# if I don't do this, memory leaks
data = copy.copy(requestdata.data)
del requestdata
return data
# def request_image_data(self, version=PHOTOS_VERSION_CURRENT):
# # Returns an NSImage which isn't overly useful
@@ -1127,19 +1183,20 @@ class PhotoLibrary:
"""
# pylint: disable=no-member
fetch_options = Photos.PHFetchOptions.alloc().init()
fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_(
uuid_list, fetch_options
)
if fetch_result and fetch_result.count() >= 1:
return [
self._asset_factory(fetch_result.objectAtIndex_(idx))
for idx in range(fetch_result.count())
]
else:
raise PhotoKitFetchFailed(
f"Fetch did not return result for uuid_list {uuid_list}"
with objc.autorelease_pool():
fetch_options = Photos.PHFetchOptions.alloc().init()
fetch_result = Photos.PHAsset.fetchAssetsWithLocalIdentifiers_options_(
uuid_list, fetch_options
)
if fetch_result and fetch_result.count() >= 1:
return [
self._asset_factory(fetch_result.objectAtIndex_(idx))
for idx in range(fetch_result.count())
]
else:
raise PhotoKitFetchFailed(
f"Fetch did not return result for uuid_list {uuid_list}"
)
def fetch_uuid(self, uuid):
""" fetch PHAsset with uuid = uuid

View File

@@ -260,10 +260,10 @@ def get_preferred_uti_extension(uti):
returns: preferred extension as str """
# reference: https://developer.apple.com/documentation/coreservices/1442744-uttypecopypreferredtagwithclass?language=objc
return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
with objc.autorelease_pool():
return CoreServices.UTTypeCopyPreferredTagWithClass(
uti, CoreServices.kUTTagClassFilenameExtension
)
def findfiles(pattern, path_):

File diff suppressed because one or more lines are too long

View File

@@ -565,6 +565,14 @@ UUID_NO_LIKES = [
"1C1C8F1F-826B-4A24-B1CB-56628946A834",
]
UUID_JPEGS_DICT = {
"4D521201-92AC-43E5-8F7C-59BC41C37A96": ["IMG_1997", "JPG"],
"E9BC5C36-7CD1-40A1-A72B-8B8FAC227D51": ["wedding", "jpg"],
"E2078879-A29C-4D6F-BACB-E3BBE6C3EB91": ["screenshot-really-a-png", "jpeg"],
}
UUID_HEIC = {"7783E8E6-9CAC-40F3-BE22-81FB7051C266": "IMG_3092"}
def modify_file(filename):
""" appends data to a file to modify it """
@@ -5238,3 +5246,77 @@ def test_export_xattr_template():
assert sorted(md.keywords) == sorted(expected)
assert md.comment == CLI_FINDER_TAGS[uuid]["XMP:Title"]
def test_export_jpeg_ext():
""" test --jpeg-ext """
import glob
import os
import os.path
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid, fileinfo in UUID_JPEGS_DICT.items():
result = runner.invoke(
export, [os.path.join(cwd, PHOTOS_DB_15_7), ".", "-V", "--uuid", uuid]
)
assert result.exit_code == 0
files = glob.glob("*")
filename, ext = fileinfo
assert f"{filename}.{ext}" in files
for jpeg_ext in ["jpg", "JPG", "jpeg", "JPEG"]:
with runner.isolated_filesystem():
for uuid, fileinfo in UUID_JPEGS_DICT.items():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--uuid",
uuid,
"--jpeg-ext",
jpeg_ext,
],
)
assert result.exit_code == 0
files = glob.glob("*")
filename, ext = fileinfo
assert f"{filename}.{jpeg_ext}" in files
@pytest.mark.skipif(
"OSXPHOTOS_TEST_CONVERT" not in os.environ,
reason="Skip if running in Github actions, no GPU.",
)
def test_export_jpeg_ext_convert_to_jpeg():
""" test --jpeg-ext with --convert-to-jpeg """
import glob
import os
import os.path
from osxphotos.__main__ import export
runner = CliRunner()
cwd = os.getcwd()
# pylint: disable=not-context-manager
with runner.isolated_filesystem():
for uuid, filename in UUID_HEIC.items():
result = runner.invoke(
export,
[
os.path.join(cwd, PHOTOS_DB_15_7),
".",
"-V",
"--uuid",
uuid,
"--convert-to-jpeg",
"--jpeg-ext",
"jpg",
],
)
assert result.exit_code == 0
files = glob.glob("*")
assert f"{filename}.jpg" in files

View File

@@ -107,3 +107,21 @@ def test_convert_to_jpeg_quality():
assert FileUtil.convert_to_jpeg(imgfile, outfile, compression_quality=0.1)
assert outfile.is_file()
assert outfile.stat().st_size < 1000000
def test_rename_file():
# rename file with valid src, dest
import pathlib
import tempfile
from osxphotos.fileutil import FileUtil
temp_dir = tempfile.TemporaryDirectory(prefix="osxphotos_")
src = "tests/test-images/wedding.jpg"
dest = f"{temp_dir.name}/foo.jpg"
dest2 = f"{temp_dir.name}/bar.jpg"
FileUtil.copy(src, dest)
result = FileUtil.rename(dest, dest2)
assert result
assert pathlib.Path(dest2).exists()
assert not pathlib.Path(dest).exists()

18
utils/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Utils
These are various utilities used in my development workflow. They may or may not be useful to you if you're working on osxphotos. If using the AppleScripts to get data from Photos, I highly recommend the excellent [FastScripts](https://redsweater.com/fastscripts/) from Red Sweater Software.
## Files
|File | Description |
|-----|-------------|
|build_help_table.py| Builds the template substitutions table used in main README.md |
|check_uuid.py| Use with output file created by dump_photo_info.scpt to check ouput of osxphotos vs what Photos reports|
|copy_uuid_to_clipboard.applescript| Copy UUID of selected photo in Photos to the Clipboard|
|dump_photo_info.applescript| Dumps UUID and other info about every photo in Photos.app to a test file; see check_uuid.py|
|dump_photo_info.scpt| Compiled version of dump_photo_info.applescript|
|gen_face_test_data.py| Generate test data for test_faceinfo.py|
|generate_search_info_test_data.py | Create the test data needed for test_search_info_10_15_7.py|
|get_photo_info.applescript| Displays UUID and other info about selected photos, useful for debugging|
|get_photo_info.scpt| Compiled version of above|
|write_uuid_to_file.applescript| Writes the UUIDs of selected images in Photos to a text file; can generate input for --uuid-from-file|

View File

@@ -0,0 +1,20 @@
-- Copies UUID of selected photo to the clipboard, if more than one selection, copies uuid from the last item
-- Useful for debugging with osxphotos
tell application "Photos"
set uuid to ""
set theSelection to selection
repeat with theItem in theSelection
set uuid to ((id of theItem) as text)
set oldDelimiter to AppleScript's text item delimiters
set AppleScript's text item delimiters to "/"
set theTextItems to every text item of uuid
set uuid to first item of theTextItems
set AppleScript's text item delimiters to oldDelimiter
end repeat
set the clipboard to uuid
end tell