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 { defaultConfig {
applicationId = "no.naiv.tiltshift" applicationId = "no.naiv.tiltshift"
minSdk = 26 minSdk = 35
targetSdk = 35 targetSdk = 35
versionCode = 1 versionCode = 1
versionName = "1.0.0" versionName = "1.0.0"
@ -18,6 +18,8 @@ android {
vectorDrawables { vectorDrawables {
useSupportLibrary = true useSupportLibrary = true
} }
base.archivesName.set("naiv-tilt-shift")
} }
buildTypes { buildTypes {

View file

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

View file

@ -41,8 +41,12 @@ class CameraManager(private val context: Context) {
private val _maxZoomRatio = MutableStateFlow(1.0f) private val _maxZoomRatio = MutableStateFlow(1.0f)
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow() val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
private val _isFrontCamera = MutableStateFlow(false)
val isFrontCamera: StateFlow<Boolean> = _isFrontCamera.asStateFlow()
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
private var surfaceSize: Size = Size(1920, 1080) private var surfaceSize: Size = Size(1920, 1080)
private var lifecycleOwnerRef: LifecycleOwner? = null
/** /**
* Starts the camera with the given lifecycle owner. * Starts the camera with the given lifecycle owner.
@ -53,6 +57,7 @@ class CameraManager(private val context: Context) {
surfaceTextureProvider: () -> SurfaceTexture? surfaceTextureProvider: () -> SurfaceTexture?
) { ) {
this.surfaceTextureProvider = surfaceTextureProvider this.surfaceTextureProvider = surfaceTextureProvider
this.lifecycleOwnerRef = lifecycleOwner
val cameraProviderFuture = ProcessCameraProvider.getInstance(context) val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener({ cameraProviderFuture.addListener({
@ -82,9 +87,13 @@ class CameraManager(private val context: Context) {
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY) .setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
.build() .build()
// Get camera selector from lens controller // Select camera based on front/back preference
val cameraSelector = lensController.getCurrentLens()?.selector val cameraSelector = if (_isFrontCamera.value) {
?: CameraSelector.DEFAULT_BACK_CAMERA CameraSelector.DEFAULT_FRONT_CAMERA
} else {
// Use lens controller for back camera lens selection
lensController.getCurrentLens()?.selector ?: CameraSelector.DEFAULT_BACK_CAMERA
}
try { try {
// Bind use cases to camera // 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) { fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
if (lensController.selectLens(lensId)) { if (!_isFrontCamera.value && lensController.selectLens(lensId)) {
bindCameraUseCases(lifecycleOwner) bindCameraUseCases(lifecycleOwner)
} }
} }
@ -169,5 +187,6 @@ class CameraManager(private val context: Context) {
camera = null camera = null
preview = null preview = null
imageCapture = null imageCapture = null
lifecycleOwnerRef = null
} }
} }

View file

@ -6,25 +6,21 @@ import android.graphics.BitmapFactory
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Matrix import android.graphics.Matrix
import android.graphics.Paint import android.graphics.Paint
import android.graphics.RenderEffect
import android.graphics.Shader
import android.location.Location import android.location.Location
import android.os.Build
import android.view.Surface
import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy import androidx.camera.core.ImageProxy
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.OrientationDetector
import java.io.File
import java.util.concurrent.Executor import java.util.concurrent.Executor
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt
/** /**
* Handles capturing photos with the tilt-shift effect applied. * Handles capturing photos with the tilt-shift effect applied.
@ -135,7 +131,7 @@ class ImageCaptureHandler(
/** /**
* Applies tilt-shift blur effect to a bitmap. * 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 { private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
val width = source.width val width = source.width
@ -143,7 +139,6 @@ class ImageCaptureHandler(
// Create output bitmap // Create output bitmap
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) 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 // For performance, we use a scaled-down version for blur and composite
val scaleFactor = 4 // Blur a 1/4 size image for speed val scaleFactor = 4 // Blur a 1/4 size image for speed
@ -165,7 +160,6 @@ class ImageCaptureHandler(
val mask = createGradientMask(width, height, params) val mask = createGradientMask(width, height, params)
// Composite: blend original with blurred based on mask // Composite: blend original with blurred based on mask
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val pixels = IntArray(width * height) val pixels = IntArray(width * height)
val blurredPixels = IntArray(width * height) val blurredPixels = IntArray(width * height)
val maskPixels = IntArray(width * height) val maskPixels = IntArray(width * height)
@ -199,6 +193,7 @@ class ImageCaptureHandler(
/** /**
* Creates a gradient mask for the tilt-shift effect. * 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 { private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
@ -206,25 +201,47 @@ class ImageCaptureHandler(
val centerX = width * params.positionX val centerX = width * params.positionX
val centerY = height * params.positionY val centerY = height * params.positionY
val focusHalfHeight = height * params.size * 0.5f val focusSize = height * params.size * 0.5f
val transitionHeight = focusHalfHeight * 0.5f val transitionSize = focusSize * params.falloff
val cosAngle = cos(params.angle) val cosAngle = cos(params.angle)
val sinAngle = sin(params.angle) val sinAngle = sin(params.angle)
val screenAspect = width.toFloat() / height.toFloat()
for (y in 0 until height) { for (y in 0 until height) {
for (x in 0 until width) { for (x in 0 until width) {
// Rotate point around focus center val dist = when (params.mode) {
val dx = x - centerX BlurMode.LINEAR -> {
val dy = y - centerY // Rotate point around focus center
val rotatedY = -dx * sinAngle + dy * cosAngle 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 // Adjust for screen aspect ratio
val dist = kotlin.math.abs(rotatedY) 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 { val blurAmount = when {
dist < focusHalfHeight -> 0f dist < focusSize -> 0f
dist < focusHalfHeight + transitionHeight -> { dist < focusSize + transitionSize -> {
(dist - focusHalfHeight) / transitionHeight (dist - focusSize) / transitionSize
} }
else -> 1f else -> 1f
} }

View file

@ -1,20 +1,34 @@
package no.naiv.tiltshift.effect 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. * 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 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 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 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 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 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( data class BlurParameters(
val mode: BlurMode = BlurMode.LINEAR,
val angle: Float = 0f, val angle: Float = 0f,
val positionX: Float = 0.5f, val positionX: Float = 0.5f,
val positionY: Float = 0.5f, val positionY: Float = 0.5f,
val size: Float = 0.3f, 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 { companion object {
val DEFAULT = BlurParameters() val DEFAULT = BlurParameters()
@ -24,6 +38,10 @@ data class BlurParameters(
const val MAX_SIZE = 0.8f const val MAX_SIZE = 0.8f
const val MIN_BLUR = 0.0f const val MIN_BLUR = 0.0f
const val MAX_BLUR = 1.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 { fun withBlurAmount(amount: Float): BlurParameters {
return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR)) 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 // Uniform locations
private var uTextureLocation: Int = 0 private var uTextureLocation: Int = 0
private var uModeLocation: Int = 0
private var uAngleLocation: Int = 0 private var uAngleLocation: Int = 0
private var uPositionXLocation: Int = 0 private var uPositionXLocation: Int = 0
private var uPositionYLocation: Int = 0 private var uPositionYLocation: Int = 0
private var uSizeLocation: Int = 0 private var uSizeLocation: Int = 0
private var uBlurAmountLocation: Int = 0 private var uBlurAmountLocation: Int = 0
private var uFalloffLocation: Int = 0
private var uAspectRatioLocation: Int = 0
private var uResolutionLocation: Int = 0 private var uResolutionLocation: Int = 0
/** /**
@ -61,11 +64,14 @@ class TiltShiftShader(private val context: Context) {
// Get uniform locations // Get uniform locations
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture") uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
uModeLocation = GLES20.glGetUniformLocation(programId, "uMode")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle") uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX") uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX")
uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY") uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize") uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount") uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution") uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
// Clean up shaders (they're linked into program now) // Clean up shaders (they're linked into program now)
@ -85,11 +91,14 @@ class TiltShiftShader(private val context: Context) {
GLES20.glUniform1i(uTextureLocation, 0) GLES20.glUniform1i(uTextureLocation, 0)
// Set effect parameters // Set effect parameters
GLES20.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1f(uAngleLocation, params.angle) GLES20.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionXLocation, params.positionX) GLES20.glUniform1f(uPositionXLocation, params.positionX)
GLES20.glUniform1f(uPositionYLocation, params.positionY) GLES20.glUniform1f(uPositionYLocation, params.positionY)
GLES20.glUniform1f(uSizeLocation, params.size) GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount) GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform1f(uFalloffLocation, params.falloff)
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat()) 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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -54,6 +57,7 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.PhotoSaver
@ -90,6 +94,7 @@ fun CameraScreen(
var isCapturing by remember { mutableStateOf(false) } var isCapturing by remember { mutableStateOf(false) }
var showSaveSuccess by remember { mutableStateOf(false) } var showSaveSuccess by remember { mutableStateOf(false) }
var showSaveError by remember { mutableStateOf<String?>(null) } var showSaveError by remember { mutableStateOf<String?>(null) }
var showControls by remember { mutableStateOf(false) }
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) } var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
var currentLocation by remember { mutableStateOf<Location?>(null) } var currentLocation by remember { mutableStateOf<Location?>(null) }
@ -97,6 +102,7 @@ fun CameraScreen(
val zoomRatio by cameraManager.zoomRatio.collectAsState() val zoomRatio by cameraManager.zoomRatio.collectAsState()
val minZoom by cameraManager.minZoomRatio.collectAsState() val minZoom by cameraManager.minZoomRatio.collectAsState()
val maxZoom by cameraManager.maxZoomRatio.collectAsState() val maxZoom by cameraManager.maxZoomRatio.collectAsState()
val isFrontCamera by cameraManager.isFrontCamera.collectAsState()
// Collect orientation updates // Collect orientation updates
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@ -172,30 +178,86 @@ fun CameraScreen(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
// Top bar // Top bar with controls
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(16.dp), .padding(16.dp)
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) { ) {
// Zoom indicator Row(
ZoomIndicator(currentZoom = zoomRatio) modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
// Settings button (placeholder) verticalAlignment = Alignment.CenterVertically
IconButton(
onClick = { /* TODO: Settings */ }
) { ) {
Icon( // Zoom indicator
imageVector = Icons.Default.Settings, ZoomIndicator(currentZoom = zoomRatio)
contentDescription = "Settings",
tint = Color.White 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 // Bottom controls
Column( Column(
modifier = Modifier modifier = Modifier
@ -204,18 +266,20 @@ fun CameraScreen(
.padding(bottom = 24.dp), .padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Zoom presets // Zoom presets (only show for back camera)
ZoomControl( if (!isFrontCamera) {
currentZoom = zoomRatio, ZoomControl(
minZoom = minZoom, currentZoom = zoomRatio,
maxZoom = maxZoom, minZoom = minZoom,
onZoomSelected = { zoom -> maxZoom = maxZoom,
cameraManager.setZoom(zoom) onZoomSelected = { zoom ->
haptics.click() cameraManager.setZoom(zoom)
} haptics.click()
) }
)
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
}
// Capture button // Capture button
CaptureButton( CaptureButton(
@ -234,7 +298,7 @@ fun CameraScreen(
blurParams = blurParams, blurParams = blurParams,
deviceRotation = currentRotation, deviceRotation = currentRotation,
location = currentLocation, location = currentLocation,
isFrontCamera = false isFrontCamera = isFrontCamera
) )
when (result) { when (result) {
@ -294,7 +358,7 @@ fun CameraScreen(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier modifier = Modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp)) .clip(RoundedCornerShape(16.dp))
.background(Color(0xFFF44336)) .background(Color(0xFFF44336))
.padding(24.dp) .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. * 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.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.DrawScope 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.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import kotlin.math.PI import kotlin.math.PI
import kotlin.math.atan2 import kotlin.math.atan2
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import kotlin.math.sqrt
/** /**
* Type of gesture being performed. * Type of gesture being performed.
@ -182,8 +184,8 @@ fun TiltShiftOverlay(
* Determines the type of two-finger gesture based on touch position. * Determines the type of two-finger gesture based on touch position.
* *
* Zones (from center outward): * Zones (from center outward):
* - Very center (< 30% of focus height): Rotation * - Very center (< 30% of focus size): Rotation
* - Near focus line (30% - 200% of focus height): Size adjustment * - Near focus region (30% - 200% of focus size): Size adjustment
* - Far outside (> 200%): Camera zoom * - Far outside (> 200%): Camera zoom
*/ */
private fun determineGestureType( private fun determineGestureType(
@ -192,39 +194,50 @@ private fun determineGestureType(
height: Float, height: Float,
params: BlurParameters params: BlurParameters
): GestureType { ): GestureType {
// Calculate distance from focus center
val focusCenterX = width * params.positionX val focusCenterX = width * params.positionX
val focusCenterY = height * params.positionY 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 dx = centroid.x - focusCenterX
val dy = centroid.y - focusCenterY 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 { return when {
// Very center of focus zone -> rotation (small area) // Very center of focus zone -> rotation (small area)
distFromCenter < focusHalfHeight * 0.3f -> { distFromCenter < focusSize * 0.3f -> GestureType.ROTATE
GestureType.ROTATE // Near the blur effect -> size adjustment (large area)
} distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE
// Anywhere near the blur effect -> size adjustment (large area)
distFromCenter < focusHalfHeight * 2.0f -> {
GestureType.PINCH_SIZE
}
// Far outside -> camera zoom // Far outside -> camera zoom
else -> { else -> GestureType.PINCH_ZOOM
GestureType.PINCH_ZOOM
}
} }
} }
/** /**
* Draws the tilt-shift visualization overlay. * 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) { 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 width = size.width
val height = size.height val height = size.height
@ -239,23 +252,23 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f) val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
// Calculate diagonal for extended drawing (ensures coverage when rotated) // Calculate diagonal for extended drawing (ensures coverage when rotated)
val diagonal = kotlin.math.sqrt(width * width + height * height) val diagonal = sqrt(width * width + height * height)
val extendedHalf = diagonal // Extend lines/rects well beyond screen val extendedHalf = diagonal
rotate(angleDegrees, pivot = Offset(centerX, centerY)) { rotate(angleDegrees, pivot = Offset(centerX, centerY)) {
// Draw blur zone indicators (top and bottom) - extended horizontally // Draw blur zone indicators (top and bottom)
drawRect( drawRect(
color = blurZoneColor, color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf), topLeft = Offset(centerX - extendedHalf, centerY - focusHalfHeight - extendedHalf),
size = androidx.compose.ui.geometry.Size(extendedHalf * 2, extendedHalf) size = Size(extendedHalf * 2, extendedHalf)
) )
drawRect( drawRect(
color = blurZoneColor, color = blurZoneColor,
topLeft = Offset(centerX - extendedHalf, centerY + focusHalfHeight), 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( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY - focusHalfHeight), start = Offset(centerX - extendedHalf, centerY - focusHalfHeight),
@ -271,7 +284,7 @@ private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
pathEffect = dashEffect pathEffect = dashEffect
) )
// Draw center focus line - extended horizontally // Draw center focus line
drawLine( drawLine(
color = focusLineColor, color = focusLineColor,
start = Offset(centerX - extendedHalf, centerY), 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 #extension GL_OES_EGL_image_external : require
// Fragment shader for tilt-shift effect // 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; precision mediump float;
@ -9,17 +9,20 @@ precision mediump float;
uniform samplerExternalOES uTexture; uniform samplerExternalOES uTexture;
// Effect parameters // Effect parameters
uniform int uMode; // 0 = linear, 1 = radial
uniform float uAngle; // Rotation angle in radians uniform float uAngle; // Rotation angle in radians
uniform float uPositionX; // Horizontal center of focus (0-1) uniform float uPositionX; // Horizontal center of focus (0-1)
uniform float uPositionY; // Vertical 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 uSize; // Size of in-focus region (0-1)
uniform float uBlurAmount; // Maximum blur intensity (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 uniform vec2 uResolution; // Texture resolution for proper sampling
varying vec2 vTexCoord; varying vec2 vTexCoord;
// Calculate signed distance from the focus line // Calculate signed distance from the focus region for LINEAR mode
float focusDistance(vec2 uv) { float linearFocusDistance(vec2 uv) {
// Center point of the focus region // Center point of the focus region
vec2 center = vec2(uPositionX, uPositionY); vec2 center = vec2(uPositionX, uPositionY);
vec2 offset = uv - center; vec2 offset = uv - center;
@ -35,18 +38,45 @@ float focusDistance(vec2 uv) {
return abs(rotatedY); 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 // Calculate blur factor based on distance from focus
float blurFactor(float dist) { float blurFactor(float dist) {
// Smooth transition from in-focus to blurred
float halfSize = uSize * 0.5; float halfSize = uSize * 0.5;
float transitionSize = halfSize * 0.5; // Falloff range scales with the falloff parameter
float transitionSize = halfSize * uFalloff;
if (dist < halfSize) { if (dist < halfSize) {
return 0.0; // In focus region return 0.0; // In focus region
} }
// Smooth falloff using smoothstep // 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; return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
} }
@ -72,9 +102,24 @@ vec4 sampleBlurred(vec2 uv, float blur) {
vec4 color = vec4(0.0); vec4 color = vec4(0.0);
vec2 texelSize = 1.0 / uResolution; vec2 texelSize = 1.0 / uResolution;
// Blur direction perpendicular to focus line (adjusted for portrait texture rotation) // For radial mode, blur in radial direction from center
float blurAngle = uAngle; // Already perpendicular after -90 adjustment in focusDistance // For linear mode, blur perpendicular to focus line
vec2 blurDir = vec2(cos(blurAngle), sin(blurAngle)); 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 // Scale blur radius by blur amount
float radius = blur * 20.0; float radius = blur * 20.0;
@ -90,7 +135,12 @@ vec4 sampleBlurred(vec2 uv, float blur) {
} }
void main() { void main() {
float dist = focusDistance(vTexCoord); float dist;
if (uMode == 1) {
dist = radialFocusDistance(vTexCoord);
} else {
dist = linearFocusDistance(vTexCoord);
}
float blur = blurFactor(dist); float blur = blurFactor(dist);
gl_FragColor = sampleBlurred(vTexCoord, blur); gl_FragColor = sampleBlurred(vTexCoord, blur);

View file

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

177
tiltshift-spec.md Normal file
View file

@ -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 (0360°)
- 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 (0100%)
- **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