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.
This commit is contained in:
Kyle Bolen
2026-07-30 03:54:40 +00:00
parent 6ed4362856
commit 30b600e2a4
2 changed files with 44 additions and 25 deletions

View File

@@ -234,19 +234,40 @@ class AppState(private val configStore: ConfigStore) {
val tmpFile = tmpDir.resolve("$hash.$ext") val tmpFile = tmpDir.resolve("$hash.$ext")
val cachePath = cacheDir.resolve("$hash.jpg") val cachePath = cacheDir.resolve("$hash.jpg")
if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) { if (tmpFile.toFile().exists() && tmpFile.toFile().length() > 0 && !cachePath.toFile().exists()) {
// sips: resample to 240px wide AND convert to JPEG val success = if (ext in setOf("heic", "heif")) {
// --setProperty format jpeg ensures HEIC files are converted // Extract embedded EXIF thumbnail from HEIC (fast, no full decode needed)
val sips = ProcessBuilder( // sips -getProperty thumbnail reads only the thumbnail track
"sips", val sips = ProcessBuilder(
"--resampleWidth", "240", "sips", "-getProperty", "thumbnail",
"--setProperty", "format", "jpeg", tmpFile.toString(), "--out", cachePath.toString()
tmpFile.toString(), ).redirectErrorStream(true).start()
"--out", cachePath.toString() val output = sips.inputStream.bufferedReader().readText()
).redirectErrorStream(true).start() sips.waitFor()
sips.inputStream.readBytes() val ok = cachePath.toFile().exists() && cachePath.toFile().length() > 0
sips.waitFor() 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() tmpFile.toFile().delete()
if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) batchLoaded++ if (success) batchLoaded++
} }
} }
loaded += batchLoaded loaded += batchLoaded

View File

@@ -163,8 +163,10 @@ async def cmd_batch_thumbs(pairs_json: str):
Input: JSON array of [device_path, local_path] pairs. Input: JSON array of [device_path, local_path] pairs.
Output: JSON array of {devicePath, ok} results. Output: JSON array of {devicePath, ok} results.
For HEIC/HEIF files: pulls full file (partial read produces broken output). Strategy by format:
For JPEG/PNG: reads first 512KB (enough for thumbnail extraction). - 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: try:
pairs = json.loads(pairs_json) pairs = json.loads(pairs_json)
@@ -174,19 +176,15 @@ async def cmd_batch_thumbs(pairs_json: str):
for device_path, local_path in pairs: for device_path, local_path in pairs:
ext = device_path.rsplit('.', 1)[-1].lower() if '.' in device_path else '' ext = device_path.rsplit('.', 1)[-1].lower() if '.' in device_path else ''
is_heic = ext in ('heic', 'heif') is_heic = ext in ('heic', 'heif')
read_size = 1024 * 1024 if is_heic else 512 * 1024 # 1MB for HEIC, 512KB for others
try: try:
os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True) os.makedirs(os.path.dirname(os.path.abspath(local_path)), exist_ok=True)
if is_heic: try:
# HEIC: must pull full file — partial reads produce broken output 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) 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}) results.append({'devicePath': device_path, 'ok': True})
except Exception as e: except Exception as e:
results.append({'devicePath': device_path, 'ok': False, 'error': str(e)}) results.append({'devicePath': device_path, 'ok': False, 'error': str(e)})