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