Added live photo support for both Photos 4 & 5

This commit is contained in:
Rhet Turnbull
2020-01-04 09:07:23 -08:00
parent 9fb05e4dd1
commit d5eaff02f2
3 changed files with 174 additions and 41 deletions

View File

@@ -50,6 +50,8 @@
- [`uti`](#uti) - [`uti`](#uti)
- [`burst`](#burst) - [`burst`](#burst)
- [`burst_photos`](#burst_photos) - [`burst_photos`](#burst_photos)
- [`live_photo`](#live_photo)
- [`path_live_photo`](#path_live_photo)
- [`json()`](#json) - [`json()`](#json)
- [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse) - [`export(dest, *filename, edited=False, overwrite=False, increment=True, sidecar=False)`](#exportdest-filename-editedfalse-overwritefalse-incrementtrue-sidecarfalse)
+ [Utility Functions](#utility-functions) + [Utility Functions](#utility-functions)
@@ -522,13 +524,15 @@ Returns a list of albums the photo is contained in
Returns a list of the names of the persons in the photo Returns a list of the names of the persons in the photo
#### `path` #### `path`
Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing)) Returns the absolute path to the photo on disk as a string. **Note**: this returns the path to the *original* unedited file (see [hasadjustments](#hasadjustments)). If the file is missing on disk, path=`None` (see [ismissing](#ismissing)).
#### `path_edited` #### `path_edited`
Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also [path](#path) and [hasadjustments](#hasadjustments). Returns the absolute path to the edited photo on disk as a string. If the photo has not been edited, returns `None`. See also [path](#path) and [hasadjustments](#hasadjustments).
**Note**: will also return None if the edited photo is missing on disk.
#### `ismissing` #### `ismissing`
Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted. **Note**: this status is set by Photos and osxphotos does not verify that the file path returned by `path` actually exists. It merely reports what Photos has stored in the library database. Returns `True` if the original image file is missing on disk, otherwise `False`. This can occur if the file has been uploaded to iCloud but not yet downloaded to the local library or if the file was deleted or imported from a disk that has been unmounted and user hasn't enabled "Copy items to the Photos library" in Photos preferences. **Note**: this status is computed based on data in the Photos library and `ismissing` does not verify if the photo is actually missing. See also [path](#path).
#### `hasadjustments` #### `hasadjustments`
Returns `True` if the picture has been edited, otherwise `False` Returns `True` if the picture has been edited, otherwise `False`
@@ -586,6 +590,14 @@ IMG_9854.JPG
IMG_9855.JPG IMG_9855.JPG
``` ```
#### `live_photo`
Returns True if photo is an Apple live photo (ie. it has an associated "live" video component), otherwise returns False. See [path_live_photo](#path_live_photo).
#### `path_live_photo`
Returns the path to the live video component of a [live photo](#live_photo). If photo is not a live photo, returns None.
**Note**: will also return None if the live video component is missing on disk. It's possible that the original photo may be on disk ([ismissing](#ismissing)==False) but the video component is missing, likely because it has not been downloaded from iCloud.
#### `json()` #### `json()`
Returns a JSON representation of all photo info Returns a JSON representation of all photo info

View File

@@ -115,6 +115,9 @@ class PhotoInfo:
""" absolute path on disk of the edited picture """ """ absolute path on disk of the edited picture """
""" None if photo has not been edited """ """ None if photo has not been edited """
# TODO: break this code into a _path_edited_4 and _path_edited_5
# version to simplify the big if/then; same for path_live_photo
photopath = None photopath = None
if self._db._db_version < _PHOTOS_5_VERSION: if self._db._db_version < _PHOTOS_5_VERSION:
@@ -148,13 +151,14 @@ class PhotoInfo:
) )
if not os.path.isfile(photopath): if not os.path.isfile(photopath):
logging.warning( logging.warning(
f"edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist" f"MISSING PATH: edited file for UUID {self._uuid} should be at {photopath} but does not appear to exist"
) )
photopath = None photopath = None
else: else:
logging.warning( logging.warning(
f"{self.uuid} hasAdjustments but edit_resource_id is None" f"{self.uuid} hasAdjustments but edit_resource_id is None"
) )
photopath = None
else: else:
photopath = None photopath = None
@@ -231,7 +235,6 @@ class PhotoInfo:
@property @property
def title(self): def title(self):
""" name / title of picture """ """ name / title of picture """
# TODO: Update documentation and tests to use title
return self._info["name"] return self._info["name"]
@property @property
@@ -333,11 +336,7 @@ class PhotoInfo:
@property @property
def live_photo(self): def live_photo(self):
""" Returns True if photo is a live photo, otherwise False """ """ Returns True if photo is a live photo, otherwise False """
# TODO: fixme for Photos 4 return self._info["live_photo"]
if self._db._db_version >= _PHOTOS_5_VERSION:
return self._info["live_photo"]
else:
return None
@property @property
def path_live_photo(self): def path_live_photo(self):
@@ -346,20 +345,49 @@ class PhotoInfo:
If photo is missing, returns None """ If photo is missing, returns None """
photopath = None photopath = None
# TODO: fixme for Photos 4
if self._db._db_version < _PHOTOS_5_VERSION: if self._db._db_version < _PHOTOS_5_VERSION:
photopath = None if self.live_photo and not self.ismissing:
live_model_id = self._info["live_model_id"]
if live_model_id == None:
logging.debug(f"missing live_model_id: {self._uuid}")
photopath = None
else:
folder_id, file_id = _get_resource_loc(live_model_id)
library_path = self._db.library_path
photopath = os.path.join(
library_path,
"resources",
"media",
"master",
folder_id,
"00",
f"jpegvideocomplement_{file_id}.mov",
)
if not os.path.isfile(photopath):
# In testing, I've seen occasional missing movie for live photo
# These appear to be valid -- e.g. live component hasn't been downloaded from iCloud
# photos 4 has "isOnDisk" column we could check
# or could do the actual check with "isfile"
# TODO: should this be a warning or debug?
logging.debug(
f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
)
photopath = None
else:
photopath = None
else: else:
# Photos 5
if self.live_photo and not self.ismissing: if self.live_photo and not self.ismissing:
filename = pathlib.Path(self.path) filename = pathlib.Path(self.path)
photopath = filename.parent.joinpath(f"{filename.stem}_3.mov") photopath = filename.parent.joinpath(f"{filename.stem}_3.mov")
if not os.path.isfile(photopath): if not os.path.isfile(photopath):
photopath = None
# In testing, I've seen occasional missing movie for live photo # In testing, I've seen occasional missing movie for live photo
# these appear to be valid -- e.g. video component not yet downloaded from iCloud
# TODO: should this be a warning or debug? # TODO: should this be a warning or debug?
logging.debug( logging.debug(
f"live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist" f"MISSING PATH: live photo path for UUID {self._uuid} should be at {photopath} but does not appear to exist"
) )
photopath = None
else: else:
photopath = None photopath = None

View File

@@ -484,9 +484,10 @@ class PhotosDB:
RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden, RKMaster.isMissing, RKMaster.originalFileName, RKVersion.isFavorite, RKVersion.isHidden,
RKVersion.latitude, RKVersion.longitude, RKVersion.latitude, RKVersion.longitude,
RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI, RKVersion.adjustmentUuid, RKVersion.type, RKMaster.UTI,
RKVersion.burstUuid, RKVersion.burstPickType RKVersion.burstUuid, RKVersion.burstPickType,
from RKVersion, RKMaster where RKVersion.isInTrash = 0 and RKVersion.specialType, RKMaster.modelID
RKVersion.masterUuid = RKMaster.uuid and RKVersion.filename not like '%.pdf' """ FROM RKVersion, RKMaster WHERE RKVersion.isInTrash = 0 AND
RKVersion.masterUuid = RKMaster.uuid AND RKVersion.filename NOT LIKE '%.pdf' """
) )
# order of results # order of results
@@ -515,6 +516,8 @@ class PhotosDB:
# 22 RKMaster.UTI # 22 RKMaster.UTI
# 23 RKVersion.burstUuid # 23 RKVersion.burstUuid
# 24 RKVersion.burstPickType # 24 RKVersion.burstPickType
# 25 RKVersion.specialType
# 26 RKMaster.modelID
for row in c: for row in c:
uuid = row[0] uuid = row[0]
@@ -579,26 +582,56 @@ class PhotosDB:
self._dbphotos[uuid]["burst"] = True self._dbphotos[uuid]["burst"] = True
burst_uuid = row[23] burst_uuid = row[23]
if burst_uuid not in self._dbphotos_burst: if burst_uuid not in self._dbphotos_burst:
self._dbphotos_burst[burst_uuid] = set() self._dbphotos_burst[burst_uuid] = set()
self._dbphotos_burst[burst_uuid].add(uuid) self._dbphotos_burst[burst_uuid].add(uuid)
if row[24] != 2 and row[24] != 4: if row[24] != 2 and row[24] != 4:
self._dbphotos[uuid]["burst_key"] = True # it's a key photo (selected from the burst) self._dbphotos[uuid][
"burst_key"
] = True # it's a key photo (selected from the burst)
else: else:
self._dbphotos[uuid]["burst_key"] = False # it's a burst photo but not one that's selected self._dbphotos[uuid][
"burst_key"
] = False # it's a burst photo but not one that's selected
else: else:
# not a burst photo # not a burst photo
self._dbphotos[uuid]["burst"] = False self._dbphotos[uuid]["burst"] = False
self._dbphotos[uuid]["burst_key"] = None self._dbphotos[uuid]["burst_key"] = None
# get details needed to find path of the edited photos and live photos # RKVersion.specialType
# 1 == panorama
# 2 == slow-mo movie
# 3 == time-lapse movie
# 4 == HDR
# 5 == live photo
# 6 == screenshot
# 8 == HDR live photo
# 9 = portrait
# get info on special types
self._dbphotos[uuid]["specialType"] = row[25]
self._dbphotos[uuid]["masterModelID"] = row[26]
self._dbphotos[uuid]["panorama"] = True if row[25] == 1 else False
self._dbphotos[uuid]["slow_mo"] = True if row[25] == 2 else False
self._dbphotos[uuid]["time_lapse"] = True if row[25] == 3 else False
self._dbphotos[uuid]["hdr"] = (
True if (row[25] == 4 or row[25] == 8) else False
)
self._dbphotos[uuid]["live_photo"] = (
True if (row[25] == 5 or row[25] == 8) else False
)
self._dbphotos[uuid]["screenshot"] = True if row[25] == 6 else False
self._dbphotos[uuid]["portrait"] = True if row[25] == 9 else False
# get details needed to find path of the edited photos
c.execute( c.execute(
"SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId, " """ SELECT RKVersion.uuid, RKVersion.adjustmentUuid, RKModelResource.modelId,
"RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType, " RKModelResource.resourceTag, RKModelResource.UTI, RKVersion.specialType,
"RKModelResource.attachedModelType, RKModelResource.resourceType " RKModelResource.attachedModelType, RKModelResource.resourceType
"FROM RKVersion " FROM RKVersion
"JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId " JOIN RKModelResource on RKModelResource.attachedModelId = RKVersion.modelId
"WHERE RKVersion.isInTrash = 0 " WHERE RKVersion.isInTrash = 0 """
) )
# get info on path of live photo movie
# Order of results: # Order of results:
# 0 RKVersion.uuid # 0 RKVersion.uuid
@@ -610,15 +643,10 @@ class PhotosDB:
# 6 RKModelResource.attachedModelType # 6 RKModelResource.attachedModelType
# 7 RKModelResource.resourceType # 7 RKModelResource.resourceType
# TODO: add live photos
# attachedmodeltype is 2, it's a photo, could be more than one
# attachedmodeltype == 2 could also be movie?
# if 5, it's a facetile
# specialtype = 0 == image, 5 or 8 == live photo movie
for row in c: for row in c:
uuid = row[0] uuid = row[0]
if uuid in self._dbphotos: if uuid in self._dbphotos:
# get info on adjustments (edits)
if self._dbphotos[uuid]["adjustmentUuid"] == row[3]: if self._dbphotos[uuid]["adjustmentUuid"] == row[3]:
if ( if (
row[1] != "UNADJUSTEDNONRAW" row[1] != "UNADJUSTEDNONRAW"
@@ -654,10 +682,50 @@ class PhotosDB:
if uuid in self._dbphotos: if uuid in self._dbphotos:
self._dbphotos[uuid]["adjustmentFormatID"] = row[3] self._dbphotos[uuid]["adjustmentFormatID"] = row[3]
# init any uuids that had no edits
# get details to find path of live photos
c.execute(
""" SELECT
RKVersion.uuid,
RKModelResource.modelId,
RKModelResource.UTI,
RKVersion.specialType,
RKModelResource.attachedModelType,
RKModelResource.resourceType,
RKModelResource.isOnDisk
FROM RKVersion
INNER JOIN RKMaster on RKVersion.masterUuid = RKMaster.uuid
INNER JOIN RKModelResource on RKMaster.modelId = RKModelResource.attachedModelId
WHERE RKModelResource.UTI = 'com.apple.quicktime-movie'
AND RKMaster.isInTrash = 0
AND RKVersion.isInTrash = 0
"""
)
# Order of results
# 0 RKVersion.uuid,
# 1 RKModelResource.modelId,
# 2 RKModelResource.UTI,
# 3 RKVersion.specialType,
# 4 RKModelResource.attachedModelType,
# 5 RKModelResource.resourceType
# 6 RKModelResource.isOnDisk
# TODO: don't think we need most of these fields, remove from SQL query?
for row in c:
uuid = row[0]
if uuid in self._dbphotos:
self._dbphotos[uuid]["live_model_id"] = row[1]
self._dbphotos[uuid]["modeResourceIsOnDisk"] = True if row[6] == 1 else False
# init any uuids that had no edits or live photos
for uuid in self._dbphotos: for uuid in self._dbphotos:
if "edit_resource_id" not in self._dbphotos[uuid]: if "edit_resource_id" not in self._dbphotos[uuid]:
self._dbphotos[uuid]["edit_resource_id"] = None self._dbphotos[uuid]["edit_resource_id"] = None
if "live_model_id" not in self._dbphotos[uuid]:
self._dbphotos[uuid]["live_model_id"] = None
self._dbphotos[uuid]["modeResourceIsOnDisk"] = None
conn.close() conn.close()
@@ -852,7 +920,8 @@ class PhotosDB:
ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER, ZGENERICASSET.ZUNIFORMTYPEIDENTIFIER,
ZGENERICASSET.ZAVALANCHEUUID, ZGENERICASSET.ZAVALANCHEUUID,
ZGENERICASSET.ZAVALANCHEPICKTYPE, ZGENERICASSET.ZAVALANCHEPICKTYPE,
ZGENERICASSET.ZKINDSUBTYPE ZGENERICASSET.ZKINDSUBTYPE,
ZGENERICASSET.ZCUSTOMRENDEREDVALUE
FROM ZGENERICASSET FROM ZGENERICASSET
JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK JOIN ZADDITIONALASSETATTRIBUTES ON ZADDITIONALASSETATTRIBUTES.ZASSET = ZGENERICASSET.Z_PK
WHERE ZGENERICASSET.ZTRASHEDSTATE = 0 WHERE ZGENERICASSET.ZTRASHEDSTATE = 0
@@ -880,8 +949,8 @@ class PhotosDB:
# 18 ZUNIFORMTYPEIDENTIFIER -- UTI # 18 ZUNIFORMTYPEIDENTIFIER -- UTI
# 19 ZGENERICASSET.ZAVALANCHEUUID, -- if not NULL, is burst photo # 19 ZGENERICASSET.ZAVALANCHEUUID, -- if not NULL, is burst photo
# 20 ZGENERICASSET.ZAVALANCHEPICKTYPE -- if not 2, is a selected burst photo # 20 ZGENERICASSET.ZAVALANCHEPICKTYPE -- if not 2, is a selected burst photo
# 21 ZGENERICASSET.ZKINDSUBTYPE # 21 ZGENERICASSET.ZKINDSUBTYPE -- determine if live photos, etc
# 22 ZGENERICASSET.ZCUSTOMRENDEREDVALUE -- determine if HDR photo
for row in c: for row in c:
uuid = row[0] uuid = row[0]
@@ -942,27 +1011,51 @@ class PhotosDB:
# handle burst photos # handle burst photos
# if burst photo, determine whether or not it's a selected burst photo # if burst photo, determine whether or not it's a selected burst photo
# in Photos 5, burstUUID is called avalancheUUID # in Photos 5, burstUUID is called avalancheUUID
info["burstUUID"] = row[19] # avalancheUUID info["burstUUID"] = row[19] # avalancheUUID
info["burstPickType"] = row[20] #avalanchePickType info["burstPickType"] = row[20] # avalanchePickType
if row[19] is not None: if row[19] is not None:
# it's a burst photo # it's a burst photo
info["burst"] = True info["burst"] = True
burst_uuid = row[19] burst_uuid = row[19]
if burst_uuid not in self._dbphotos_burst: if burst_uuid not in self._dbphotos_burst:
self._dbphotos_burst[burst_uuid] = set() self._dbphotos_burst[burst_uuid] = set()
self._dbphotos_burst[burst_uuid].add(uuid) self._dbphotos_burst[burst_uuid].add(uuid)
if row[20] != 2 and row[20] != 4: if row[20] != 2 and row[20] != 4:
info["burst_key"] = True # it's a key photo (selected from the burst) info[
"burst_key"
] = True # it's a key photo (selected from the burst)
else: else:
info["burst_key"] = False # it's a burst photo but not one that's selected info[
"burst_key"
] = False # it's a burst photo but not one that's selected
else: else:
# not a burst photo # not a burst photo
info["burst"] = False info["burst"] = False
info["burst_key"] = None info["burst_key"] = None
# Info on sub-type (live photo, panorama, etc) # Info on sub-type (live photo, panorama, etc)
# ZGENERICASSET.ZKINDSUBTYPE
# 1 == panorama
# 2 == live photo
# 10 = screenshot
# 100 = shared movie (MP4) ??
# 101 = slow-motion video
# 102 = Time lapse video
info["subtype"] = row[21] info["subtype"] = row[21]
info["panorama"] = True if row[21] == 1 else False
info["live_photo"] = True if row[21] == 2 else False info["live_photo"] = True if row[21] == 2 else False
info["screenshot"] = True if row[21] == 10 else False
info["slow_mo"] = True if row[21] == 101 else False
info["time_lapse"] = True if row[21] == 102 else False
# Handle HDR photos and portraits
# ZGENERICASSET.ZCUSTOMRENDEREDVALUE
# 3 = HDR photo
# 4 = non-HDR version of the photo
# 8 = portrait
info["customRenderedValue"] = row[22]
info["hdr"] = True if row[22] == 3 else False
info["portrait"] = True if row[22] == 8 else False
self._dbphotos[uuid] = info self._dbphotos[uuid] = info