From d3ca23b71cc8a67a808003f132ba37ed3870cb07 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Thu, 29 Jan 2026 11:13:31 +0100 Subject: [PATCH] Add radial mode, UI controls, front camera, update to API 35 - Add radial/elliptical blur mode with aspect ratio control - Add UI sliders for blur intensity, falloff, and shape - Add front camera support with flip button - Update minimum SDK to API 35 (Android 15) - Enable landscape orientation (fullSensor) - Rename app to "Naiv Tilt Shift Camera" - Set APK output name to naiv-tilt-shift - Add project specification document Co-Authored-By: Claude Opus 4.5 --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 3 +- .../no/naiv/tiltshift/camera/CameraManager.kt | 29 +- .../tiltshift/camera/ImageCaptureHandler.kt | 57 ++-- .../naiv/tiltshift/effect/BlurParameters.kt | 34 ++- .../naiv/tiltshift/effect/TiltShiftShader.kt | 9 + .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 258 ++++++++++++++++-- .../no/naiv/tiltshift/ui/TiltShiftOverlay.kt | 132 +++++++-- app/src/main/res/raw/tiltshift_fragment.glsl | 70 ++++- app/src/main/res/values/strings.xml | 2 +- tiltshift-spec.md | 177 ++++++++++++ 11 files changed, 680 insertions(+), 95 deletions(-) create mode 100644 tiltshift-spec.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 01831dd..4d113de 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,7 @@ android { defaultConfig { applicationId = "no.naiv.tiltshift" - minSdk = 26 + minSdk = 35 targetSdk = 35 versionCode = 1 versionName = "1.0.0" @@ -18,6 +18,8 @@ android { vectorDrawables { useSupportLibrary = true } + + base.archivesName.set("naiv-tilt-shift") } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2a10e4c..6961041 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,7 +28,8 @@ diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index ee6cbc4..f70d6a4 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -41,8 +41,12 @@ class CameraManager(private val context: Context) { private val _maxZoomRatio = MutableStateFlow(1.0f) val maxZoomRatio: StateFlow = _maxZoomRatio.asStateFlow() + private val _isFrontCamera = MutableStateFlow(false) + val isFrontCamera: StateFlow = _isFrontCamera.asStateFlow() + private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null private var surfaceSize: Size = Size(1920, 1080) + private var lifecycleOwnerRef: LifecycleOwner? = null /** * Starts the camera with the given lifecycle owner. @@ -53,6 +57,7 @@ class CameraManager(private val context: Context) { surfaceTextureProvider: () -> SurfaceTexture? ) { this.surfaceTextureProvider = surfaceTextureProvider + this.lifecycleOwnerRef = lifecycleOwner val cameraProviderFuture = ProcessCameraProvider.getInstance(context) cameraProviderFuture.addListener({ @@ -82,9 +87,13 @@ class CameraManager(private val context: Context) { .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .build() - // Get camera selector from lens controller - val cameraSelector = lensController.getCurrentLens()?.selector - ?: CameraSelector.DEFAULT_BACK_CAMERA + // Select camera based on front/back preference + val cameraSelector = if (_isFrontCamera.value) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + // Use lens controller for back camera lens selection + lensController.getCurrentLens()?.selector ?: CameraSelector.DEFAULT_BACK_CAMERA + } try { // Bind use cases to camera @@ -146,10 +155,19 @@ class CameraManager(private val context: Context) { } /** - * Switches to a different lens. + * Switches between front and back camera. + */ + fun switchCamera() { + _isFrontCamera.value = !_isFrontCamera.value + _zoomRatio.value = 1.0f // Reset zoom when switching + lifecycleOwnerRef?.let { bindCameraUseCases(it) } + } + + /** + * Switches to a different lens (back camera only). */ fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) { - if (lensController.selectLens(lensId)) { + if (!_isFrontCamera.value && lensController.selectLens(lensId)) { bindCameraUseCases(lifecycleOwner) } } @@ -169,5 +187,6 @@ class CameraManager(private val context: Context) { camera = null preview = null imageCapture = null + lifecycleOwnerRef = null } } diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index c57dc7a..aaa6279 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -6,25 +6,21 @@ import android.graphics.BitmapFactory import android.graphics.Canvas import android.graphics.Matrix import android.graphics.Paint -import android.graphics.RenderEffect -import android.graphics.Shader import android.location.Location -import android.os.Build -import android.view.Surface import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageProxy import androidx.exifinterface.media.ExifInterface import kotlinx.coroutines.suspendCancellableCoroutine +import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.SaveResult -import no.naiv.tiltshift.util.OrientationDetector -import java.io.File import java.util.concurrent.Executor import kotlin.coroutines.resume import kotlin.math.cos import kotlin.math.sin +import kotlin.math.sqrt /** * Handles capturing photos with the tilt-shift effect applied. @@ -135,7 +131,7 @@ class ImageCaptureHandler( /** * Applies tilt-shift blur effect to a bitmap. - * This is a software fallback - on newer devices we could use RenderScript/RenderEffect. + * Supports both linear and radial modes. */ private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap { val width = source.width @@ -143,7 +139,6 @@ class ImageCaptureHandler( // Create output bitmap val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val canvas = Canvas(result) // For performance, we use a scaled-down version for blur and composite val scaleFactor = 4 // Blur a 1/4 size image for speed @@ -165,7 +160,6 @@ class ImageCaptureHandler( val mask = createGradientMask(width, height, params) // Composite: blend original with blurred based on mask - val paint = Paint(Paint.ANTI_ALIAS_FLAG) val pixels = IntArray(width * height) val blurredPixels = IntArray(width * height) val maskPixels = IntArray(width * height) @@ -199,6 +193,7 @@ class ImageCaptureHandler( /** * Creates a gradient mask for the tilt-shift effect. + * Supports both linear and radial modes. */ private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap { val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) @@ -206,25 +201,47 @@ class ImageCaptureHandler( val centerX = width * params.positionX val centerY = height * params.positionY - val focusHalfHeight = height * params.size * 0.5f - val transitionHeight = focusHalfHeight * 0.5f + val focusSize = height * params.size * 0.5f + val transitionSize = focusSize * params.falloff val cosAngle = cos(params.angle) val sinAngle = sin(params.angle) + val screenAspect = width.toFloat() / height.toFloat() for (y in 0 until height) { for (x in 0 until width) { - // Rotate point around focus center - val dx = x - centerX - val dy = y - centerY - val rotatedY = -dx * sinAngle + dy * cosAngle + val dist = when (params.mode) { + BlurMode.LINEAR -> { + // Rotate point around focus center + val dx = x - centerX + val dy = y - centerY + val rotatedY = -dx * sinAngle + dy * cosAngle + kotlin.math.abs(rotatedY) + } + BlurMode.RADIAL -> { + // Calculate elliptical distance from center + var dx = x - centerX + var dy = y - centerY - // Calculate blur amount based on distance from focus line - val dist = kotlin.math.abs(rotatedY) + // Adjust for screen aspect ratio + dx *= screenAspect + + // Rotate + val rotatedX = dx * cosAngle - dy * sinAngle + val rotatedY = dx * sinAngle + dy * cosAngle + + // Apply ellipse aspect ratio + val adjustedX = rotatedX / params.aspectRatio + + sqrt(adjustedX * adjustedX + rotatedY * rotatedY) + } + } + + // Calculate blur amount based on distance from focus region val blurAmount = when { - dist < focusHalfHeight -> 0f - dist < focusHalfHeight + transitionHeight -> { - (dist - focusHalfHeight) / transitionHeight + dist < focusSize -> 0f + dist < focusSize + transitionSize -> { + (dist - focusSize) / transitionSize } else -> 1f } diff --git a/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt b/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt index e145227..2b399a4 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt @@ -1,20 +1,34 @@ package no.naiv.tiltshift.effect +/** + * Blur mode for tilt-shift effect. + */ +enum class BlurMode { + LINEAR, // Horizontal band of focus with blur above and below + RADIAL // Central focus point with blur radiating outward +} + /** * Parameters controlling the tilt-shift blur effect. * + * @param mode The blur mode (linear or radial) * @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands) * @param positionX The horizontal center of the in-focus region (0.0 to 1.0) * @param positionY The vertical center of the in-focus region (0.0 to 1.0) * @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height) * @param blurAmount The intensity of the blur effect (0.0 to 1.0) + * @param falloff Transition sharpness from focused to blurred (0.0 = sharp edge, 1.0 = gradual) + * @param aspectRatio Ellipse aspect ratio for radial mode (1.0 = circle, <1 = tall, >1 = wide) */ data class BlurParameters( + val mode: BlurMode = BlurMode.LINEAR, val angle: Float = 0f, val positionX: Float = 0.5f, val positionY: Float = 0.5f, val size: Float = 0.3f, - val blurAmount: Float = 0.8f + val blurAmount: Float = 0.8f, + val falloff: Float = 0.5f, + val aspectRatio: Float = 1.0f ) { companion object { val DEFAULT = BlurParameters() @@ -24,6 +38,10 @@ data class BlurParameters( const val MAX_SIZE = 0.8f const val MIN_BLUR = 0.0f const val MAX_BLUR = 1.0f + const val MIN_FALLOFF = 0.1f + const val MAX_FALLOFF = 1.0f + const val MIN_ASPECT = 0.3f + const val MAX_ASPECT = 3.0f } /** @@ -56,4 +74,18 @@ data class BlurParameters( fun withBlurAmount(amount: Float): BlurParameters { return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR)) } + + /** + * Returns a copy with the falloff clamped to valid range. + */ + fun withFalloff(newFalloff: Float): BlurParameters { + return copy(falloff = newFalloff.coerceIn(MIN_FALLOFF, MAX_FALLOFF)) + } + + /** + * Returns a copy with the aspect ratio clamped to valid range. + */ + fun withAspectRatio(ratio: Float): BlurParameters { + return copy(aspectRatio = ratio.coerceIn(MIN_ASPECT, MAX_ASPECT)) + } } diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt index 3ed138c..2dc8447 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -23,11 +23,14 @@ class TiltShiftShader(private val context: Context) { // Uniform locations private var uTextureLocation: Int = 0 + private var uModeLocation: Int = 0 private var uAngleLocation: Int = 0 private var uPositionXLocation: Int = 0 private var uPositionYLocation: Int = 0 private var uSizeLocation: Int = 0 private var uBlurAmountLocation: Int = 0 + private var uFalloffLocation: Int = 0 + private var uAspectRatioLocation: Int = 0 private var uResolutionLocation: Int = 0 /** @@ -61,11 +64,14 @@ class TiltShiftShader(private val context: Context) { // Get uniform locations uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture") + uModeLocation = GLES20.glGetUniformLocation(programId, "uMode") uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle") uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX") uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY") uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize") uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount") + uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff") + uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio") uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution") // Clean up shaders (they're linked into program now) @@ -85,11 +91,14 @@ class TiltShiftShader(private val context: Context) { GLES20.glUniform1i(uTextureLocation, 0) // Set effect parameters + GLES20.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0) GLES20.glUniform1f(uAngleLocation, params.angle) GLES20.glUniform1f(uPositionXLocation, params.positionX) GLES20.glUniform1f(uPositionYLocation, params.positionY) GLES20.glUniform1f(uSizeLocation, params.size) GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount) + GLES20.glUniform1f(uFalloffLocation, params.falloff) + GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio) GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat()) } diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 894b6f9..1d51d2b 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -22,20 +22,23 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.FlipCameraAndroid import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -54,6 +57,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import no.naiv.tiltshift.camera.CameraManager import no.naiv.tiltshift.camera.ImageCaptureHandler +import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.storage.PhotoSaver @@ -90,6 +94,7 @@ fun CameraScreen( var isCapturing by remember { mutableStateOf(false) } var showSaveSuccess by remember { mutableStateOf(false) } var showSaveError by remember { mutableStateOf(null) } + var showControls by remember { mutableStateOf(false) } var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentLocation by remember { mutableStateOf(null) } @@ -97,6 +102,7 @@ fun CameraScreen( val zoomRatio by cameraManager.zoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState() + val isFrontCamera by cameraManager.isFrontCamera.collectAsState() // Collect orientation updates LaunchedEffect(Unit) { @@ -172,30 +178,86 @@ fun CameraScreen( modifier = Modifier.fillMaxSize() ) - // Top bar - Row( + // Top bar with controls + Column( modifier = Modifier .fillMaxWidth() .statusBarsPadding() - .padding(16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .padding(16.dp) ) { - // Zoom indicator - ZoomIndicator(currentZoom = zoomRatio) - - // Settings button (placeholder) - IconButton( - onClick = { /* TODO: Settings */ } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Default.Settings, - contentDescription = "Settings", - tint = Color.White + // Zoom indicator + ZoomIndicator(currentZoom = zoomRatio) + + Row(verticalAlignment = Alignment.CenterVertically) { + // Camera flip button + IconButton( + onClick = { + cameraManager.switchCamera() + haptics.click() + } + ) { + Icon( + imageVector = Icons.Default.FlipCameraAndroid, + contentDescription = "Switch Camera", + tint = Color.White + ) + } + + // Toggle controls button + IconButton( + onClick = { + showControls = !showControls + haptics.tick() + } + ) { + Text( + text = if (showControls) "Hide" else "Ctrl", + color = Color.White, + fontSize = 12.sp + ) + } + } + } + + // Mode toggle + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.Center + ) { + ModeToggle( + currentMode = blurParams.mode, + onModeChange = { mode -> + blurParams = blurParams.copy(mode = mode) + haptics.click() + } ) } } + // Control panel (sliders) + AnimatedVisibility( + visible = showControls, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 16.dp) + ) { + ControlPanel( + params = blurParams, + onParamsChange = { newParams -> + blurParams = newParams + } + ) + } + // Bottom controls Column( modifier = Modifier @@ -204,18 +266,20 @@ fun CameraScreen( .padding(bottom = 24.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Zoom presets - ZoomControl( - currentZoom = zoomRatio, - minZoom = minZoom, - maxZoom = maxZoom, - onZoomSelected = { zoom -> - cameraManager.setZoom(zoom) - haptics.click() - } - ) + // Zoom presets (only show for back camera) + if (!isFrontCamera) { + ZoomControl( + currentZoom = zoomRatio, + minZoom = minZoom, + maxZoom = maxZoom, + onZoomSelected = { zoom -> + cameraManager.setZoom(zoom) + haptics.click() + } + ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) + } // Capture button CaptureButton( @@ -234,7 +298,7 @@ fun CameraScreen( blurParams = blurParams, deviceRotation = currentRotation, location = currentLocation, - isFrontCamera = false + isFrontCamera = isFrontCamera ) when (result) { @@ -294,7 +358,7 @@ fun CameraScreen( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) .background(Color(0xFFF44336)) .padding(24.dp) ) { @@ -315,6 +379,140 @@ fun CameraScreen( } } +/** + * Mode toggle for Linear / Radial blur modes. + */ +@Composable +private fun ModeToggle( + currentMode: BlurMode, + onModeChange: (BlurMode) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .background(Color(0x80000000)) + .padding(4.dp), + horizontalArrangement = Arrangement.Center + ) { + ModeButton( + text = "Linear", + isSelected = currentMode == BlurMode.LINEAR, + onClick = { onModeChange(BlurMode.LINEAR) } + ) + Spacer(modifier = Modifier.width(4.dp)) + ModeButton( + text = "Radial", + isSelected = currentMode == BlurMode.RADIAL, + onClick = { onModeChange(BlurMode.RADIAL) } + ) + } +} + +@Composable +private fun ModeButton( + text: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(16.dp)) + .background(if (isSelected) Color(0xFFFFB300) else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = if (isSelected) Color.Black else Color.White, + fontSize = 14.sp + ) + } +} + +/** + * Control panel with sliders for blur parameters. + */ +@Composable +private fun ControlPanel( + params: BlurParameters, + onParamsChange: (BlurParameters) -> Unit, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier + .width(200.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color(0xCC000000)) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + // Blur intensity slider + SliderControl( + label = "Blur", + value = params.blurAmount, + valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, + onValueChange = { onParamsChange(params.copy(blurAmount = it)) } + ) + + // Falloff slider + SliderControl( + label = "Falloff", + value = params.falloff, + valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, + onValueChange = { onParamsChange(params.copy(falloff = it)) } + ) + + // Aspect ratio slider (radial mode only) + if (params.mode == BlurMode.RADIAL) { + SliderControl( + label = "Shape", + value = params.aspectRatio, + valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT, + onValueChange = { onParamsChange(params.copy(aspectRatio = it)) } + ) + } + } +} + +@Composable +private fun SliderControl( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + onValueChange: (Float) -> Unit +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Color.White, + fontSize = 12.sp + ) + Text( + text = "${(value * 100).toInt()}%", + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = Color(0xFFFFB300), + activeTrackColor = Color(0xFFFFB300), + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ), + modifier = Modifier.height(24.dp) + ) + } +} + /** * Capture button with animation for capturing state. */ diff --git a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt index 3a8e289..8c84e76 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt @@ -8,13 +8,13 @@ import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.drawscope.DrawScope @@ -23,11 +23,13 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import kotlin.math.PI import kotlin.math.atan2 import kotlin.math.cos import kotlin.math.sin +import kotlin.math.sqrt /** * Type of gesture being performed. @@ -182,8 +184,8 @@ fun TiltShiftOverlay( * Determines the type of two-finger gesture based on touch position. * * Zones (from center outward): - * - Very center (< 30% of focus height): Rotation - * - Near focus line (30% - 200% of focus height): Size adjustment + * - Very center (< 30% of focus size): Rotation + * - Near focus region (30% - 200% of focus size): Size adjustment * - Far outside (> 200%): Camera zoom */ private fun determineGestureType( @@ -192,39 +194,50 @@ private fun determineGestureType( height: Float, params: BlurParameters ): GestureType { - // Calculate distance from focus center val focusCenterX = width * params.positionX val focusCenterY = height * params.positionY - val focusHalfHeight = height * params.size * 0.5f + val focusSize = height * params.size * 0.5f - // Rotate centroid to align with focus line val dx = centroid.x - focusCenterX val dy = centroid.y - focusCenterY - val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle) - val distFromCenter = kotlin.math.abs(rotatedY) + val distFromCenter = when (params.mode) { + BlurMode.LINEAR -> { + // For linear mode, use perpendicular distance to focus line + val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle) + kotlin.math.abs(rotatedY) + } + BlurMode.RADIAL -> { + // For radial mode, use distance from center + sqrt(dx * dx + dy * dy) + } + } return when { // Very center of focus zone -> rotation (small area) - distFromCenter < focusHalfHeight * 0.3f -> { - GestureType.ROTATE - } - // Anywhere near the blur effect -> size adjustment (large area) - distFromCenter < focusHalfHeight * 2.0f -> { - GestureType.PINCH_SIZE - } + distFromCenter < focusSize * 0.3f -> GestureType.ROTATE + // Near the blur effect -> size adjustment (large area) + distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE // Far outside -> camera zoom - else -> { - GestureType.PINCH_ZOOM - } + else -> GestureType.PINCH_ZOOM } } /** * Draws the tilt-shift visualization overlay. - * Uses extended geometry so rotated elements don't clip at screen edges. + * Supports both linear and radial modes. */ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { + when (params.mode) { + BlurMode.LINEAR -> drawLinearOverlay(params) + BlurMode.RADIAL -> drawRadialOverlay(params) + } +} + +/** + * Draws the linear mode overlay (horizontal band with rotation). + */ +private fun DrawScope.drawLinearOverlay(params: BlurParameters) { val width = size.width val height = size.height @@ -239,23 +252,23 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f) // Calculate diagonal for extended drawing (ensures coverage when rotated) - val diagonal = kotlin.math.sqrt(width * width + height * height) - val extendedHalf = diagonal // Extend lines/rects well beyond screen + val diagonal = sqrt(width * width + height * height) + val extendedHalf = diagonal rotate(angleDegrees, pivot = Offset(centerX, centerY)) { - // Draw blur zone indicators (top and bottom) - extended horizontally + // Draw blur zone indicators (top and bottom) drawRect( color = blurZoneColor, topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf), - size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf) + size = Size(extendedHalf * 2, extendedHalf) ) drawRect( color = blurZoneColor, topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight), - size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf) + size = Size(extendedHalf * 2, extendedHalf) ) - // Draw focus zone boundary lines - extended horizontally + // Draw focus zone boundary lines drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), @@ -271,7 +284,7 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { pathEffect = dashEffect ) - // Draw center focus line - extended horizontally + // Draw center focus line drawLine( color = focusLineColor, start = Offset(centerX - extendedHalf, centerY), @@ -298,3 +311,70 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) { ) } } + +/** + * Draws the radial mode overlay (ellipse/circle). + */ +private fun DrawScope.drawRadialOverlay(params: BlurParameters) { + val width = size.width + val height = size.height + + val centerX = width * params.positionX + val centerY = height * params.positionY + val focusRadius = height * params.size * 0.5f + val angleDegrees = params.angle * (180f / PI.toFloat()) + + // Colors for overlay + val focusLineColor = Color(0xFFFFB300) // Amber + val blurZoneColor = Color(0x30FFFFFF) // Semi-transparent white + val dashEffect = PathEffect.dashPathEffect(floatArrayOf(15f, 8f), 0f) + + // Calculate ellipse dimensions based on aspect ratio + val ellipseWidth = focusRadius * 2 * params.aspectRatio + val ellipseHeight = focusRadius * 2 + + rotate(angleDegrees, pivot = Offset(centerX, centerY)) { + // Draw focus ellipse outline (inner boundary) + drawOval( + color = focusLineColor, + topLeft = Offset(centerX - ellipseWidth / 2, centerY - ellipseHeight / 2), + size = Size(ellipseWidth, ellipseHeight), + style = Stroke(width = 3.dp.toPx()) + ) + + // Draw outer blur boundary (with falloff) + val outerScale = 1f + params.falloff + drawOval( + color = focusLineColor.copy(alpha = 0.5f), + topLeft = Offset( + centerX - (ellipseWidth * outerScale) / 2, + centerY - (ellipseHeight * outerScale) / 2 + ), + size = Size(ellipseWidth * outerScale, ellipseHeight * outerScale), + style = Stroke(width = 2.dp.toPx(), pathEffect = dashEffect) + ) + + // Draw center crosshair + val crosshairSize = 20.dp.toPx() + drawLine( + color = focusLineColor, + start = Offset(centerX - crosshairSize, centerY), + end = Offset(centerX + crosshairSize, centerY), + strokeWidth = 2.dp.toPx() + ) + drawLine( + color = focusLineColor, + start = Offset(centerX, centerY - crosshairSize), + end = Offset(centerX, centerY + crosshairSize), + strokeWidth = 2.dp.toPx() + ) + + // Draw rotation indicator (small line at top of ellipse) + drawLine( + color = focusLineColor, + start = Offset(centerX, centerY - ellipseHeight / 2 - 5.dp.toPx()), + end = Offset(centerX, centerY - ellipseHeight / 2 - 20.dp.toPx()), + strokeWidth = 3.dp.toPx() + ) + } +} diff --git a/app/src/main/res/raw/tiltshift_fragment.glsl b/app/src/main/res/raw/tiltshift_fragment.glsl index d6b59c8..72a6a30 100644 --- a/app/src/main/res/raw/tiltshift_fragment.glsl +++ b/app/src/main/res/raw/tiltshift_fragment.glsl @@ -1,7 +1,7 @@ #extension GL_OES_EGL_image_external : require // Fragment shader for tilt-shift effect -// Applies gradient blur based on distance from focus line +// Supports both linear and radial blur modes precision mediump float; @@ -9,17 +9,20 @@ precision mediump float; uniform samplerExternalOES uTexture; // Effect parameters +uniform int uMode; // 0 = linear, 1 = radial uniform float uAngle; // Rotation angle in radians uniform float uPositionX; // Horizontal center of focus (0-1) uniform float uPositionY; // Vertical center of focus (0-1) uniform float uSize; // Size of in-focus region (0-1) uniform float uBlurAmount; // Maximum blur intensity (0-1) +uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual) +uniform float uAspectRatio; // Ellipse aspect ratio for radial mode uniform vec2 uResolution; // Texture resolution for proper sampling varying vec2 vTexCoord; -// Calculate signed distance from the focus line -float focusDistance(vec2 uv) { +// Calculate signed distance from the focus region for LINEAR mode +float linearFocusDistance(vec2 uv) { // Center point of the focus region vec2 center = vec2(uPositionX, uPositionY); vec2 offset = uv - center; @@ -35,18 +38,45 @@ float focusDistance(vec2 uv) { return abs(rotatedY); } +// Calculate signed distance from the focus region for RADIAL mode +float radialFocusDistance(vec2 uv) { + // Center point of the focus region + vec2 center = vec2(uPositionX, uPositionY); + vec2 offset = uv - center; + + // Adjust for aspect ratio to create ellipse + // Correct for screen aspect ratio first + float screenAspect = uResolution.x / uResolution.y; + offset.x *= screenAspect; + + // Apply rotation + float adjustedAngle = uAngle - 1.5707963; + float cosA = cos(adjustedAngle); + float sinA = sin(adjustedAngle); + vec2 rotated = vec2( + offset.x * cosA - offset.y * sinA, + offset.x * sinA + offset.y * cosA + ); + + // Apply ellipse aspect ratio + rotated.x /= uAspectRatio; + + // Distance from center (elliptical) + return length(rotated); +} + // Calculate blur factor based on distance from focus float blurFactor(float dist) { - // Smooth transition from in-focus to blurred float halfSize = uSize * 0.5; - float transitionSize = halfSize * 0.5; + // Falloff range scales with the falloff parameter + float transitionSize = halfSize * uFalloff; if (dist < halfSize) { return 0.0; // In focus region } // Smooth falloff using smoothstep - float normalizedDist = (dist - halfSize) / transitionSize; + float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001); return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount; } @@ -72,9 +102,24 @@ vec4 sampleBlurred(vec2 uv, float blur) { vec4 color = vec4(0.0); vec2 texelSize = 1.0 / uResolution; - // Blur direction perpendicular to focus line (adjusted for portrait texture rotation) - float blurAngle = uAngle; // Already perpendicular after -90 adjustment in focusDistance - vec2 blurDir = vec2(cos(blurAngle), sin(blurAngle)); + // For radial mode, blur in radial direction from center + // For linear mode, blur perpendicular to focus line + vec2 blurDir; + if (uMode == 1) { + // Radial: blur away from center + vec2 center = vec2(uPositionX, uPositionY); + vec2 toCenter = uv - center; + float len = length(toCenter); + if (len > 0.001) { + blurDir = toCenter / len; + } else { + blurDir = vec2(1.0, 0.0); + } + } else { + // Linear: blur perpendicular to focus line + float blurAngle = uAngle; + blurDir = vec2(cos(blurAngle), sin(blurAngle)); + } // Scale blur radius by blur amount float radius = blur * 20.0; @@ -90,7 +135,12 @@ vec4 sampleBlurred(vec2 uv, float blur) { } void main() { - float dist = focusDistance(vTexCoord); + float dist; + if (uMode == 1) { + dist = radialFocusDistance(vTexCoord); + } else { + dist = linearFocusDistance(vTexCoord); + } float blur = blurFactor(dist); gl_FragColor = sampleBlurred(vTexCoord, blur); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6c295e2..c3153fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,4 @@ - Tilt-Shift Camera + Naiv Tilt Shift Camera diff --git a/tiltshift-spec.md b/tiltshift-spec.md new file mode 100644 index 0000000..631e0d2 --- /dev/null +++ b/tiltshift-spec.md @@ -0,0 +1,177 @@ +# Tilt-Shift Camera App Specification + +## Overview + +A camera app that applies real-time tilt-shift (miniature) effects to photos and videos. The effect is applied live during preview and capture, with the final output matching what the user sees on screen. + +## Target Platform + +- **Minimum API**: 35 (Android 15) +- **Devices**: Phones (tablet support optional) + +--- + +## Core Features + +### Blur Modes + +#### Linear Mode +- Horizontal band of focus with blur above and below +- Freely rotatable (0–360°) +- Adjustable position (drag anywhere on screen) +- Adjustable band width + +#### Radial/Elliptical Mode +- Central focus point with blur radiating outward +- Adjustable shape: circle to ellipse (aspect ratio control) +- Freely rotatable +- Adjustable position (drag anywhere on screen) +- Adjustable focus region size + +### Blur Parameters (both modes) +- **Intensity**: Strength of the blur effect (0–100%) +- **Falloff**: Transition sharpness from focused to blurred (sharp edge ↔ gradual gradient) + +--- + +## Camera Features + +### Capture Modes +- **Photo**: Full-resolution still capture with effect applied +- **Video**: Real-time recording with effect applied, audio pass-through + +### Zoom +- Smooth continuous zoom gesture (pinch) +- Snap points at each available physical lens (wide, main, telephoto as available) +- Lens indicator showing current zoom level / active lens +- Behaviour matches native camera app + +### Cameras +- Front and rear camera support +- Camera switch button + +--- + +## User Interface + +### Live Preview +- Full-screen camera preview +- Effect applied in real-time +- What you see is what you get (preview matches output exactly) + +### Focus Region Overlay +- Visual indicator showing current focus region while adjusting +- Semi-transparent mask or outline showing blur zone +- Hides after adjustment (or stays subtle) + +### Gesture Controls +| Gesture | Action | +|---------|--------| +| Drag (one finger) | Move focus region | +| Pinch (two finger) | Resize focus region | +| Rotate (two finger) | Rotate focus region / ellipse | +| Pinch (on preview, outside focus region) | Zoom camera | + +### On-Screen Controls +- Mode toggle: Linear / Radial +- Blur intensity slider +- Falloff slider +- Ellipse aspect ratio slider (radial mode only) +- Shutter button (photo) +- Record button (video) +- Camera flip button +- Zoom level indicator +- Settings access + +### Optional Enhancements (Future) +- Saturation boost slider +- Contrast adjustment +- Vignette toggle/strength +- Presets (quick settings combinations) + +--- + +## Output + +### Photos +- **Resolution**: Full sensor resolution +- **Format**: JPEG +- **Location**: Default system pictures directory (DCIM or Pictures) +- **Metadata**: Full EXIF preserved from camera +- **GPS**: Location embedded if device permissions granted +- **Storage**: Processed image only (original not kept) + +### Videos +- **Resolution**: Up to sensor max (may need to cap for real-time performance) +- **Format**: MP4 (H.264 or H.265) +- **Audio**: Pass-through from device microphone +- **Location**: Default system video directory +- **Metadata**: Standard video metadata with location if permitted +- **Storage**: Processed video only + +--- + +## Permissions Required + +- `CAMERA` – camera access +- `RECORD_AUDIO` – video recording +- `ACCESS_FINE_LOCATION` – GPS tagging (optional, prompt user) +- `WRITE_EXTERNAL_STORAGE` – not needed for API 35 (scoped storage) + +--- + +## Technical Approach + +### Camera +- CameraX (Camera2 interop if needed for manual controls) +- Preview bound to OpenGL surface for shader processing + +### Graphics Pipeline +- OpenGL ES 3.0 +- Camera frames → texture → blur shader → display/encode +- Two-pass Gaussian blur with distance-based mask +- Same shader path for preview and capture (guarantees match) + +### Video Encoding +- MediaCodec surface input +- Rendered frames go directly to encoder +- May need resolution/framerate limits for smooth real-time performance on lower-end devices + +### Storage +- MediaStore API for saving to standard locations +- ExifInterface for photo metadata +- Location services for GPS coordinates + +--- + +## Design Decisions + +- **Orientation**: Portrait and landscape supported, UI adapts +- **Aspect ratio**: Native sensor ratio only, no crop options +- **Flash/torch**: Not included +- **Manual exposure**: Not included +- **Reference device**: Pixel 7 Pro (minimum performance target) +- **Zoom during recording**: Locked at recording start +- **Settings persistence**: No, reset to defaults each launch +- **Weaker devices**: No fallback, let them struggle + +--- + +## Out of Scope (v1) + +- Path/curve-based focus regions +- Multiple focus points +- Filter presets or other effects beyond tilt-shift +- RAW capture +- Pro manual controls (ISO, shutter speed, focus) +- Cloud/social sharing integration + +--- + +## Success Criteria + +1. Live preview blur matches saved output exactly +2. Smooth 30fps preview with effect on mid-range devices +3. Video recording at minimum 1080p30 with real-time effect +4. Sub-second capture latency for photos +5. Intuitive gesture controls requiring no tutorial