Updated photokit code to work with raw+jpeg

This commit is contained in:
Rhet Turnbull
2021-06-29 17:46:40 -07:00
parent 08147e91d9
commit d2dcbaaec4
3 changed files with 246 additions and 160 deletions

View File

@@ -1,3 +1,3 @@
""" version info """ """ version info """
__version__ = "0.42.48" __version__ = "0.42.49"

View File

@@ -65,7 +65,7 @@ MIN_SLEEP = 0.015
### utility functions ### utility functions
def NSURL_to_path(url): def NSURL_to_path(url):
""" Convert URL string as represented by NSURL to a path string """ """Convert URL string as represented by NSURL to a path string"""
nsurl = Foundation.NSURL.alloc().initWithString_( nsurl = Foundation.NSURL.alloc().initWithString_(
Foundation.NSString.alloc().initWithString_(str(url)) Foundation.NSString.alloc().initWithString_(str(url))
) )
@@ -75,7 +75,7 @@ def NSURL_to_path(url):
def path_to_NSURL(path): def path_to_NSURL(path):
""" Convert path string to NSURL """ """Convert path string to NSURL"""
pathstr = Foundation.NSString.alloc().initWithString_(str(path)) pathstr = Foundation.NSString.alloc().initWithString_(str(path))
url = Foundation.NSURL.fileURLWithPath_(pathstr) url = Foundation.NSURL.fileURLWithPath_(pathstr)
pathstr.dealloc() pathstr.dealloc()
@@ -83,10 +83,10 @@ def path_to_NSURL(path):
def check_photokit_authorization(): def check_photokit_authorization():
""" Check authorization to use user's Photos Library """Check authorization to use user's Photos Library
Returns: Returns:
True if user has authorized access to the Photos library, otherwise False True if user has authorized access to the Photos library, otherwise False
""" """
auth_status = Photos.PHPhotoLibrary.authorizationStatus() auth_status = Photos.PHPhotoLibrary.authorizationStatus()
@@ -94,7 +94,7 @@ def check_photokit_authorization():
def request_photokit_authorization(): def request_photokit_authorization():
""" Request authorization to user's Photos Library """Request authorization to user's Photos Library
Returns: Returns:
authorization status authorization status
@@ -136,39 +136,39 @@ def request_photokit_authorization():
### exceptions ### exceptions
class PhotoKitError(Exception): class PhotoKitError(Exception):
"""Base class for exceptions in this module. """ """Base class for exceptions in this module."""
pass pass
class PhotoKitFetchFailed(PhotoKitError): class PhotoKitFetchFailed(PhotoKitError):
"""Exception raised for errors in the input. """ """Exception raised for errors in the input."""
pass pass
class PhotoKitAuthError(PhotoKitError): class PhotoKitAuthError(PhotoKitError):
"""Exception raised if unable to authorize use of PhotoKit. """ """Exception raised if unable to authorize use of PhotoKit."""
pass pass
class PhotoKitExportError(PhotoKitError): class PhotoKitExportError(PhotoKitError):
"""Exception raised if unable to export asset. """ """Exception raised if unable to export asset."""
pass pass
class PhotoKitMediaTypeError(PhotoKitError): class PhotoKitMediaTypeError(PhotoKitError):
""" Exception raised if an unknown mediaType() is encountered """ """Exception raised if an unknown mediaType() is encountered"""
pass pass
### helper classes ### helper classes
class ImageData: class ImageData:
""" Simple class to hold the data passed to the handler for """Simple class to hold the data passed to the handler for
requestImageDataAndOrientationForAsset_options_resultHandler_ requestImageDataAndOrientationForAsset_options_resultHandler_
""" """
def __init__( def __init__(
@@ -182,8 +182,7 @@ class ImageData:
class AVAssetData: class AVAssetData:
""" Simple class to hold the data passed to the handler for """Simple class to hold the data passed to the handler for"""
"""
def __init__(self): def __init__(self):
self.asset = None self.asset = None
@@ -193,7 +192,7 @@ class AVAssetData:
class PHAssetResourceData: class PHAssetResourceData:
""" Simple class to hold data from """Simple class to hold data from
requestDataForAssetResource:options:dataReceivedHandler:completionHandler: requestDataForAssetResource:options:dataReceivedHandler:completionHandler:
""" """
@@ -212,8 +211,8 @@ class PHAssetResourceData:
class PhotoKitNotificationDelegate(NSObject): class PhotoKitNotificationDelegate(NSObject):
""" Handles notifications from NotificationCenter; """Handles notifications from NotificationCenter;
used with asynchronous PhotoKit requests to stop event loop when complete used with asynchronous PhotoKit requests to stop event loop when complete
""" """
def liveNotification_(self, note): def liveNotification_(self, note):
@@ -227,11 +226,11 @@ class PhotoKitNotificationDelegate(NSObject):
### main class implementation ### main class implementation
class PhotoAsset: class PhotoAsset:
""" PhotoKit PHAsset representation """ """PhotoKit PHAsset representation"""
def __init__(self, manager, phasset): def __init__(self, manager, phasset):
""" Return a PhotoAsset object """Return a PhotoAsset object
Args: Args:
manager = ImageManager object manager = ImageManager object
phasset: a PHAsset object phasset: a PHAsset object
@@ -242,32 +241,32 @@ class PhotoAsset:
@property @property
def phasset(self): def phasset(self):
""" Return PHAsset instance """ """Return PHAsset instance"""
return self._phasset return self._phasset
@property @property
def uuid(self): def uuid(self):
""" Return local identifier (UUID) of PHAsset """ """Return local identifier (UUID) of PHAsset"""
return self._phasset.localIdentifier() return self._phasset.localIdentifier()
@property @property
def isphoto(self): def isphoto(self):
""" Return True if asset is photo (image), otherwise False """ """Return True if asset is photo (image), otherwise False"""
return self.media_type == Photos.PHAssetMediaTypeImage return self.media_type == Photos.PHAssetMediaTypeImage
@property @property
def ismovie(self): def ismovie(self):
""" Return True if asset is movie (video), otherwise False """ """Return True if asset is movie (video), otherwise False"""
return self.media_type == Photos.PHAssetMediaTypeVideo return self.media_type == Photos.PHAssetMediaTypeVideo
@property @property
def isaudio(self): def isaudio(self):
""" Return True if asset is audio, otherwise False """ """Return True if asset is audio, otherwise False"""
return self.media_type == Photos.PHAssetMediaTypeAudio return self.media_type == Photos.PHAssetMediaTypeAudio
@property @property
def original_filename(self): def original_filename(self):
""" Return original filename asset was imported with """ """Return original filename asset was imported with"""
resources = self._resources() resources = self._resources()
for resource in resources: for resource in resources:
if ( if (
@@ -279,10 +278,22 @@ class PhotoAsset:
return resource.originalFilename() return resource.originalFilename()
return None return None
@property
def raw_filename(self):
"""Return RAW filename for RAW+JPEG photos or None if no RAW asset"""
resources = self._resources()
for resource in resources:
if (
self.isphoto
and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto
):
return resource.originalFilename()
return None
@property @property
def hasadjustments(self): def hasadjustments(self):
""" Check to see if a PHAsset has adjustment data associated with it """Check to see if a PHAsset has adjustment data associated with it
Returns False if no adjustments, True if any adjustments """ Returns False if no adjustments, True if any adjustments"""
# reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc # reference: https://developer.apple.com/documentation/photokit/phassetresource/1623988-assetresourcesforasset?language=objc
@@ -299,112 +310,112 @@ class PhotoAsset:
@property @property
def media_type(self): def media_type(self):
""" media type such as image or video """ """media type such as image or video"""
return self.phasset.mediaType() return self.phasset.mediaType()
@property @property
def media_subtypes(self): def media_subtypes(self):
""" media subtype """ """media subtype"""
return self.phasset.mediaSubtypes() return self.phasset.mediaSubtypes()
@property @property
def panorama(self): def panorama(self):
""" return True if asset is panorama, otherwise False """ """return True if asset is panorama, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoPanorama) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoPanorama)
@property @property
def hdr(self): def hdr(self):
""" return True if asset is HDR, otherwise False """ """return True if asset is HDR, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoHDR) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoHDR)
@property @property
def screenshot(self): def screenshot(self):
""" return True if asset is screenshot, otherwise False """ """return True if asset is screenshot, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoScreenshot) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoScreenshot)
@property @property
def live(self): def live(self):
""" return True if asset is live, otherwise False """ """return True if asset is live, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoLive) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoLive)
@property @property
def streamed(self): def streamed(self):
""" return True if asset is streamed video, otherwise False """ """return True if asset is streamed video, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoStreamed) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoStreamed)
@property @property
def slow_mo(self): def slow_mo(self):
""" return True if asset is slow motion (high frame rate) video, otherwise False """ """return True if asset is slow motion (high frame rate) video, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoHighFrameRate) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoHighFrameRate)
@property @property
def time_lapse(self): def time_lapse(self):
""" return True if asset is time lapse video, otherwise False """ """return True if asset is time lapse video, otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoTimelapse) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypeVideoTimelapse)
@property @property
def portrait(self): def portrait(self):
""" return True if asset is portrait (depth effect), otherwise False """ """return True if asset is portrait (depth effect), otherwise False"""
return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoDepthEffect) return bool(self.media_subtypes & Photos.PHAssetMediaSubtypePhotoDepthEffect)
@property @property
def burstid(self): def burstid(self):
""" return burstIdentifier of image if image is burst photo otherwise None """ """return burstIdentifier of image if image is burst photo otherwise None"""
return self.phasset.burstIdentifier() return self.phasset.burstIdentifier()
@property @property
def burst(self): def burst(self):
""" return True if image is burst otherwise False """ """return True if image is burst otherwise False"""
return bool(self.burstid) return bool(self.burstid)
@property @property
def source_type(self): def source_type(self):
""" the means by which the asset entered the user's library """ """the means by which the asset entered the user's library"""
return self.phasset.sourceType() return self.phasset.sourceType()
@property @property
def pixel_width(self): def pixel_width(self):
""" width in pixels """ """width in pixels"""
return self.phasset.pixelWidth() return self.phasset.pixelWidth()
@property @property
def pixel_height(self): def pixel_height(self):
""" height in pixels """ """height in pixels"""
return self.phasset.pixelHeight() return self.phasset.pixelHeight()
@property @property
def date(self): def date(self):
""" date asset was created """ """date asset was created"""
return self.phasset.creationDate() return self.phasset.creationDate()
@property @property
def date_modified(self): def date_modified(self):
""" date asset was modified """ """date asset was modified"""
return self.phasset.modificationDate() return self.phasset.modificationDate()
@property @property
def location(self): def location(self):
""" location of the asset """ """location of the asset"""
return self.phasset.location() return self.phasset.location()
@property @property
def duration(self): def duration(self):
""" duration of the asset """ """duration of the asset"""
return self.phasset.duration() return self.phasset.duration()
@property @property
def favorite(self): def favorite(self):
""" True if asset is favorite, otherwise False """ """True if asset is favorite, otherwise False"""
return self.phasset.isFavorite() return self.phasset.isFavorite()
@property @property
def hidden(self): def hidden(self):
""" True if asset is hidden, otherwise False """ """True if asset is hidden, otherwise False"""
return self.phasset.isHidden() return self.phasset.isHidden()
def metadata(self, version=PHOTOS_VERSION_CURRENT): def metadata(self, version=PHOTOS_VERSION_CURRENT):
""" Return dict of asset metadata """Return dict of asset metadata
Args: Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
""" """
@@ -412,17 +423,28 @@ class PhotoAsset:
return imagedata.metadata return imagedata.metadata
def uti(self, version=PHOTOS_VERSION_CURRENT): def uti(self, version=PHOTOS_VERSION_CURRENT):
""" Return UTI of asset """Return UTI of asset
Args: Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
""" """
imagedata = self._request_image_data(version=version) imagedata = self._request_image_data(version=version)
return imagedata.uti return imagedata.uti
def uti_raw(self):
"""Return UTI of RAW component of RAW+JPEG pair"""
resources = self._resources()
for resource in resources:
if (
self.isphoto
and resource.type() == Photos.PHAssetResourceTypeAlternatePhoto
):
return resource.uniformTypeIdentifier()
return None
def url(self, version=PHOTOS_VERSION_CURRENT): def url(self, version=PHOTOS_VERSION_CURRENT):
""" Return URL of asset """Return URL of asset
Args: Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
""" """
@@ -430,8 +452,8 @@ class PhotoAsset:
return str(imagedata.info["PHImageFileURLKey"]) return str(imagedata.info["PHImageFileURLKey"])
def path(self, version=PHOTOS_VERSION_CURRENT): def path(self, version=PHOTOS_VERSION_CURRENT):
""" Return path of asset """Return path of asset
Args: Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
""" """
@@ -440,8 +462,8 @@ class PhotoAsset:
return url.fileSystemRepresentation().decode("utf-8") return url.fileSystemRepresentation().decode("utf-8")
def orientation(self, version=PHOTOS_VERSION_CURRENT): def orientation(self, version=PHOTOS_VERSION_CURRENT):
""" Return orientation of asset """Return orientation of asset
Args: Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
""" """
@@ -450,8 +472,8 @@ class PhotoAsset:
@property @property
def degraded(self, version=PHOTOS_VERSION_CURRENT): def degraded(self, version=PHOTOS_VERSION_CURRENT):
""" Return True if asset is degraded version """Return True if asset is degraded version
Args: Args:
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
""" """
@@ -459,15 +481,21 @@ class PhotoAsset:
return imagedata.info["PHImageResultIsDegradedKey"] return imagedata.info["PHImageResultIsDegradedKey"]
def export( def export(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False self,
dest,
filename=None,
version=PHOTOS_VERSION_CURRENT,
overwrite=False,
raw=False,
): ):
""" Export image to path """Export image to path
Args: Args:
dest: str, path to destination directory dest: str, path to destination directory
filename: str, optional name of exported file; if not provided, defaults to asset's original filename filename: str, optional name of exported file; if not provided, defaults to asset's original filename
version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT) version: which version of image (PHOTOS_VERSION_ORIGINAL or PHOTOS_VERSION_CURRENT)
overwrite: bool, if True, overwrites destination file if it already exists; default is False overwrite: bool, if True, overwrites destination file if it already exists; default is False
raw: bool, if True, export RAW component of RAW+JPEG pair, default is False
Returns: Returns:
List of path to exported image(s) List of path to exported image(s)
@@ -492,11 +520,28 @@ class PhotoAsset:
output_file = None output_file = None
if self.isphoto: if self.isphoto:
imagedata = self._request_image_data(version=version) # will hold exported image data and needs to be cleaned up at end
if not imagedata.image_data: imagedata = None
raise PhotoKitExportError("Could not get image data") if raw:
# export the raw component
ext = get_preferred_uti_extension(imagedata.uti) resources = self._resources()
for resource in resources:
if resource.type() == Photos.PHAssetResourceTypeAlternatePhoto:
data = self._request_resource_data(resource)
ext = pathlib.Path(self.raw_filename).suffix[1:]
break
else:
raise PhotoKitExportError(
"Could not get image data for RAW photo"
)
else:
# TODO: if user has selected use RAW as original, this returns the RAW
# can get the jpeg with resource.type() == Photos.PHAssetResourceTypePhoto
imagedata = self._request_image_data(version=version)
if not imagedata.image_data:
raise PhotoKitExportError("Could not get image data")
ext = get_preferred_uti_extension(imagedata.uti)
data = imagedata.image_data
output_file = dest / f"{filename.stem}.{ext}" output_file = dest / f"{filename.stem}.{ext}"
@@ -504,7 +549,9 @@ class PhotoAsset:
output_file = pathlib.Path(increment_filename(output_file)) output_file = pathlib.Path(increment_filename(output_file))
with open(output_file, "wb") as fd: with open(output_file, "wb") as fd:
fd.write(imagedata.image_data) fd.write(data)
if imagedata:
del imagedata del imagedata
elif self.ismovie: elif self.ismovie:
videodata = self._request_video_data(version=version) videodata = self._request_video_data(version=version)
@@ -526,14 +573,14 @@ class PhotoAsset:
return [str(output_file)] return [str(output_file)]
def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL): def _request_image_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" Request image data and metadata for self._phasset """Request image data and metadata for self._phasset
Args: Args:
version: which version to request version: which version to request
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_CURRENT, request current version with all edits PHOTOS_VERSION_CURRENT, request current version with all edits
PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version
Returns: Returns:
ImageData instance ImageData instance
@@ -563,8 +610,8 @@ class PhotoAsset:
event = threading.Event() event = threading.Event()
def handler(imageData, dataUTI, orientation, info): def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ """result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """ all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
nonlocal requestdata nonlocal requestdata
@@ -594,19 +641,63 @@ class PhotoAsset:
del requestdata del requestdata
return data return data
def _request_resource_data(self, resource):
"""Request asset resource data (either photo or video component)
Args:
resource: PHAssetResource to request
Raises:
"""
with objc.autorelease_pool():
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
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)"""
nonlocal requestdata
requestdata.data += data
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
)
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 _make_result_handle_(self, data): def _make_result_handle_(self, data):
""" Make handler function and threading event to use with """Make handler function and threading event to use with
requestImageDataAndOrientationForAsset_options_resultHandler_ requestImageDataAndOrientationForAsset_options_resultHandler_
data: Fetchdata class to hold resulting metadata data: Fetchdata class to hold resulting metadata
returns: handler function, threading.Event() instance returns: handler function, threading.Event() instance
Following call to requestImageDataAndOrientationForAsset_options_resultHandler_, Following call to requestImageDataAndOrientationForAsset_options_resultHandler_,
data will hold data from the fetch """ data will hold data from the fetch"""
event = threading.Event() event = threading.Event()
def handler(imageData, dataUTI, orientation, info): def handler(imageData, dataUTI, orientation, info):
""" result handler for requestImageDataAndOrientationForAsset_options_resultHandler_ """result handler for requestImageDataAndOrientationForAsset_options_resultHandler_
all returned by the request is set as properties of nonlocal data (Fetchdata object) """ all returned by the request is set as properties of nonlocal data (Fetchdata object)"""
nonlocal data nonlocal data
@@ -627,14 +718,14 @@ class PhotoAsset:
return handler, event return handler, event
def _resources(self): def _resources(self):
""" Return list of PHAssetResource for object """ """Return list of PHAssetResource for object"""
resources = Photos.PHAssetResource.assetResourcesForAsset_(self.phasset) resources = Photos.PHAssetResource.assetResourcesForAsset_(self.phasset)
return [resources.objectAtIndex_(idx) for idx in range(resources.count())] return [resources.objectAtIndex_(idx) for idx in range(resources.count())]
class SlowMoVideoExporter(NSObject): class SlowMoVideoExporter(NSObject):
def initWithAVAsset_path_(self, avasset, path): def initWithAVAsset_path_(self, avasset, path):
""" init helper class for exporting slow-mo video """init helper class for exporting slow-mo video
Args: Args:
avasset: AVAsset avasset: AVAsset
@@ -649,15 +740,17 @@ class SlowMoVideoExporter(NSObject):
return self return self
def exportSlowMoVideo(self): def exportSlowMoVideo(self):
""" export slow-mo video with AVAssetExportSession """export slow-mo video with AVAssetExportSession
Returns: Returns:
path to exported file path to exported file
""" """
with objc.autorelease_pool(): with objc.autorelease_pool():
exporter = AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_( exporter = (
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality AVFoundation.AVAssetExportSession.alloc().initWithAsset_presetName_(
self.avasset, AVFoundation.AVAssetExportPresetHighestQuality
)
) )
exporter.setOutputURL_(self.url) exporter.setOutputURL_(self.url)
exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie) exporter.setOutputFileType_(AVFoundation.AVFileTypeQuickTimeMovie)
@@ -666,7 +759,7 @@ class SlowMoVideoExporter(NSObject):
self.done = False self.done = False
def handler(): def handler():
""" result handler for exportAsynchronouslyWithCompletionHandler """ """result handler for exportAsynchronouslyWithCompletionHandler"""
self.done = True self.done = True
exporter.exportAsynchronouslyWithCompletionHandler_(handler) exporter.exportAsynchronouslyWithCompletionHandler_(handler)
@@ -700,7 +793,7 @@ class SlowMoVideoExporter(NSObject):
class VideoAsset(PhotoAsset): class VideoAsset(PhotoAsset):
""" PhotoKit PHAsset representation of video asset """ """PhotoKit PHAsset representation of video asset"""
# TODO: doesn't work for slow-mo videos # TODO: doesn't work for slow-mo videos
# see https://stackoverflow.com/questions/26152396/how-to-access-nsdata-nsurl-of-slow-motion-videos-using-photokit # see https://stackoverflow.com/questions/26152396/how-to-access-nsdata-nsurl-of-slow-motion-videos-using-photokit
@@ -710,7 +803,7 @@ class VideoAsset(PhotoAsset):
def export( def export(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
): ):
""" Export video to path """Export video to path
Args: Args:
dest: str, path to destination directory dest: str, path to destination directory
@@ -766,7 +859,7 @@ class VideoAsset(PhotoAsset):
def _export_slow_mo( def _export_slow_mo(
self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False self, dest, filename=None, version=PHOTOS_VERSION_CURRENT, overwrite=False
): ):
""" Export slow-motion video to path """Export slow-motion video to path
Args: Args:
dest: str, path to destination directory dest: str, path to destination directory
@@ -815,14 +908,14 @@ class VideoAsset(PhotoAsset):
# todo: rewrite this with NotificationCenter and App event loop? # todo: rewrite this with NotificationCenter and App event loop?
def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL): def _request_video_data(self, version=PHOTOS_VERSION_ORIGINAL):
""" Request video data for self._phasset """Request video data for self._phasset
Args: Args:
version: which version to request version: which version to request
PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version PHOTOS_VERSION_ORIGINAL (default), request original highest fidelity version
PHOTOS_VERSION_CURRENT, request current version with all edits PHOTOS_VERSION_CURRENT, request current version with all edits
PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version PHOTOS_VERSION_UNADJUSTED, request highest quality unadjusted version
Raises: Raises:
ValueError if passed invalid value for version ValueError if passed invalid value for version
""" """
@@ -844,7 +937,7 @@ class VideoAsset(PhotoAsset):
event = threading.Event() event = threading.Event()
def handler(asset, audiomix, info): def handler(asset, audiomix, info):
""" result handler for requestAVAssetForVideo:asset options:options resultHandler """ """result handler for requestAVAssetForVideo:asset options:options resultHandler"""
nonlocal requestdata nonlocal requestdata
requestdata.asset = asset requestdata.asset = asset
@@ -866,8 +959,8 @@ class VideoAsset(PhotoAsset):
class LivePhotoRequest(NSObject): class LivePhotoRequest(NSObject):
""" Manage requests for live photo assets """Manage requests for live photo assets
See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc See: https://developer.apple.com/documentation/photokit/phimagemanager/1616984-requestlivephotoforasset?language=objc
""" """
def initWithManager_Asset_(self, manager, asset): def initWithManager_Asset_(self, manager, asset):
@@ -880,7 +973,7 @@ class LivePhotoRequest(NSObject):
return self return self
def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT): def requestLivePhotoResources(self, version=PHOTOS_VERSION_CURRENT):
""" return the photos and video components of a live video as [PHAssetResource] """ """return the photos and video components of a live video as [PHAssetResource]"""
with objc.autorelease_pool(): with objc.autorelease_pool():
options = Photos.PHLivePhotoRequestOptions.alloc().init() options = Photos.PHLivePhotoRequestOptions.alloc().init()
@@ -898,7 +991,7 @@ class LivePhotoRequest(NSObject):
self.live_photo = None self.live_photo = None
def handler(result, info): def handler(result, info):
""" result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler: """ """result handler for requestLivePhotoForAsset:targetSize:contentMode:options:resultHandler:"""
if not info["PHImageResultIsDegradedKey"]: if not info["PHImageResultIsDegradedKey"]:
self.live_photo = result self.live_photo = result
self.info = info self.info = info
@@ -940,7 +1033,7 @@ class LivePhotoRequest(NSObject):
class LivePhotoAsset(PhotoAsset): class LivePhotoAsset(PhotoAsset):
""" Represents a live photo """ """Represents a live photo"""
def export( def export(
self, self,
@@ -951,7 +1044,7 @@ class LivePhotoAsset(PhotoAsset):
photo=True, photo=True,
video=True, video=True,
): ):
""" Export image to path """Export image to path
Args: Args:
dest: str, path to destination directory dest: str, path to destination directory
@@ -1062,50 +1155,6 @@ class LivePhotoAsset(PhotoAsset):
request.dealloc() request.dealloc()
return exported return exported
def _request_resource_data(self, resource):
""" Request asset resource data (either photo or video component)
Args:
resource: PHAssetResource to request
Raises:
"""
with objc.autorelease_pool():
resource_manager = Photos.PHAssetResourceManager.defaultManager()
options = Photos.PHAssetResourceRequestOptions.alloc().init()
options.setNetworkAccessAllowed_(True)
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) """
nonlocal requestdata
requestdata.data += data
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
)
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): # def request_image_data(self, version=PHOTOS_VERSION_CURRENT):
# # Returns an NSImage which isn't overly useful # # Returns an NSImage which isn't overly useful
# # https://developer.apple.com/documentation/photokit/phimagemanager/1616964-requestimageforasset?language=objc # # https://developer.apple.com/documentation/photokit/phimagemanager/1616964-requestimageforasset?language=objc
@@ -1143,12 +1192,12 @@ class LivePhotoAsset(PhotoAsset):
class PhotoLibrary: class PhotoLibrary:
""" Interface to PhotoKit PHImageManager and PHPhotoLibrary """ """Interface to PhotoKit PHImageManager and PHPhotoLibrary"""
def __init__(self): def __init__(self):
""" Initialize ImageManager instance. Requests authorization to use the """Initialize ImageManager instance. Requests authorization to use the
Photos library if authorization has not already been granted. Photos library if authorization has not already been granted.
Raises: Raises:
PhotoKitAuthError if unable to authorize access to PhotoKit PhotoKitAuthError if unable to authorize access to PhotoKit
""" """
@@ -1167,7 +1216,7 @@ class PhotoLibrary:
self._phimagemanager = Photos.PHCachingImageManager.defaultManager() self._phimagemanager = Photos.PHCachingImageManager.defaultManager()
def request_authorization(self): def request_authorization(self):
""" Request authorization to user's Photos Library """Request authorization to user's Photos Library
Returns: Returns:
authorization status authorization status
@@ -1177,7 +1226,7 @@ class PhotoLibrary:
return self.auth_status return self.auth_status
def fetch_uuid_list(self, uuid_list): def fetch_uuid_list(self, uuid_list):
""" fetch PHAssets with uuids in uuid_list """fetch PHAssets with uuids in uuid_list
Args: Args:
uuid_list: list of str (UUID of image assets to fetch) uuid_list: list of str (UUID of image assets to fetch)
@@ -1206,7 +1255,7 @@ class PhotoLibrary:
) )
def fetch_uuid(self, uuid): def fetch_uuid(self, uuid):
""" fetch PHAsset with uuid = uuid """fetch PHAsset with uuid = uuid
Args: Args:
uuid: str; UUID of image asset to fetch uuid: str; UUID of image asset to fetch
@@ -1224,8 +1273,8 @@ class PhotoLibrary:
raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}") raise PhotoKitFetchFailed(f"Fetch did not return result for uuid {uuid}")
def fetch_burst_uuid(self, burstid, all=False): def fetch_burst_uuid(self, burstid, all=False):
""" fetch PhotoAssets with burst ID = burstid """fetch PhotoAssets with burst ID = burstid
Args: Args:
burstid: str, burst UUID burstid: str, burst UUID
all: return all burst assets; if False returns only those selected by the user (including the "key photo" even if user hasn't manually selected it) all: return all burst assets; if False returns only those selected by the user (including the "key photo" even if user hasn't manually selected it)
@@ -1254,11 +1303,11 @@ class PhotoLibrary:
) )
def _asset_factory(self, phasset): def _asset_factory(self, phasset):
""" creates a PhotoAsset, VideoAsset, or LivePhotoAsset """creates a PhotoAsset, VideoAsset, or LivePhotoAsset
Args: Args:
phasset: PHAsset object phasset: PHAsset object
Returns: Returns:
PhotoAsset, VideoAsset, or LivePhotoAsset depending on type of PHAsset PhotoAsset, VideoAsset, or LivePhotoAsset depending on type of PHAsset
""" """

