Detect Live Photo companions and show video file size

iOS helper: classify_iphone_file now detects Live Photo MOV companions
by checking if IMG_XXXX.MOV has a matching IMG_XXXX.HEIC/JPG in the
same DCIM folder. Falls back to size heuristic (<5MB = Live Photo).

Live Photo companions appear in 'Live Photo' group (Other section) —
excluded from camera-roll default selection.

DenseThumb: show file size on video badge (e.g. '▶ 2MB' or '▶ 42MB')
so Live Photo clips (small) are visually distinct from real videos (large).
This commit is contained in:
Kyle Bolen
2026-07-30 04:24:22 +00:00
parent e6d27d3ab3
commit 6876709051
3 changed files with 29 additions and 6 deletions

View File

@@ -247,9 +247,14 @@ private fun DenseThumb(
} }
} }
if (photo.mediaType == MediaType.VIDEO) { if (photo.mediaType == MediaType.VIDEO) {
val sizeLabel = if (photo.sizeBytes > 1024 * 1024)
"${photo.sizeBytes / (1024 * 1024)}MB"
else if (photo.sizeBytes > 0)
"${photo.sizeBytes / 1024}KB"
else ""
Badge(containerColor = MaterialTheme.colorScheme.tertiary, Badge(containerColor = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) { modifier = Modifier.align(Alignment.BottomStart).padding(2.dp)) {
Text("", style = MaterialTheme.typography.labelSmall) Text(sizeLabel, style = MaterialTheme.typography.labelSmall)
} }
} }
if (isSelected) { if (isSelected) {

View File

@@ -106,7 +106,7 @@ object ExifFilter {
fun isCameraRollFolder(folderName: String): Boolean { fun isCameraRollFolder(folderName: String): Boolean {
// iPhone classified groups (from iOS helper classify_iphone_file) // iPhone classified groups (from iOS helper classify_iphone_file)
if (folderName == "Camera" || folderName == "Edited") return true if (folderName == "Camera" || folderName == "Edited") return true
if (folderName in setOf("Screenshots", "Screen Recordings", "Videos", "Other")) return false if (folderName in setOf("Screenshots", "Screen Recordings", "Videos", "Live Photo", "Other")) return false
// Android: path-based exclusion // Android: path-based exclusion
val lower = folderName.lowercase() val lower = folderName.lowercase()

View File

@@ -42,10 +42,13 @@ SCREENSHOT_RE = re.compile(r'.*\.PNG$', re.IGNORECASE)
# Screen recordings # Screen recordings
SCREEN_RECORDING_RE = re.compile(r'^RPReplay_Final.*\.mp4$', re.IGNORECASE) SCREEN_RECORDING_RE = re.compile(r'^RPReplay_Final.*\.mp4$', re.IGNORECASE)
def classify_iphone_file(filename: str) -> str: def classify_iphone_file(filename: str, all_filenames: set = None, size_bytes: int = 0) -> str:
""" """
Returns a group name for display in the folder panel. Returns a group name for display in the folder panel.
Groups: 'Camera', 'Edited', 'Screenshots', 'Videos', 'Other' Groups: 'Camera', 'Edited', 'Live Photo', 'Screenshots', 'Videos', 'Other'
all_filenames: set of all filenames in the same DCIM folder (for pairing detection).
size_bytes: used to distinguish Live Photo clips (<5MB) from real videos.
""" """
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if SCREEN_RECORDING_RE.match(filename): if SCREEN_RECORDING_RE.match(filename):
@@ -54,7 +57,19 @@ def classify_iphone_file(filename: str) -> str:
return 'Screenshots' return 'Screenshots'
if CAMERA_ORIGINAL_RE.match(filename): if CAMERA_ORIGINAL_RE.match(filename):
if ext in ('mov',): if ext in ('mov',):
return 'Videos' # Check if this is a Live Photo companion
base = filename.rsplit('.', 1)[0] # IMG_XXXX
is_live_companion = False
if all_filenames is not None:
# Paired HEIC/JPEG exists with same base name → Live Photo companion
for still_ext in ('HEIC', 'HEIF', 'JPG', 'JPEG'):
if f'{base}.{still_ext}' in all_filenames:
is_live_companion = True
break
elif size_bytes > 0 and size_bytes < 5 * 1024 * 1024:
# No pairing info but very small MOV → likely Live Photo
is_live_companion = True
return 'Live Photo' if is_live_companion else 'Videos'
return 'Camera' return 'Camera'
if CAMERA_EDITED_RE.match(filename): if CAMERA_EDITED_RE.match(filename):
return 'Edited' return 'Edited'
@@ -109,6 +124,9 @@ async def cmd_list_photos():
except Exception: except Exception:
continue continue
# Build a set of all filenames in this folder for Live Photo pairing
all_filenames_in_folder = set(entries)
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:
@@ -141,7 +159,7 @@ async def cmd_list_photos():
'filename': filename, 'filename': filename,
'sizeBytes': size_bytes, 'sizeBytes': size_bytes,
'dateIso': date_iso, 'dateIso': date_iso,
'sourceFolder': classify_iphone_file(filename), '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'),
}) })