From 6ed436285683abe43c8b9a40d71b817d1e669af9 Mon Sep 17 00:00:00 2001 From: Kyle Bolen Date: Thu, 30 Jul 2026 03:53:24 +0000 Subject: [PATCH] Fix HEIC thumbnails: pull full file + convert to JPEG via sips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HEIC is not front-loaded — partial 512KB reads produce broken output. batch-thumbs now pulls full file for HEIC/HEIF, partial for JPEG/PNG. sips: add --setProperty format jpeg to all thumbnail conversions so HEIC files are always converted to JPEG. SkiaImage on JVM desktop cannot decode HEIC natively — JPEG is required. --- .../com/bolenpad/photophetch/ui/AppState.kt | 14 ++++++++---- .../photophetch/util/ThumbnailCache.kt | 9 +++++--- .../scripts/photophetch-ios-helper.py | 22 +++++++++++++------ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt index b72cfcf..a6d062e 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/ui/AppState.kt @@ -234,13 +234,19 @@ 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()) { - val sips = ProcessBuilder("sips", "--resampleWidth", "240", - tmpFile.toString(), "--out", cachePath.toString()) - .redirectErrorStream(true).start() + // 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() tmpFile.toFile().delete() - if (cachePath.toFile().exists()) batchLoaded++ + if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) batchLoaded++ } } loaded += batchLoaded diff --git a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt index eb5e198..fd8b15f 100644 --- a/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt +++ b/src/main/kotlin/com/bolenpad/photophetch/util/ThumbnailCache.kt @@ -56,10 +56,13 @@ object ThumbnailCache { val success = pullFn(devicePath, tmpFile) if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null - // Resize to thumbnail via sips (macOS built-in) + // Resize to thumbnail via sips — also converts HEIC→JPEG val sips = ProcessBuilder( - "sips", "--resampleWidth", "240", - tmpFile.toString(), "--out", cachePath.toString() + "sips", + "--resampleWidth", "240", + "--setProperty", "format", "jpeg", + tmpFile.toString(), + "--out", cachePath.toString() ).redirectErrorStream(true).start() sips.inputStream.readBytes() sips.waitFor() diff --git a/src/main/resources/scripts/photophetch-ios-helper.py b/src/main/resources/scripts/photophetch-ios-helper.py index 3c8a29c..ede570f 100644 --- a/src/main/resources/scripts/photophetch-ios-helper.py +++ b/src/main/resources/scripts/photophetch-ios-helper.py @@ -162,6 +162,9 @@ 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). """ try: pairs = json.loads(pairs_json) @@ -169,16 +172,21 @@ async def cmd_batch_thumbs(pairs_json: str): afc = await get_afc(lockdown) results = [] 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') try: os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True) - try: - data = await afc.fread(device_path, 512 * 1024, 0) - except Exception: + if is_heic: + # HEIC: must pull full file — partial reads produce broken output await afc.pull(device_path, local_path) - results.append({'devicePath': device_path, 'ok': True}) - continue - with open(local_path, 'wb') as f: - f.write(data) + 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)})