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 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. * Applies tilt-shift blur effect to a bitmap.
* Supports both linear and radial modes. * Supports both linear and radial modes.

View file

@ -106,8 +106,8 @@ fun CameraScreen(
val showControls by viewModel.showControls.collectAsState() val showControls by viewModel.showControls.collectAsState()
val lastSavedUri by viewModel.lastSavedUri.collectAsState() val lastSavedUri by viewModel.lastSavedUri.collectAsState()
val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState() val lastThumbnailBitmap by viewModel.lastThumbnailBitmap.collectAsState()
val galleryBitmap by viewModel.galleryBitmap.collectAsState() val galleryPreviewBitmap by viewModel.galleryPreviewBitmap.collectAsState()
val isGalleryPreview = galleryBitmap != null val isGalleryPreview = galleryPreviewBitmap != null
val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState() val zoomRatio by viewModel.cameraManager.zoomRatio.collectAsState()
val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState() val minZoom by viewModel.cameraManager.minZoomRatio.collectAsState()
@ -179,10 +179,10 @@ fun CameraScreen(
) { ) {
// Main view: gallery preview image or camera GL surface // Main view: gallery preview image or camera GL surface
if (isGalleryPreview) { if (isGalleryPreview) {
galleryBitmap?.let { bmp -> galleryPreviewBitmap?.let { bmp ->
Image( Image(
bitmap = bmp.asImageBitmap(), 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, contentScale = ContentScale.Fit,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View file

@ -8,10 +8,14 @@ import android.util.Log
import android.view.Surface import android.view.Surface
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import no.naiv.tiltshift.camera.CameraManager import no.naiv.tiltshift.camera.CameraManager
import no.naiv.tiltshift.camera.ImageCaptureHandler import no.naiv.tiltshift.camera.ImageCaptureHandler
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
@ -29,6 +33,8 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
companion object { companion object {
private const val TAG = "CameraViewModel" 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) val cameraManager = CameraManager(application)
@ -64,11 +70,19 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
// Gallery preview state // Gallery preview state
private val _galleryBitmap = MutableStateFlow<Bitmap?>(null) private val _galleryBitmap = MutableStateFlow<Bitmap?>(null)
val galleryBitmap: StateFlow<Bitmap?> = _galleryBitmap.asStateFlow()
private val _galleryImageUri = MutableStateFlow<Uri?>(null) private val _galleryImageUri = MutableStateFlow<Uri?>(null)
val galleryImageUri: StateFlow<Uri?> = _galleryImageUri.asStateFlow() 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 val isGalleryPreview: Boolean get() = _galleryBitmap.value != null
// Device state // Device state
@ -111,6 +125,24 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
if (bitmap != null) { if (bitmap != null) {
_galleryBitmap.value = bitmap _galleryBitmap.value = bitmap
_galleryImageUri.value = uri _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 { } else {
haptics.error() haptics.error()
showError("Failed to load image") 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() { fun cancelGalleryPreview() {
val old = _galleryBitmap.value previewJob?.cancel()
previewJob = null
val oldGallery = _galleryBitmap.value
val oldPreview = _galleryPreviewBitmap.value
_galleryBitmap.value = null _galleryBitmap.value = null
_galleryImageUri.value = null _galleryImageUri.value = null
old?.recycle() _galleryPreviewBitmap.value = null
oldGallery?.recycle()
oldPreview?.recycle()
galleryPreviewSource?.recycle()
galleryPreviewSource = null
} }
fun applyGalleryEffect() { fun applyGalleryEffect() {
@ -205,5 +264,7 @@ class CameraViewModel(application: Application) : AndroidViewModel(application)
cameraManager.release() cameraManager.release()
_lastThumbnailBitmap.value?.recycle() _lastThumbnailBitmap.value?.recycle()
_galleryBitmap.value?.recycle() _galleryBitmap.value?.recycle()
_galleryPreviewBitmap.value?.recycle()
galleryPreviewSource?.recycle()
} }
} }