iOS thumbnails: batch pre-fetch after photo listing

After loadPhotos completes for an iPhone, spawn a background coroutine
that runs batch-thumbs in chunks of 30 photos per Python process call.
One Python process handles 30 thumbnails instead of one per photo —
reduces startup overhead by 30x.

sips resizes each pulled file to 240px and writes to the cache.
DenseThumb then reads from cache instantly. Files already cached are
skipped.
This commit is contained in:
Kyle Bolen
2026-07-28 08:45:54 +00:00
parent b12cb4dd1e
commit 6e3f6aa20e
3 changed files with 74 additions and 2 deletions

View File

View File

@@ -42,7 +42,7 @@ class IosConnector(
private val log = LoggerFactory.getLogger(IosConnector::class.java) private val log = LoggerFactory.getLogger(IosConnector::class.java)
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val pythonBin: String = pythonBinOverride ?: discoverPython() internal val pythonBin: String = pythonBinOverride ?: discoverPython()
companion object { companion object {
private val PIPX_PYTHON_CANDIDATES = listOf( private val PIPX_PYTHON_CANDIDATES = listOf(
@@ -67,7 +67,7 @@ class IosConnector(
} }
/** Path to the bundled helper script, extracted to a temp location on first use */ /** Path to the bundled helper script, extracted to a temp location on first use */
private val helperScript: String by lazy { extractHelperScript() } internal val helperScript: String by lazy { extractHelperScript() }
override suspend fun detectDevices(): List<DeviceInfo> { override suspend fun detectDevices(): List<DeviceInfo> {
return try { return try {

View File

@@ -157,6 +157,13 @@ class AppState(private val configStore: ConfigStore) {
// Default: select the first camera-roll folder // Default: select the first camera-roll folder
val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name
selectFolder(defaultFolder) selectFolder(defaultFolder)
// For iOS: batch pre-fetch thumbnails in background (amortises Python startup cost)
if (device.type == DeviceType.IOS && connector is com.bolenpad.photophetch.device.IosConnector) {
launch(Dispatchers.IO) {
prefetchIosThumbnails(device, raw, connector)
}
}
} catch (e: Exception) { } catch (e: Exception) {
log.error("Photo listing failed", e) log.error("Photo listing failed", e)
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error") _photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
@@ -164,6 +171,71 @@ class AppState(private val configStore: ConfigStore) {
} }
} }
private suspend fun prefetchIosThumbnails(
device: DeviceInfo,
photos: List<PhonePhoto>,
connector: com.bolenpad.photophetch.device.IosConnector,
) {
val cacheKey = connector.cacheKey(device)
val cacheRoot = java.nio.file.Paths.get(System.getProperty("user.home"), ".photophetch", "thumbcache")
val cacheDir = cacheRoot.resolve(cacheKey.replace("/", "_").replace(":", "_"))
if (!cacheDir.toFile().exists()) cacheDir.toFile().mkdirs()
val tmpDir = java.nio.file.Paths.get(System.getProperty("java.io.tmpdir"), "photophetch-thumbs")
if (!tmpDir.toFile().exists()) tmpDir.toFile().mkdirs()
// Only fetch photos not already cached
val needed = photos.filter { photo ->
val ext = photo.filename.substringAfterLast(".", "").lowercase()
if (ext in setOf("mp4", "mov", "3gp", "avi", "mkv", "m4v", "webm")) return@filter false
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
val cachePath = cacheDir.resolve("$hash.jpg")
!cachePath.toFile().exists() || cachePath.toFile().length() == 0L
}
if (needed.isEmpty()) return
log.info("Pre-fetching ${needed.size} iOS thumbnails...")
// Build pairs JSON and run batch-thumbs command
needed.chunked(30).forEach { batch ->
val pairs = batch.joinToString(",") { photo ->
val ext = photo.filename.substringAfterLast(".", "").lowercase()
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
val tmpPath = tmpDir.resolve("$hash.$ext")
val src = photo.devicePath.replace("\"", "\\\"")
val dst = tmpPath.toString().replace("\"", "\\\"")
"[\"$src\",\"$dst\"]"
}
val pairsJson = "[$pairs]"
try {
val process = ProcessBuilder(connector.pythonBin, connector.helperScript, "batch-thumbs", pairsJson)
.redirectErrorStream(false).start()
process.inputStream.readBytes() // stdout
process.errorStream.readBytes() // stderr
process.waitFor()
// Run sips on each successfully pulled file
batch.forEach { photo ->
val ext = photo.filename.substringAfterLast(".", "").lowercase()
val hash = (cacheKey + photo.devicePath).hashCode().toString(16)
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.inputStream.readBytes()
sips.waitFor()
tmpFile.toFile().delete()
}
}
} catch (e: Exception) {
log.warn("iOS thumbnail batch failed: ${e.message}")
}
}
log.info("iOS thumbnail pre-fetch complete")
}
fun selectFolder(folderName: String?) { fun selectFolder(folderName: String?) {
_selectedFolder.value = folderName _selectedFolder.value = folderName
_selectedPhotos.value = emptySet() _selectedPhotos.value = emptySet()