View File

@@ -57,6 +57,14 @@ UUID_DICT = {
"burst_selected": 4, "burst_selected": 4,
"burst_all": 5, "burst_all": 5,
}, },
"raw+jpeg": {
"uuid": "E3DD04AF-CB65-4D9B-BB79-FF4C955533DB",
"filename": "IMG_1994.JPG",
"raw_filename": "IMG_1994.CR2",
"unadjusted_size": 16128420,
"uti_raw": "com.canon.cr2-raw-image",
"uti": "public.jpeg",
},
} }
@@ -78,10 +86,23 @@ def test_plain_photo():
lib = PhotoLibrary() lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid) photo = lib.fetch_uuid(uuid)
assert photo.original_filename == filename assert photo.original_filename == filename
assert photo.raw_filename is None
assert photo.isphoto assert photo.isphoto
assert not photo.ismovie assert not photo.ismovie
def test_raw_plus_jpeg():
"""test RAW+JPEG"""
uuid = UUID_DICT["raw+jpeg"]["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
assert photo.original_filename == UUID_DICT["raw+jpeg"]["filename"]
assert photo.raw_filename == UUID_DICT["raw+jpeg"]["raw_filename"]
assert photo.uti_raw() == UUID_DICT["raw+jpeg"]["uti_raw"]
assert photo.uti() == UUID_DICT["raw+jpeg"]["uti"]
def test_hdr(): def test_hdr():
"""test hdr""" """test hdr"""
uuid = UUID_DICT["hdr"]["uuid"] uuid = UUID_DICT["hdr"]["uuid"]
@@ -196,6 +217,22 @@ def test_export_photo_current():
assert export_path.stat().st_size == test_dict["adjusted_size"] assert export_path.stat().st_size == test_dict["adjusted_size"]
def test_export_photo_raw():
"""test PhotoAsset.export for raw component"""
test_dict = UUID_DICT["raw+jpeg"]
uuid = test_dict["uuid"]
lib = PhotoLibrary()
photo = lib.fetch_uuid(uuid)
with tempfile.TemporaryDirectory(prefix="photokit_test") as tempdir:
export_path = photo.export(tempdir, raw=True)
export_path = pathlib.Path(export_path[0])
assert export_path.is_file()
filename = test_dict["raw_filename"]
assert export_path.stem == pathlib.Path(filename).stem
assert export_path.stat().st_size == test_dict["unadjusted_size"]
### VideoAsset ### VideoAsset