Live Photo handling: hide MOV companions, LIVE badge, atomic import/delete
PhonePhoto: add companionDevicePath (MOV path stored on the HEIC). iOS helper: detect Live Photo pairs by IMG_XXXX filename matching. MOVs with a paired HEIC get isLivePhotoMotion=true, sourceFolder='Live Photo'. HEICs with a paired MOV get isLivePhotoStill=true, companionDevicePath set. Grid: Live Photo MOVs appear only in 'Live Photo' folder (Other section). HEIC stills show a LIVE badge. Camera folder stays clean. Import: 'Import Live Photo motion clips' toggle (default off). When on, Copier pulls the companion MOV alongside the HEIC atomically. Delete: deleteVerifiedFromDevice also deletes the companion MOV when deleting a Live Photo still.
This commit is contained in:
@@ -121,6 +121,9 @@ class IosConnector(
|
|||||||
exifModel = device.displayName,
|
exifModel = device.displayName,
|
||||||
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
|
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
|
||||||
sourceFolder = sourceFolder,
|
sourceFolder = sourceFolder,
|
||||||
|
isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true",
|
||||||
|
isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null,
|
||||||
|
companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log.debug("Failed to parse photo entry: ${e.message}")
|
log.debug("Failed to parse photo entry: ${e.message}")
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ data class ImportJob(
|
|||||||
val stripLivePhotoMotion: Boolean = true,
|
val stripLivePhotoMotion: Boolean = true,
|
||||||
/** Whether to delete photos from the phone after successful verification */
|
/** Whether to delete photos from the phone after successful verification */
|
||||||
val deleteAfterVerify: Boolean = false,
|
val deleteAfterVerify: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Whether to import Live Photo .MOV companions alongside their HEIC stills.
|
||||||
|
* Default false — you said you don't want them.
|
||||||
|
*/
|
||||||
|
val importLivePhotoCompanions: Boolean = false,
|
||||||
/**
|
/**
|
||||||
* Whether to preserve the device's subfolder structure under the staging folder.
|
* Whether to preserve the device's subfolder structure under the staging folder.
|
||||||
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg
|
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ data class PhonePhoto(
|
|||||||
val isLivePhotoStill: Boolean = false,
|
val isLivePhotoStill: Boolean = false,
|
||||||
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
||||||
val isLivePhotoMotion: Boolean = false,
|
val isLivePhotoMotion: Boolean = false,
|
||||||
|
/**
|
||||||
|
* Device path of the paired MOV companion (set on the HEIC when isLivePhotoStill=true).
|
||||||
|
* Used by the Copier to pull both files atomically and by deletePhoto to delete both.
|
||||||
|
*/
|
||||||
|
val companionDevicePath: String? = null,
|
||||||
/** True if the format is HEIC — user may want to convert to JPEG */
|
/** True if the format is HEIC — user may want to convert to JPEG */
|
||||||
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
|
||||||
filename.endsWith(".HEIF", ignoreCase = true),
|
filename.endsWith(".HEIF", ignoreCase = true),
|
||||||
|
|||||||
@@ -63,14 +63,10 @@ class Copier(
|
|||||||
|
|
||||||
photosToImport.forEachIndexed { idx, photo ->
|
photosToImport.forEachIndexed { idx, photo ->
|
||||||
onProgress(idx, photosToImport.size, photo.filename)
|
onProgress(idx, photosToImport.size, photo.filename)
|
||||||
// Determine destination directory
|
|
||||||
val destDir = if (job.preserveFolderStructure) {
|
val destDir = if (job.preserveFolderStructure) {
|
||||||
// Always use sourceFolder regardless of how many folders are in the job
|
|
||||||
val subFolder = photo.sourceFolder.ifBlank {
|
val subFolder = photo.sourceFolder.ifBlank {
|
||||||
photo.devicePath
|
photo.devicePath.substringAfter("/DCIM/", "")
|
||||||
.substringAfter("/DCIM/", "")
|
.substringBeforeLast("/", "").ifBlank { "Camera" }
|
||||||
.substringBeforeLast("/", "")
|
|
||||||
.ifBlank { "Camera" }
|
|
||||||
}
|
}
|
||||||
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
|
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
|
||||||
} else {
|
} else {
|
||||||
@@ -81,6 +77,23 @@ class Copier(
|
|||||||
if (result.error != null) {
|
if (result.error != null) {
|
||||||
log.error("Failed to copy ${photo.filename}: ${result.error}")
|
log.error("Failed to copy ${photo.filename}: ${result.error}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pull Live Photo companion MOV if requested
|
||||||
|
if (job.importLivePhotoCompanions && photo.isLivePhotoStill && photo.companionDevicePath != null) {
|
||||||
|
val companionFilename = photo.companionDevicePath.substringAfterLast("/")
|
||||||
|
val companionPhoto = photo.copy(
|
||||||
|
devicePath = photo.companionDevicePath,
|
||||||
|
filename = companionFilename,
|
||||||
|
isLivePhotoStill = false,
|
||||||
|
isLivePhotoMotion = true,
|
||||||
|
companionDevicePath = null,
|
||||||
|
)
|
||||||
|
val companionResult = copyOne(companionPhoto, destDir, convertHeic = false)
|
||||||
|
results.add(companionResult)
|
||||||
|
if (companionResult.error != null) {
|
||||||
|
log.error("Failed to copy companion ${companionFilename}: ${companionResult.error}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onProgress(photosToImport.size, photosToImport.size, "")
|
onProgress(photosToImport.size, photosToImport.size, "")
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
val stripLivePhotoMotion: Boolean,
|
val stripLivePhotoMotion: Boolean,
|
||||||
val deleteAfterVerify: Boolean,
|
val deleteAfterVerify: Boolean,
|
||||||
val preserveFolderStructure: Boolean,
|
val preserveFolderStructure: Boolean,
|
||||||
|
val importLivePhotoCompanions: Boolean = false,
|
||||||
val folderName: String,
|
val folderName: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -402,6 +403,7 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
|
||||||
deleteAfterVerify = importCfg.deleteAfterVerify,
|
deleteAfterVerify = importCfg.deleteAfterVerify,
|
||||||
preserveFolderStructure = importCfg.preserveFolderStructure,
|
preserveFolderStructure = importCfg.preserveFolderStructure,
|
||||||
|
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
|
||||||
)
|
)
|
||||||
|
|
||||||
navigateTo(Screen.IMPORTING)
|
navigateTo(Screen.IMPORTING)
|
||||||
@@ -428,8 +430,22 @@ class AppState(private val configStore: ConfigStore) {
|
|||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.IO) {
|
||||||
val connector = connectorFor(device)
|
val connector = connectorFor(device)
|
||||||
result.results.filter { it.verified }.forEach { r ->
|
result.results.filter { it.verified }.forEach { r ->
|
||||||
try { connector.deletePhoto(device, r.photo) }
|
try {
|
||||||
catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") }
|
connector.deletePhoto(device, r.photo)
|
||||||
|
// Also delete companion MOV if this was a Live Photo still
|
||||||
|
if (r.photo.isLivePhotoStill && r.photo.companionDevicePath != null) {
|
||||||
|
val companion = r.photo.copy(
|
||||||
|
devicePath = r.photo.companionDevicePath,
|
||||||
|
filename = r.photo.companionDevicePath.substringAfterLast("/"),
|
||||||
|
)
|
||||||
|
try { connector.deletePhoto(device, companion) }
|
||||||
|
catch (e: DeviceException) {
|
||||||
|
log.warn("Could not delete companion for ${r.photo.filename}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: DeviceException) {
|
||||||
|
log.error("Delete failed for ${r.photo.filename}: ${e.message}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -246,6 +246,13 @@ private fun DenseThumb(
|
|||||||
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
|
tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// LIVE badge for Live Photo stills
|
||||||
|
if (photo.isLivePhotoStill) {
|
||||||
|
Badge(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondary,
|
||||||
|
modifier = Modifier.align(Alignment.TopStart).padding(2.dp),
|
||||||
|
) { Text("LIVE", style = MaterialTheme.typography.labelSmall) }
|
||||||
|
}
|
||||||
if (photo.mediaType == MediaType.VIDEO) {
|
if (photo.mediaType == MediaType.VIDEO) {
|
||||||
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
|
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
|
||||||
"▶ ${photo.sizeBytes / (1024 * 1024)}MB"
|
"▶ ${photo.sizeBytes / (1024 * 1024)}MB"
|
||||||
|
|||||||
@@ -145,10 +145,10 @@ fun ImportScreen(state: AppState) {
|
|||||||
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Strip Live Photo motion clips",
|
label = "Import Live Photo motion clips (.MOV)",
|
||||||
description = "Excludes .MOV companions from Live Photos.",
|
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
|
||||||
checked = importConfig.stripLivePhotoMotion,
|
checked = importConfig.importLivePhotoCompanions,
|
||||||
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
|
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
|
||||||
)
|
)
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label = "Preserve folder structure",
|
label = "Preserve folder structure",
|
||||||
|
|||||||
@@ -127,12 +127,40 @@ async def cmd_list_photos():
|
|||||||
# Build a set of all filenames in this folder for Live Photo pairing
|
# Build a set of all filenames in this folder for Live Photo pairing
|
||||||
all_filenames_in_folder = set(entries)
|
all_filenames_in_folder = set(entries)
|
||||||
|
|
||||||
|
# Pre-compute Live Photo pairs: IMG_XXXX.MOV + IMG_XXXX.HEIC/JPG
|
||||||
|
# Key: base name (IMG_XXXX) -> MOV device path
|
||||||
|
live_photo_mov_paths = {}
|
||||||
|
for fname in entries:
|
||||||
|
if re.match(r'^IMG_\d{4}\.MOV$', fname, re.IGNORECASE):
|
||||||
|
base = fname.rsplit('.', 1)[0]
|
||||||
|
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
|
||||||
|
if f'{base}.{still_ext}' in all_filenames_in_folder:
|
||||||
|
# Confirm it's small enough to be a Live Photo clip
|
||||||
|
# (we'll verify size when we hit the MOV entry)
|
||||||
|
live_photo_mov_paths[base] = f'{folder_path}/{fname}'
|
||||||
|
break
|
||||||
|
|
||||||
for filename in entries:
|
for filename in entries:
|
||||||
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
if ext not in MEDIA_EXTENSIONS:
|
if ext not in MEDIA_EXTENSIONS:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
device_path = f'{folder_path}/{filename}'
|
device_path = f'{folder_path}/{filename}'
|
||||||
|
base = filename.rsplit('.', 1)[0]
|
||||||
|
|
||||||
|
# Determine Live Photo status
|
||||||
|
is_live_motion = False
|
||||||
|
companion_device_path = None
|
||||||
|
|
||||||
|
if re.match(r'^IMG_\d{4}\.MOV$', filename, re.IGNORECASE):
|
||||||
|
if base in live_photo_mov_paths:
|
||||||
|
# This MOV is a companion — mark it but still include it
|
||||||
|
# (AppState will filter it from display)
|
||||||
|
is_live_motion = True
|
||||||
|
|
||||||
|
elif re.match(r'^IMG_\d{4}\.(HEIC|HEIF|JPG|JPEG)$', filename, re.IGNORECASE):
|
||||||
|
if base in live_photo_mov_paths:
|
||||||
|
companion_device_path = live_photo_mov_paths[base]
|
||||||
|
|
||||||
# Get file metadata via stat
|
# Get file metadata via stat
|
||||||
size_bytes = 0
|
size_bytes = 0
|
||||||
@@ -162,6 +190,8 @@ async def cmd_list_photos():
|
|||||||
'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes),
|
'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes),
|
||||||
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
|
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
|
||||||
'isHeic': ext in ('heic', 'heif'),
|
'isHeic': ext in ('heic', 'heif'),
|
||||||
|
'isLivePhotoMotion': is_live_motion,
|
||||||
|
'companionDevicePath': companion_device_path,
|
||||||
})
|
})
|
||||||
|
|
||||||
print(json.dumps(photos))
|
print(json.dumps(photos))
|
||||||
|
|||||||
Reference in New Issue
Block a user