From 30b600e2a4f32449f6145c363ae06e824833d2a4 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 03:54:40 +0000 Subject: [PATCH] HEIC thumbnails: extract embedded thumbnail instead of full file pull For HEIC photos: read first 1MB (thumbnail track is near file start), then use sips -getProperty thumbnail to extract the embedded JPEG thumbnail without decoding the full image. Falls back to full resample if thumbnail extraction fails. For JPEG/PNG: unchanged, 512KB partial read + resample. This avoids transferring 3-5MB per HEIC photo just for a thumbnail. --- .../com/bolenpad/photophetch/ui/AppState.kt | 45 ++++++++++++++----- .../scripts/photophetch-ios-helper.py | 24 +++++----- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index a6d062e..1ab1d3f 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -234,19 +234,40 @@ class AppState(private val configStore: ConfigStore) { val tmpFile = tmpDir.resolve("$hash.$ext") val cachePath = cacheDir.resolve("$hash.jpg") if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) { - // sips: resample to 240px wide AND convert to JPEG - // --setProperty format jpeg ensures HEIC files are converted - val sips = ProcessBuilder( - "sips", - "--resampleWidth", "240", - "--setProperty", "format", "jpeg", - tmpFile.toString(), - "--out", cachePath.toString() - ).redirectErrorStream(true).start() - sips.inputStream.readBytes() - sips.waitFor() + val success = if (ext in setOf("heic", "heif")) { + // Extract embedded EXIF thumbnail from HEIC (fast, no full decode needed) + // sips -getProperty thumbnail reads only the thumbnail track + val sips = ProcessBuilder( + "sips", "-getProperty", "thumbnail", + tmpFile.toString(), "--out", cachePath.toString() + ).redirectErrorStream(true).start() + val output = sips.inputStream.bufferedReader().readText() + sips.waitFor() + val ok = cachePath.toFile().exists() && cachePath.toFile().length() > 0 + if (!ok) { + // Thumbnail extraction failed — fall back to full resample + val sips2 = ProcessBuilder( + "sips", "--resampleWidth", "240", + "--setProperty", "format", "jpeg", + tmpFile.toString(), "--out", cachePath.toString() + ).redirectErrorStream(true).start() + sips2.inputStream.readBytes() + sips2.waitFor() + cachePath.toFile().exists() && cachePath.toFile().length() > 0 + } else ok + } else { + // JPEG/PNG: simple resample + val sips = ProcessBuilder( + "sips", "--resampleWidth", "240", + "--setProperty", "format", "jpeg", + tmpFile.toString(), "--out", cachePath.toString() + ).redirectErrorStream(true).start() + sips.inputStream.readBytes() + sips.waitFor() + cachePath.toFile().exists() && cachePath.toFile().length() > 0 + } tmpFile.toFile().delete() - if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) batchLoaded++ + if (success) batchLoaded++ } } loaded += batchLoaded diff --git a/src/main/resources/scripts/photophetch-ios-helper.py b/src/main/resources/scripts/photophetch-ios-helper.py index ede570f..06ee9ba 100644 --- a/src/main/resources/scripts/photophetch-ios-helper.py +++ b/src/main/resources/scripts/photophetch-ios-helper.py @@ -162,9 +162,11 @@ async def cmd_batch_thumbs(pairs_json: str): Pull thumbnails for multiple files in a single AFC session. Input: JSON array of [device_path, local_path] pairs. Output: JSON array of {devicePath, ok} results. - - For HEIC/HEIF files: pulls full file (partial read produces broken output). - For JPEG/PNG: reads first 512KB (enough for thumbnail extraction). + + Strategy by format: + - HEIC/HEIF: read first 1MB (thumbnail track is near file start), + extract embedded thumbnail via sips. Falls back to full pull if needed. + - JPEG/PNG: read first 512KB (sufficient for sips resample). """ try: pairs = json.loads(pairs_json) @@ -174,19 +176,15 @@ async def cmd_batch_thumbs(pairs_json: str): for device_path, local_path in pairs: ext = device_path.rsplit('.', 1)[-1].lower() if '.' in device_path else '' is_heic = ext in ('heic', 'heif') + read_size = 1024 * 1024 if is_heic else 512 * 1024 # 1MB for HEIC, 512KB for others try: os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True) - if is_heic: - # HEIC: must pull full file — partial reads produce broken output + try: + data = await afc.fread(device_path, read_size, 0) + with open(local_path, 'wb') as f: + f.write(data) + except Exception: await afc.pull(device_path, local_path) - else: - # JPEG/PNG: 512KB partial read is sufficient - try: - data = await afc.fread(device_path, 512 * 1024, 0) - with open(local_path, 'wb') as f: - f.write(data) - except Exception: - await afc.pull(device_path, local_path) results.append({'devicePath': device_path, 'ok': True}) except Exception as e: results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})