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 <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-01-29 11:13:31 +01:00
commit d3ca23b71c
11 changed files with 679 additions and 94 deletions

View file

@ -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 {

View file

@ -28,7 +28,8 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:screenOrientation="fullSensor"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.TiltShiftCamera">
<intent-filter>

View file

@ -41,8 +41,12 @@ class CameraManager(private val context: Context) {
private val _maxZoomRatio = MutableStateFlow(1.0f)
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _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
}
}

View file

@ -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
}

View file

@ -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))
}
}

View file

@ -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())
}

View file

@ -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<String?>(null) }
var showControls by remember { mutableStateOf(false) }
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(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<Float>,
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.
*/

View file

@ -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()
)
}
}

View file

@ -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);

View file

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Tilt-Shift Camera</string>
<string name="app_name">Naiv Tilt Shift Camera</string>
</resources>