Add real-time tilt-shift preview for gallery images

Gallery imports now show the effect live as you adjust parameters,
matching the camera preview experience. Uses a downscaled (1024px)
source bitmap for fast recomputation via collectLatest, which
cancels stale frames when params change mid-computation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-03-05 12:51:26 +01:00
commit 12051b2a83
3 changed files with 77 additions and 7 deletions

View file

@ -327,6 +327,15 @@ class ImageCaptureHandler(
return rotated
}
/**
* Applies tilt-shift effect to a bitmap for real-time preview.
* Runs on [Dispatchers.IO]. The caller owns the returned bitmap.
*/
suspend fun applyTiltShiftPreview(source: Bitmap, params: BlurParameters): Bitmap =
withContext(Dispatchers.IO) {
applyTiltShiftEffect(source, params)
}
/**
* Applies tilt-shift blur effect to a bitmap.
* Supports both linear and radial modes.

View file

@ -106,8 +106,8 @@ fun CameraScreen(
val showControls by viewModel.showControls.collectAsState()
val lastSavedUri by viewModel.lastSavedUri.collectAsState()
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
val galleryBitmap by viewModel.galleryBitmap.collectAsState()
val isGalleryPreview = galleryBitmap != null
val galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
val isGalleryPreview = galleryPreviewBitmap != null
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
@ -179,10 +179,10 @@ fun CameraScreen(
) {
// Main view: gallery preview image or camera GL surface
if (isGalleryPreview) {
galleryBitmap?.let { bmp ->
galleryPreviewBitmap?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Gallery image preview. Adjust tilt-shift parameters then tap Apply.",
contentDescription = "Gallery image preview with tilt-shift effect. Adjust parameters then tap Apply.",
contentScale = ContentScale.Fit,
modifier = Modifier
.fillMaxSize()

View file

@ -8,10 +8,14 @@ import android.util.Log
import android.view.Surface
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import no.naiv.tiltshift.camera.CameraManager
import no.naiv.tiltshift.camera.ImageCaptureHandler
import no.naiv.tiltshift.effect.BlurParameters
@ -29,6 +33,8 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
companion object {
private const val TAG = "CameraViewModel"
/** Max dimension for the preview source bitmap to keep effect computation fast. */
private const val PREVIEW_MAX_DIMENSION = 1024
}
val cameraManager = CameraManager(application)
@ -64,11 +70,19 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
// Gallery preview state
private val _galleryBitmap = MutableStateFlow<Bitmap?>(null)
val galleryBitmap: StateFlow<Bitmap?> = _galleryBitmap.asStateFlow()
private val _galleryImageUri = MutableStateFlow<Uri?>(null)
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow()
/** Downscaled source for fast preview recomputation. */
private var galleryPreviewSource: Bitmap? = null
/** Processed preview bitmap shown in the UI. */
private val _galleryPreviewBitmap = MutableStateFlow<Bitmap?>(null)
val galleryPreviewBitmap: StateFlow<Bitmap?> = _galleryPreviewBitmap.asStateFlow()
private var previewJob: Job? = null
val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
// Device state
@ -111,6 +125,24 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
if (bitmap != null) {
_galleryBitmap.value = bitmap
_galleryImageUri.value = uri
// Create downscaled source for fast preview recomputation
galleryPreviewSource = withContext(Dispatchers.IO) {
val maxDim = maxOf(bitmap.width, bitmap.height)
if (maxDim > PREVIEW_MAX_DIMENSION) {
val scale = PREVIEW_MAX_DIMENSION.toFloat() / maxDim
Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * scale).toInt(),
(bitmap.height * scale).toInt(),
true
)
} else {
bitmap.copy(bitmap.config ?: Bitmap.Config.ARGB_8888, false)
}
}
startPreviewLoop()
} else {
haptics.error()
showError("Failed to load image")
@ -118,11 +150,38 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
}
}
/** Reactively recomputes the tilt-shift preview when blur params change. */
private fun startPreviewLoop() {
previewJob?.cancel()
previewJob = viewModelScope.launch {
_blurParams.collectLatest { params ->
val source = galleryPreviewSource ?: return@collectLatest
try {
val processed = captureHandler.applyTiltShiftPreview(source, params)
val old = _galleryPreviewBitmap.value
_galleryPreviewBitmap.value = processed
old?.recycle()
} catch (e: Exception) {
Log.w(TAG, "Preview computation failed", e)
}
}
}
}
fun cancelGalleryPreview() {
val old = _galleryBitmap.value
previewJob?.cancel()
previewJob = null
val oldGallery = _galleryBitmap.value
val oldPreview = _galleryPreviewBitmap.value
_galleryBitmap.value = null
_galleryImageUri.value = null
old?.recycle()
_galleryPreviewBitmap.value = null
oldGallery?.recycle()
oldPreview?.recycle()
galleryPreviewSource?.recycle()
galleryPreviewSource = null
}
fun applyGalleryEffect() {
@ -205,5 +264,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
cameraManager.release()
_lastThumbnailBitmap.value?.recycle()
_galleryBitmap.value?.recycle()
_galleryPreviewBitmap.value?.recycle()
galleryPreviewSource?.recycle()
}
}