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:
parent
dedf445cf6
commit
12051b2a83
3 changed files with 77 additions and 7 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue