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:
@@ -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
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
|||||||
Reference in New Issue
Block a user