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
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
177
tiltshift-spec.md
Normal 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 (0–360°)
|
||||||
|
- Adjustable position (drag anywhere on screen)
|
||||||
|
- Adjustable band width
|
||||||
|
|
||||||
|
#### Radial/Elliptical Mode
|
||||||
|
- Central focus point with blur radiating outward
|
||||||
|
- Adjustable shape: circle to ellipse (aspect ratio control)
|
||||||
|
- Freely rotatable
|
||||||
|
- Adjustable position (drag anywhere on screen)
|
||||||
|
- Adjustable focus region size
|
||||||
|
|
||||||
|
### Blur Parameters (both modes)
|
||||||
|
- **Intensity**: Strength of the blur effect (0–100%)
|
||||||
|
- **Falloff**: Transition sharpness from focused to blurred (sharp edge ↔ gradual gradient)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Camera Features
|
||||||
|
|
||||||
|
### Capture Modes
|
||||||
|
- **Photo**: Full-resolution still capture with effect applied
|
||||||
|
- **Video**: Real-time recording with effect applied, audio pass-through
|
||||||
|
|
||||||
|
### Zoom
|
||||||
|
- Smooth continuous zoom gesture (pinch)
|
||||||
|
- Snap points at each available physical lens (wide, main, telephoto as available)
|
||||||
|
- Lens indicator showing current zoom level / active lens
|
||||||
|
- Behaviour matches native camera app
|
||||||
|
|
||||||
|
### Cameras
|
||||||
|
- Front and rear camera support
|
||||||
|
- Camera switch button
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Interface
|
||||||
|
|
||||||
|
### Live Preview
|
||||||
|
- Full-screen camera preview
|
||||||
|
- Effect applied in real-time
|
||||||
|
- What you see is what you get (preview matches output exactly)
|
||||||
|
|
||||||
|
### Focus Region Overlay
|
||||||
|
- Visual indicator showing current focus region while adjusting
|
||||||
|
- Semi-transparent mask or outline showing blur zone
|
||||||
|
- Hides after adjustment (or stays subtle)
|
||||||
|
|
||||||
|
### Gesture Controls
|
||||||
|
| Gesture | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| Drag (one finger) | Move focus region |
|
||||||
|
| Pinch (two finger) | Resize focus region |
|
||||||
|
| Rotate (two finger) | Rotate focus region / ellipse |
|
||||||
|
| Pinch (on preview, outside focus region) | Zoom camera |
|
||||||
|
|
||||||
|
### On-Screen Controls
|
||||||
|
- Mode toggle: Linear / Radial
|
||||||
|
- Blur intensity slider
|
||||||
|
- Falloff slider
|
||||||
|
- Ellipse aspect ratio slider (radial mode only)
|
||||||
|
- Shutter button (photo)
|
||||||
|
- Record button (video)
|
||||||
|
- Camera flip button
|
||||||
|
- Zoom level indicator
|
||||||
|
- Settings access
|
||||||
|
|
||||||
|
### Optional Enhancements (Future)
|
||||||
|
- Saturation boost slider
|
||||||
|
- Contrast adjustment
|
||||||
|
- Vignette toggle/strength
|
||||||
|
- Presets (quick settings combinations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
### Photos
|
||||||
|
- **Resolution**: Full sensor resolution
|
||||||
|
- **Format**: JPEG
|
||||||
|
- **Location**: Default system pictures directory (DCIM or Pictures)
|
||||||
|
- **Metadata**: Full EXIF preserved from camera
|
||||||
|
- **GPS**: Location embedded if device permissions granted
|
||||||
|
- **Storage**: Processed image only (original not kept)
|
||||||
|
|
||||||
|
### Videos
|
||||||
|
- **Resolution**: Up to sensor max (may need to cap for real-time performance)
|
||||||
|
- **Format**: MP4 (H.264 or H.265)
|
||||||
|
- **Audio**: Pass-through from device microphone
|
||||||
|
- **Location**: Default system video directory
|
||||||
|
- **Metadata**: Standard video metadata with location if permitted
|
||||||
|
- **Storage**: Processed video only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions Required
|
||||||
|
|
||||||
|
- `CAMERA` – camera access
|
||||||
|
- `RECORD_AUDIO` – video recording
|
||||||
|
- `ACCESS_FINE_LOCATION` – GPS tagging (optional, prompt user)
|
||||||
|
- `WRITE_EXTERNAL_STORAGE` – not needed for API 35 (scoped storage)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### Camera
|
||||||
|
- CameraX (Camera2 interop if needed for manual controls)
|
||||||
|
- Preview bound to OpenGL surface for shader processing
|
||||||
|
|
||||||
|
### Graphics Pipeline
|
||||||
|
- OpenGL ES 3.0
|
||||||
|
- Camera frames → texture → blur shader → display/encode
|
||||||
|
- Two-pass Gaussian blur with distance-based mask
|
||||||
|
- Same shader path for preview and capture (guarantees match)
|
||||||
|
|
||||||
|
### Video Encoding
|
||||||
|
- MediaCodec surface input
|
||||||
|
- Rendered frames go directly to encoder
|
||||||
|
- May need resolution/framerate limits for smooth real-time performance on lower-end devices
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
- MediaStore API for saving to standard locations
|
||||||
|
- ExifInterface for photo metadata
|
||||||
|
- Location services for GPS coordinates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
- **Orientation**: Portrait and landscape supported, UI adapts
|
||||||
|
- **Aspect ratio**: Native sensor ratio only, no crop options
|
||||||
|
- **Flash/torch**: Not included
|
||||||
|
- **Manual exposure**: Not included
|
||||||
|
- **Reference device**: Pixel 7 Pro (minimum performance target)
|
||||||
|
- **Zoom during recording**: Locked at recording start
|
||||||
|
- **Settings persistence**: No, reset to defaults each launch
|
||||||
|
- **Weaker devices**: No fallback, let them struggle
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (v1)
|
||||||
|
|
||||||
|
- Path/curve-based focus regions
|
||||||
|
- Multiple focus points
|
||||||
|
- Filter presets or other effects beyond tilt-shift
|
||||||
|
- RAW capture
|
||||||
|
- Pro manual controls (ISO, shutter speed, focus)
|
||||||
|
- Cloud/social sharing integration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
1. Live preview blur matches saved output exactly
|
||||||
|
2. Smooth 30fps preview with effect on mid-range devices
|
||||||
|
3. Video recording at minimum 1080p30 with real-time effect
|
||||||
|
4. Sub-second capture latency for photos
|
||||||
|
5. Intuitive gesture controls requiring no tutorial
|
||||||
Loading…
Add table
Add a link
Reference in a new issue