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:
Kyle Bolen
2026-07-30 04:43:41 +00:00
parent 6876709051
commit 595d583f6e
8 changed files with 91 additions and 12 deletions

View File

@@ -121,6 +121,9 @@ class IosConnector(
exifModel = device.displayName,
mediaType = if (mediaTypeStr == "VIDEO") MediaType.VIDEO else MediaType.PHOTO,
sourceFolder = sourceFolder,
isLivePhotoMotion = obj["isLivePhotoMotion"]?.jsonPrimitive?.contentOrNull == "true",
isLivePhotoStill = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull != null,
companionDevicePath = obj["companionDevicePath"]?.jsonPrimitive?.contentOrNull,
)
} catch (e: Exception) {
log.debug("Failed to parse photo entry: ${e.message}")

View File

@@ -19,6 +19,11 @@ data class ImportJob(
val stripLivePhotoMotion: Boolean = true,
/** Whether to delete photos from the phone after successful verification */
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.
* e.g. _staging/2026-07-28_Samsung/Camera/IMG_001.jpg

View File

@@ -26,6 +26,11 @@ data class PhonePhoto(
val isLivePhotoStill: Boolean = false,
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
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 */
val isHeic: Boolean = filename.endsWith(".HEIC", ignoreCase = true) ||
filename.endsWith(".HEIF", ignoreCase = true),

View File

@@ -63,14 +63,10 @@ class Copier(
photosToImport.forEachIndexed { idx, photo ->
onProgress(idx, photosToImport.size, photo.filename)
// Determine destination directory
val destDir = if (job.preserveFolderStructure) {
// Always use sourceFolder regardless of how many folders are in the job
val subFolder = photo.sourceFolder.ifBlank {
photo.devicePath
.substringAfter("/DCIM/", "")
.substringBeforeLast("/", "")
.ifBlank { "Camera" }
photo.devicePath.substringAfter("/DCIM/", "")
.substringBeforeLast("/", "").ifBlank { "Camera" }
}
stagingDir.resolve(subFolder).also { it.toFile().mkdirs() }
} else {
@@ -81,6 +77,23 @@ class Copier(
if (result.error != null) {
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, "")

View File

@@ -350,6 +350,7 @@ class AppState(private val configStore: ConfigStore) {
val stripLivePhotoMotion: Boolean,
val deleteAfterVerify: Boolean,
val preserveFolderStructure: Boolean,
val importLivePhotoCompanions: Boolean = false,
val folderName: String,
)
@@ -402,6 +403,7 @@ class AppState(private val configStore: ConfigStore) {
stripLivePhotoMotion = importCfg.stripLivePhotoMotion,
deleteAfterVerify = importCfg.deleteAfterVerify,
preserveFolderStructure = importCfg.preserveFolderStructure,
importLivePhotoCompanions = importCfg.importLivePhotoCompanions,
)
navigateTo(Screen.IMPORTING)
@@ -428,8 +430,22 @@ class AppState(private val configStore: ConfigStore) {
scope.launch(Dispatchers.IO) {
val connector = connectorFor(device)
result.results.filter { it.verified }.forEach { r ->
try { connector.deletePhoto(device, r.photo) }
catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") }
try {
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}")
}
}
}
}

View File

@@ -246,6 +246,13 @@ private fun DenseThumb(
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) {
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
"${photo.sizeBytes / (1024 * 1024)}MB"

View File

@@ -145,10 +145,10 @@ fun ImportScreen(state: AppState) {
onCheckedChange = { state.updateImportConfig { copy(convertHeicToJpeg = it) } },
)
ToggleRow(
label = "Strip Live Photo motion clips",
description = "Excludes .MOV companions from Live Photos.",
checked = importConfig.stripLivePhotoMotion,
onCheckedChange = { state.updateImportConfig { copy(stripLivePhotoMotion = it) } },
label = "Import Live Photo motion clips (.MOV)",
description = "Include the .MOV companion alongside each Live Photo still. Paired by filename match. Default off.",
checked = importConfig.importLivePhotoCompanions,
onCheckedChange = { state.updateImportConfig { copy(importLivePhotoCompanions = it) } },
)
ToggleRow(
label = "Preserve folder structure",

View File

@@ -127,12 +127,40 @@ async def cmd_list_photos():
# Build a set of all filenames in this folder for Live Photo pairing
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:
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if ext not in MEDIA_EXTENSIONS:
continue
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
size_bytes = 0
@@ -162,6 +190,8 @@ async def cmd_list_photos():
'sourceFolder': classify_iphone_file(filename, all_filenames_in_folder, size_bytes),
'mediaType': 'VIDEO' if ext in VIDEO_EXTENSIONS else 'PHOTO',
'isHeic': ext in ('heic', 'heif'),
'isLivePhotoMotion': is_live_motion,
'companionDevicePath': companion_device_path,
})
print(json.dumps(photos))