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:
parent
e8a5fa4811
commit
d3ca23b71c
11 changed files with 679 additions and 94 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue