Fix HEIC thumbnails: pull full file + convert to JPEG via sips

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.
This commit is contained in:
Kyle Bolen
2026-07-30 03:53:24 +00:00
parent fbd228b768
commit 6ed4362856
3 changed files with 31 additions and 14 deletions

View File

@@ -234,13 +234,19 @@ 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()) {
val sips = ProcessBuilder("sips", "--resampleWidth", "240", // sips: resample to 240px wide AND convert to JPEG
tmpFile.toString(), "--out", cachePath.toString()) // --setProperty format jpeg ensures HEIC files are converted
.redirectErrorStream(true).start() val sips = ProcessBuilder(
"sips",
"--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(),
"--out", cachePath.toString()
).redirectErrorStream(true).start()
sips.inputStream.readBytes() sips.inputStream.readBytes()
sips.waitFor() sips.waitFor()
tmpFile.toFile().delete() tmpFile.toFile().delete()
if (cachePath.toFile().exists()) batchLoaded++ if (cachePath.toFile().exists() && cachePath.toFile().length() > 0) batchLoaded++
} }
} }
loaded += batchLoaded loaded += batchLoaded

View File

@@ -56,10 +56,13 @@ object ThumbnailCache {
val success = pullFn(devicePath, tmpFile) val success = pullFn(devicePath, tmpFile)
if (!success || !tmpFile.exists() || tmpFile.toFile().length() == 0L) return null 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( val sips = ProcessBuilder(
"sips", "--resampleWidth", "240", "sips",
tmpFile.toString(), "--out", cachePath.toString() "--resampleWidth", "240",
"--setProperty", "format", "jpeg",
tmpFile.toString(),
"--out", cachePath.toString()
).redirectErrorStream(true).start() ).redirectErrorStream(true).start()
sips.inputStream.readBytes() sips.inputStream.readBytes()
sips.waitFor() sips.waitFor()

View File

@@ -162,6 +162,9 @@ async def cmd_batch_thumbs(pairs_json: str):
Pull thumbnails for multiple files in a single AFC session. Pull thumbnails for multiple files in a single AFC session.
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).
For JPEG/PNG: reads first 512KB (enough for thumbnail extraction).
""" """
try: try:
pairs = json.loads(pairs_json) pairs = json.loads(pairs_json)
@@ -169,16 +172,21 @@ async def cmd_batch_thumbs(pairs_json: str):
afc = await get_afc(lockdown) afc = await get_afc(lockdown)
results = [] results = []
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 ''
is_heic = ext in ('heic', 'heif')
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:
# HEIC: must pull full file — partial reads produce broken output
await afc.pull(device_path, local_path)
else:
# JPEG/PNG: 512KB partial read is sufficient
try: try:
data = await afc.fread(device_path, 512 * 1024, 0) data = await afc.fread(device_path, 512 * 1024, 0)
except Exception:
await afc.pull(device_path, local_path)
results.append({'devicePath': device_path, 'ok': True})
continue
with open(local_path, 'wb') as f: with open(local_path, 'wb') as f:
f.write(data) 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)})