diff --git a/CLAUDE.md b/CLAUDE.md index 09d9b5d..f405c1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,5 +99,6 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after ## Known limitations / future work - `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing. -- Dependencies updated to March 2026 versions (AGP 9.1, Kotlin 2.3, Compose BOM 2026.03). +- Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API. +- Dependencies are pinned to late-2024 versions; periodic bumps recommended. - Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afba03e..5e3c123 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } @@ -24,7 +25,7 @@ val vCode = versionProperties["versionCode"].toString().toInt() android { namespace = "no.naiv.tiltshift" - compileSdk = 36 + compileSdk = 35 signingConfigs { create("release") { @@ -41,7 +42,7 @@ android { defaultConfig { applicationId = "no.naiv.tiltshift" minSdk = 35 - targetSdk = 36 + targetSdk = 35 versionCode = vCode versionName = "$vMajor.$vMinor.$vPatch" @@ -69,6 +70,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { compose = true } @@ -107,8 +112,8 @@ dependencies { // Location implementation(libs.play.services.location) - // Test - testImplementation(libs.junit) + // Permissions + implementation(libs.accompanist.permissions) // Debug debugImplementation(libs.androidx.ui.tooling) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 7312d90..2fb4752 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,2 +1,5 @@ # Add project specific ProGuard rules here. # CameraX and GMS Location ship their own consumer ProGuard rules. + +# Keep OpenGL shader-related code (accessed via reflection by GLSL pipeline) +-keep class no.naiv.tiltshift.effect.** { *; } diff --git a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt index 7a379a2..ab0cca3 100644 --- a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt +++ b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt @@ -2,15 +2,12 @@ package no.naiv.tiltshift import android.Manifest import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import android.os.Bundle import android.provider.Settings import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -29,24 +26,23 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale import no.naiv.tiltshift.ui.CameraScreen import no.naiv.tiltshift.ui.theme.AppColors @@ -70,47 +66,21 @@ class MainActivity : ComponentActivity() { } } +@OptIn(ExperimentalPermissionsApi::class) @Composable private fun TiltShiftApp() { - val context = LocalContext.current - val activity = context as? ComponentActivity - - var cameraGranted by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) - == PackageManager.PERMISSION_GRANTED + val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) + val locationPermissions = rememberMultiplePermissionsState( + listOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION ) - } - var locationGranted by remember { - mutableStateOf( - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED || - ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) - == PackageManager.PERMISSION_GRANTED - ) - } - - // Track whether the camera permission dialog has returned a result, - // so we can distinguish "never asked" from "permanently denied" - var cameraResultReceived by remember { mutableStateOf(false) } - - val cameraPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestPermission() - ) { granted -> - cameraGranted = granted - cameraResultReceived = true - } - - val locationPermissionLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.RequestMultiplePermissions() - ) { permissions -> - locationGranted = permissions.values.any { it } - } + ) // Request camera permission on launch LaunchedEffect(Unit) { - if (!cameraGranted) { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + if (!cameraPermission.status.isGranted) { + cameraPermission.launchPermissionRequest() } } @@ -120,47 +90,28 @@ private fun TiltShiftApp() { .background(Color.Black) ) { when { - cameraGranted -> { + cameraPermission.status.isGranted -> { // Camera permission granted - show camera CameraScreen() // Request location in background (for EXIF GPS) LaunchedEffect(Unit) { - if (!locationGranted) { - locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) + if (!locationPermissions.allPermissionsGranted) { + locationPermissions.launchMultiplePermissionRequest() } } } else -> { - // Permanently denied: user has responded to the dialog, but permission - // is still denied and the system won't show the dialog again - val cameraPermanentlyDenied = cameraResultReceived && - activity?.let { - !ActivityCompat.shouldShowRequestPermissionRationale( - it, Manifest.permission.CAMERA - ) - } ?: false + // Permanently denied: not granted AND rationale not shown + val cameraPermanentlyDenied = !cameraPermission.status.isGranted && + !cameraPermission.status.shouldShowRationale // Show permission request UI PermissionRequestScreen( - onRequestCamera = { - cameraPermissionLauncher.launch(Manifest.permission.CAMERA) - }, - onRequestLocation = { - locationPermissionLauncher.launch( - arrayOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - ) - ) - }, - cameraGranted = false, - locationGranted = locationGranted, + onRequestCamera = { cameraPermission.launchPermissionRequest() }, + onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() }, + cameraGranted = cameraPermission.status.isGranted, + locationGranted = locationPermissions.allPermissionsGranted, cameraPermanentlyDenied = cameraPermanentlyDenied ) } diff --git a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt index 51d42ed..00f9107 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -174,14 +174,24 @@ class CameraManager(private val context: Context) { } /** - * Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom - * gestures accumulate correctly (each frame uses the latest ratio as its base). - * If the camera rejects the value, the next successful set corrects the state. + * Sets the zoom ratio. Updates UI state only after the camera confirms the change. */ fun setZoom(ratio: Float) { val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value) - _zoomRatio.value = clamped - camera?.cameraControl?.setZoomRatio(clamped) + val future = camera?.cameraControl?.setZoomRatio(clamped) + if (future != null) { + future.addListener({ + try { + future.get() + _zoomRatio.value = clamped + } catch (e: Exception) { + Log.w(TAG, "Zoom operation failed", e) + } + }, ContextCompat.getMainExecutor(context)) + } else { + // Optimistic update when camera not available (e.g. during init) + _zoomRatio.value = clamped + } } /** diff --git a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt index b0401e7..4736e52 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -18,7 +18,6 @@ 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.StackBlur import java.util.concurrent.Executor import kotlin.coroutines.resume import kotlin.math.cos @@ -387,7 +386,7 @@ class ImageCaptureHandler( scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) - blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) + blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) scaled.recycle() scaled = null @@ -531,4 +530,230 @@ class ImageCaptureHandler( return fullPixels } + /** + * Fast stack blur algorithm. + */ + private fun stackBlur(bitmap: Bitmap, radius: Int): Bitmap { + if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true) + + val w = bitmap.width + val h = bitmap.height + val pix = IntArray(w * h) + bitmap.getPixels(pix, 0, w, 0, 0, w, h) + + val wm = w - 1 + val hm = h - 1 + val wh = w * h + val div = radius + radius + 1 + + val r = IntArray(wh) + val g = IntArray(wh) + val b = IntArray(wh) + var rsum: Int + var gsum: Int + var bsum: Int + var x: Int + var y: Int + var i: Int + var p: Int + var yp: Int + var yi: Int + var yw: Int + val vmin = IntArray(maxOf(w, h)) + + var divsum = (div + 1) shr 1 + divsum *= divsum + val dv = IntArray(256 * divsum) + for (i2 in 0 until 256 * divsum) { + dv[i2] = (i2 / divsum) + } + + yi = 0 + yw = 0 + + val stack = Array(div) { IntArray(3) } + var stackpointer: Int + var stackstart: Int + var sir: IntArray + var rbs: Int + val r1 = radius + 1 + var routsum: Int + var goutsum: Int + var boutsum: Int + var rinsum: Int + var ginsum: Int + var binsum: Int + + for (y2 in 0 until h) { + rinsum = 0 + ginsum = 0 + binsum = 0 + routsum = 0 + goutsum = 0 + boutsum = 0 + rsum = 0 + gsum = 0 + bsum = 0 + for (i2 in -radius..radius) { + p = pix[yi + minOf(wm, maxOf(i2, 0))] + sir = stack[i2 + radius] + sir[0] = (p and 0xff0000) shr 16 + sir[1] = (p and 0x00ff00) shr 8 + sir[2] = (p and 0x0000ff) + rbs = r1 - kotlin.math.abs(i2) + rsum += sir[0] * rbs + gsum += sir[1] * rbs + bsum += sir[2] * rbs + if (i2 > 0) { + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + } else { + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + } + } + stackpointer = radius + + for (x2 in 0 until w) { + r[yi] = dv[rsum] + g[yi] = dv[gsum] + b[yi] = dv[bsum] + + rsum -= routsum + gsum -= goutsum + bsum -= boutsum + + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + + routsum -= sir[0] + goutsum -= sir[1] + boutsum -= sir[2] + + if (y2 == 0) { + vmin[x2] = minOf(x2 + radius + 1, wm) + } + p = pix[yw + vmin[x2]] + + sir[0] = (p and 0xff0000) shr 16 + sir[1] = (p and 0x00ff00) shr 8 + sir[2] = (p and 0x0000ff) + + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + + rsum += rinsum + gsum += ginsum + bsum += binsum + + stackpointer = (stackpointer + 1) % div + sir = stack[(stackpointer) % div] + + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + + rinsum -= sir[0] + ginsum -= sir[1] + binsum -= sir[2] + + yi++ + } + yw += w + } + for (x2 in 0 until w) { + rinsum = 0 + ginsum = 0 + binsum = 0 + routsum = 0 + goutsum = 0 + boutsum = 0 + rsum = 0 + gsum = 0 + bsum = 0 + yp = -radius * w + for (i2 in -radius..radius) { + yi = maxOf(0, yp) + x2 + + sir = stack[i2 + radius] + + sir[0] = r[yi] + sir[1] = g[yi] + sir[2] = b[yi] + + rbs = r1 - kotlin.math.abs(i2) + + rsum += r[yi] * rbs + gsum += g[yi] * rbs + bsum += b[yi] * rbs + + if (i2 > 0) { + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + } else { + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + } + + if (i2 < hm) { + yp += w + } + } + yi = x2 + stackpointer = radius + for (y2 in 0 until h) { + pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] + + rsum -= routsum + gsum -= goutsum + bsum -= boutsum + + stackstart = stackpointer - radius + div + sir = stack[stackstart % div] + + routsum -= sir[0] + goutsum -= sir[1] + boutsum -= sir[2] + + if (x2 == 0) { + vmin[y2] = minOf(y2 + r1, hm) * w + } + p = x2 + vmin[y2] + + sir[0] = r[p] + sir[1] = g[p] + sir[2] = b[p] + + rinsum += sir[0] + ginsum += sir[1] + binsum += sir[2] + + rsum += rinsum + gsum += ginsum + bsum += binsum + + stackpointer = (stackpointer + 1) % div + sir = stack[stackpointer] + + routsum += sir[0] + goutsum += sir[1] + boutsum += sir[2] + + rinsum -= sir[0] + ginsum -= sir[1] + binsum -= sir[2] + + yi += w + } + } + + val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) + result.setPixels(pix, 0, w, 0, 0, w, h) + return result + } } diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt index d170d58..3258fa8 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,7 +5,6 @@ import android.graphics.SurfaceTexture import android.opengl.GLES11Ext import android.opengl.GLES20 import android.opengl.GLSurfaceView -import android.util.Log import java.nio.ByteBuffer import java.nio.ByteOrder import java.nio.FloatBuffer @@ -13,16 +12,10 @@ import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.opengles.GL10 /** - * OpenGL renderer for applying tilt-shift effect to camera preview - * using a two-pass separable Gaussian blur. + * OpenGL renderer for applying tilt-shift effect to camera preview. * - * Rendering pipeline (3 draw calls per frame): - * 1. **Passthrough**: camera texture → FBO-A (handles coordinate transform via vertex/texcoord) - * 2. **Horizontal blur**: FBO-A → FBO-B (13-tap Gaussian, tilt-shift mask) - * 3. **Vertical blur**: FBO-B → screen (13-tap Gaussian, tilt-shift mask) - * - * The passthrough decouples the camera's rotated coordinate system from the blur - * passes, which work entirely in screen space. + * This renderer receives camera frames via SurfaceTexture and applies + * the tilt-shift blur effect using GLSL shaders. */ class TiltShiftRenderer( private val context: Context, @@ -30,30 +23,16 @@ class TiltShiftRenderer( private val onFrameAvailable: () -> Unit ) : GLSurfaceView.Renderer { - companion object { - private const val TAG = "TiltShiftRenderer" - } - private lateinit var shader: TiltShiftShader private var surfaceTexture: SurfaceTexture? = null private var cameraTextureId: Int = 0 - // Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only) - private lateinit var cameraVertexBuffer: FloatBuffer - private lateinit var cameraTexCoordBuffer: FloatBuffer - - // Fullscreen quad for blur passes (no crop, standard texcoords) - private lateinit var fullscreenVertexBuffer: FloatBuffer - private lateinit var fullscreenTexCoordBuffer: FloatBuffer + private lateinit var vertexBuffer: FloatBuffer + private lateinit var texCoordBuffer: FloatBuffer private var surfaceWidth: Int = 0 private var surfaceHeight: Int = 0 - // FBO resources: one framebuffer, two color textures for ping-pong - private var fboId: Int = 0 - private var fboTexA: Int = 0 - private var fboTexB: Int = 0 - // Current effect parameters (updated from UI thread) @Volatile var blurParameters: BlurParameters = BlurParameters.DEFAULT @@ -90,33 +69,27 @@ class TiltShiftRenderer( @Volatile private var currentTexCoords = texCoordsBack - @Volatile - private var updateTexCoordBuffer = false - override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { GLES20.glClearColor(0f, 0f, 0f, 1f) + // Initialize shader shader = TiltShiftShader(context) shader.initialize() - // Camera quad vertex buffer (crop-to-fill, recomputed when resolution is known) - cameraVertexBuffer = allocateFloatBuffer(8) - cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) - cameraVertexBuffer.position(0) + // Allocate vertex buffer (8 floats = 4 vertices × 2 components) + vertexBuffer = ByteBuffer.allocateDirect(8 * 4) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + // Fill with default full-screen quad; will be recomputed when camera resolution is known + vertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) + vertexBuffer.position(0) - // Camera texcoord buffer (rotated for portrait) - cameraTexCoordBuffer = allocateFloatBuffer(8) - cameraTexCoordBuffer.put(currentTexCoords) - cameraTexCoordBuffer.position(0) - - // Fullscreen quad for blur passes (standard coords) - fullscreenVertexBuffer = allocateFloatBuffer(8) - fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) - fullscreenVertexBuffer.position(0) - - fullscreenTexCoordBuffer = allocateFloatBuffer(8) - fullscreenTexCoordBuffer.put(floatArrayOf(0f, 0f, 1f, 0f, 0f, 1f, 1f, 1f)) - fullscreenTexCoordBuffer.position(0) + // Create texture coordinate buffer + texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4) + .order(ByteOrder.nativeOrder()) + .asFloatBuffer() + .put(currentTexCoords) + texCoordBuffer.position(0) // Create camera texture val textures = IntArray(1) @@ -141,75 +114,88 @@ class TiltShiftRenderer( surfaceWidth = width surfaceHeight = height vertexBufferDirty = true - recreateFBOs(width, height) } override fun onDrawFrame(gl: GL10?) { + // Update texture with latest camera frame surfaceTexture?.updateTexImage() + // Recompute vertex buffer for crop-to-fill when camera or surface dimensions change if (vertexBufferDirty) { recomputeVertices() vertexBufferDirty = false } + // Update texture coordinate buffer if camera changed if (updateTexCoordBuffer) { - cameraTexCoordBuffer.clear() - cameraTexCoordBuffer.put(currentTexCoords) - cameraTexCoordBuffer.position(0) + texCoordBuffer.clear() + texCoordBuffer.put(currentTexCoords) + texCoordBuffer.position(0) updateTexCoordBuffer = false } - val params = blurParameters - - // --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) --- - GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId) - GLES20.glFramebufferTexture2D( - GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, - GLES20.GL_TEXTURE_2D, fboTexA, 0 - ) - GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.usePassthrough(cameraTextureId) - drawQuad( - shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, - cameraVertexBuffer, cameraTexCoordBuffer + + // Use shader and set parameters + shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera) + + // Set vertex positions + GLES20.glEnableVertexAttribArray(shader.aPositionLocation) + GLES20.glVertexAttribPointer( + shader.aPositionLocation, + 2, + GLES20.GL_FLOAT, + false, + 0, + vertexBuffer ) - // --- Pass 2: FBO-A → FBO-B (horizontal blur) --- - GLES20.glFramebufferTexture2D( - GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, - GLES20.GL_TEXTURE_2D, fboTexB, 0 - ) - GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.useBlurPass(fboTexA, params, surfaceWidth, surfaceHeight, 1f, 0f) - drawQuad( - shader.blurPositionLoc, shader.blurTexCoordLoc, - fullscreenVertexBuffer, fullscreenTexCoordBuffer + // Set texture coordinates + GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation) + GLES20.glVertexAttribPointer( + shader.aTexCoordLocation, + 2, + GLES20.GL_FLOAT, + false, + 0, + texCoordBuffer ) - // --- Pass 3: FBO-B → screen (vertical blur) --- - GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0) - GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight) - GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) - shader.useBlurPass(fboTexB, params, surfaceWidth, surfaceHeight, 0f, 1f) - drawQuad( - shader.blurPositionLoc, shader.blurTexCoordLoc, - fullscreenVertexBuffer, fullscreenTexCoordBuffer - ) + // Draw quad + GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) + + // Cleanup + GLES20.glDisableVertexAttribArray(shader.aPositionLocation) + GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation) } + /** + * Updates blur parameters. Thread-safe. + */ fun updateParameters(params: BlurParameters) { blurParameters = params } + /** + * Sets whether using front camera. Updates texture coordinates accordingly. + * Thread-safe - actual buffer update happens on next frame. + */ fun setFrontCamera(front: Boolean) { if (isFrontCamera != front) { isFrontCamera = front currentTexCoords = if (front) texCoordsFront else texCoordsBack + // Buffer will be updated on next draw updateTexCoordBuffer = true } } + @Volatile + private var updateTexCoordBuffer = false + + /** + * Sets the camera preview resolution for crop-to-fill aspect ratio correction. + * Thread-safe — vertex buffer is recomputed on the next frame. + */ fun setCameraResolution(width: Int, height: Int) { if (cameraWidth != width || cameraHeight != height) { cameraWidth = width @@ -218,6 +204,45 @@ class TiltShiftRenderer( } } + /** + * Recomputes vertex positions to achieve crop-to-fill. + * + * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, + * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex + * quad so the camera frame fills the screen without stretching — the GPU clips the overflow. + */ + private fun recomputeVertices() { + var scaleX = 1f + var scaleY = 1f + + if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { + // After 90° rotation: portrait width = cameraHeight, portrait height = cameraWidth + val cameraRatio = cameraHeight.toFloat() / cameraWidth + val screenRatio = surfaceWidth.toFloat() / surfaceHeight + + if (cameraRatio > screenRatio) { + // Camera wider than screen → crop sides + scaleX = cameraRatio / screenRatio + } else { + // Camera taller than screen → crop top/bottom + scaleY = screenRatio / cameraRatio + } + } + + vertexBuffer.clear() + vertexBuffer.put(floatArrayOf( + -scaleX, -scaleY, + scaleX, -scaleY, + -scaleX, scaleY, + scaleX, scaleY + )) + vertexBuffer.position(0) + } + + /** + * Releases OpenGL resources. + * Must be called from GL thread. + */ fun release() { shader.release() surfaceTexture?.release() @@ -227,117 +252,5 @@ class TiltShiftRenderer( GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0) cameraTextureId = 0 } - - deleteFBOs() - } - - // --- Private helpers --- - - private fun drawQuad( - positionLoc: Int, - texCoordLoc: Int, - vertices: FloatBuffer, - texCoords: FloatBuffer - ) { - GLES20.glEnableVertexAttribArray(positionLoc) - GLES20.glVertexAttribPointer(positionLoc, 2, GLES20.GL_FLOAT, false, 0, vertices) - - GLES20.glEnableVertexAttribArray(texCoordLoc) - GLES20.glVertexAttribPointer(texCoordLoc, 2, GLES20.GL_FLOAT, false, 0, texCoords) - - GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) - - GLES20.glDisableVertexAttribArray(positionLoc) - GLES20.glDisableVertexAttribArray(texCoordLoc) - } - - /** - * Recomputes camera vertex positions to achieve crop-to-fill. - * - * The camera sensor is landscape; after the 90° rotation applied via texture coordinates, - * the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex - * quad so the camera frame fills the surface without stretching — the GPU clips the overflow. - */ - private fun recomputeVertices() { - var scaleX = 1f - var scaleY = 1f - - if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) { - val cameraRatio = cameraHeight.toFloat() / cameraWidth - val screenRatio = surfaceWidth.toFloat() / surfaceHeight - - if (cameraRatio > screenRatio) { - scaleX = cameraRatio / screenRatio - } else { - scaleY = screenRatio / cameraRatio - } - } - - cameraVertexBuffer.clear() - cameraVertexBuffer.put(floatArrayOf( - -scaleX, -scaleY, - scaleX, -scaleY, - -scaleX, scaleY, - scaleX, scaleY - )) - cameraVertexBuffer.position(0) - } - - private fun recreateFBOs(width: Int, height: Int) { - deleteFBOs() - - // Create two color textures for ping-pong - val texIds = IntArray(2) - GLES20.glGenTextures(2, texIds, 0) - fboTexA = texIds[0] - fboTexB = texIds[1] - - for (texId in texIds) { - GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texId) - GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR) - GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR) - GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE) - GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE) - GLES20.glTexImage2D( - GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA, - width, height, 0, - GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, null - ) - } - - // Create single FBO (we swap the attached texture for ping-pong) - val fbos = IntArray(1) - GLES20.glGenFramebuffers(1, fbos, 0) - fboId = fbos[0] - - // Verify with texture A - GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, fboId) - GLES20.glFramebufferTexture2D( - GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, - GLES20.GL_TEXTURE_2D, fboTexA, 0 - ) - val status = GLES20.glCheckFramebufferStatus(GLES20.GL_FRAMEBUFFER) - if (status != GLES20.GL_FRAMEBUFFER_COMPLETE) { - Log.e(TAG, "FBO incomplete: $status") - } - GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0) - } - - private fun deleteFBOs() { - if (fboId != 0) { - GLES20.glDeleteFramebuffers(1, intArrayOf(fboId), 0) - fboId = 0 - } - if (fboTexA != 0 || fboTexB != 0) { - GLES20.glDeleteTextures(2, intArrayOf(fboTexA, fboTexB), 0) - fboTexA = 0 - fboTexB = 0 - } - } - - private fun allocateFloatBuffer(floatCount: Int): FloatBuffer { - return ByteBuffer.allocateDirect(floatCount * 4) - .order(ByteOrder.nativeOrder()) - .asFloatBuffer() } } diff --git a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt index cdafd8f..2c3a7d5 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -4,167 +4,57 @@ import android.content.Context import android.opengl.GLES11Ext import android.opengl.GLES20 import no.naiv.tiltshift.R -import java.io.BufferedReader -import java.io.InputStreamReader import kotlin.math.cos import kotlin.math.sin +import java.io.BufferedReader +import java.io.InputStreamReader /** - * Manages OpenGL shader programs for the two-pass tilt-shift effect. - * - * Two programs: - * - **Passthrough**: copies camera texture (external OES) to an FBO, handling the - * coordinate transform via vertex/texcoord setup. - * - **Blur**: applies a directional Gaussian blur with tilt-shift mask. - * Used twice per frame (horizontal then vertical) via the [uBlurDirection] uniform. + * Manages OpenGL shader programs for the tilt-shift effect. */ class TiltShiftShader(private val context: Context) { - // --- Passthrough program (camera → FBO) --- - - private var passthroughProgramId: Int = 0 - - var passthroughPositionLoc: Int = 0 + var programId: Int = 0 private set - var passthroughTexCoordLoc: Int = 0 - private set - private var passthroughTextureLoc: Int = 0 - // --- Blur program (FBO → FBO/screen) --- - - private var blurProgramId: Int = 0 - - var blurPositionLoc: Int = 0 + // Attribute locations + var aPositionLocation: Int = 0 private set - var blurTexCoordLoc: Int = 0 + var aTexCoordLocation: Int = 0 private set - private var blurTextureLoc: Int = 0 - private var blurModeLoc: Int = 0 - private var blurPositionXLoc: Int = 0 - private var blurPositionYLoc: Int = 0 - private var blurSizeLoc: Int = 0 - private var blurAmountLoc: Int = 0 - private var blurFalloffLoc: Int = 0 - private var blurAspectRatioLoc: Int = 0 - private var blurResolutionLoc: Int = 0 - private var blurCosAngleLoc: Int = 0 - private var blurSinAngleLoc: Int = 0 - private var blurDirectionLoc: Int = 0 + + // Uniform locations + private var uTextureLocation: Int = 0 + private var uModeLocation: Int = 0 + private var uIsFrontCameraLocation: 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 + private var uCosAngleLocation: Int = 0 + private var uSinAngleLocation: Int = 0 /** - * Compiles and links both shader programs. + * Compiles and links the shader program. * Must be called from GL thread. */ fun initialize() { val vertexSource = loadShaderSource(R.raw.tiltshift_vertex) + val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment) + val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource) + val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource) - // Passthrough program - val passthroughFragSource = loadShaderSource(R.raw.tiltshift_passthrough_fragment) - val passthroughFragShader = compileShader(GLES20.GL_FRAGMENT_SHADER, passthroughFragSource) - passthroughProgramId = linkProgram(vertexShader, passthroughFragShader) - GLES20.glDeleteShader(passthroughFragShader) - - passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition") - passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord") - passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture") - - // Blur program - val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment) - val blurFragShader = compileShader(GLES20.GL_FRAGMENT_SHADER, blurFragSource) - blurProgramId = linkProgram(vertexShader, blurFragShader) - GLES20.glDeleteShader(blurFragShader) - - blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition") - blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord") - blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture") - blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode") - blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX") - blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY") - blurSizeLoc = GLES20.glGetUniformLocation(blurProgramId, "uSize") - blurAmountLoc = GLES20.glGetUniformLocation(blurProgramId, "uBlurAmount") - blurFalloffLoc = GLES20.glGetUniformLocation(blurProgramId, "uFalloff") - blurAspectRatioLoc = GLES20.glGetUniformLocation(blurProgramId, "uAspectRatio") - blurResolutionLoc = GLES20.glGetUniformLocation(blurProgramId, "uResolution") - blurCosAngleLoc = GLES20.glGetUniformLocation(blurProgramId, "uCosAngle") - blurSinAngleLoc = GLES20.glGetUniformLocation(blurProgramId, "uSinAngle") - blurDirectionLoc = GLES20.glGetUniformLocation(blurProgramId, "uBlurDirection") - - // Vertex shader is linked into both programs and can be freed - GLES20.glDeleteShader(vertexShader) - } - - /** - * Activates the passthrough program and binds the camera texture. - */ - fun usePassthrough(cameraTextureId: Int) { - GLES20.glUseProgram(passthroughProgramId) - GLES20.glActiveTexture(GLES20.GL_TEXTURE0) - GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId) - GLES20.glUniform1i(passthroughTextureLoc, 0) - } - - /** - * Activates the blur program and sets all uniforms for one blur pass. - * - * @param fboTextureId The FBO color attachment to sample from. - * @param params Current blur parameters. - * @param width Surface width in pixels. - * @param height Surface height in pixels. - * @param dirX Blur direction X component (1 for horizontal pass, 0 for vertical). - * @param dirY Blur direction Y component (0 for horizontal pass, 1 for vertical). - */ - fun useBlurPass( - fboTextureId: Int, - params: BlurParameters, - width: Int, - height: Int, - dirX: Float, - dirY: Float - ) { - GLES20.glUseProgram(blurProgramId) - - GLES20.glActiveTexture(GLES20.GL_TEXTURE0) - GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId) - GLES20.glUniform1i(blurTextureLoc, 0) - - GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0) - GLES20.glUniform1f(blurPositionXLoc, params.positionX) - GLES20.glUniform1f(blurPositionYLoc, params.positionY) - GLES20.glUniform1f(blurSizeLoc, params.size) - GLES20.glUniform1f(blurAmountLoc, params.blurAmount) - GLES20.glUniform1f(blurFalloffLoc, params.falloff) - GLES20.glUniform1f(blurAspectRatioLoc, params.aspectRatio) - GLES20.glUniform2f(blurResolutionLoc, width.toFloat(), height.toFloat()) - - // Raw screen-space angle (no camera rotation adjustment needed — FBO is already - // in screen orientation after the passthrough pass) - GLES20.glUniform1f(blurCosAngleLoc, cos(params.angle)) - GLES20.glUniform1f(blurSinAngleLoc, sin(params.angle)) - - GLES20.glUniform2f(blurDirectionLoc, dirX, dirY) - } - - /** - * Releases both shader programs. - */ - fun release() { - if (passthroughProgramId != 0) { - GLES20.glDeleteProgram(passthroughProgramId) - passthroughProgramId = 0 - } - if (blurProgramId != 0) { - GLES20.glDeleteProgram(blurProgramId) - blurProgramId = 0 - } - } - - private fun linkProgram(vertexShader: Int, fragmentShader: Int): Int { - val programId = GLES20.glCreateProgram() + programId = GLES20.glCreateProgram() GLES20.glAttachShader(programId, vertexShader) GLES20.glAttachShader(programId, fragmentShader) GLES20.glLinkProgram(programId) + // Check for link errors val linkStatus = IntArray(1) GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0) if (linkStatus[0] == 0) { @@ -173,7 +63,72 @@ class TiltShiftShader(private val context: Context) { throw RuntimeException("Shader program link failed: $error") } - return programId + // Get attribute locations + aPositionLocation = GLES20.glGetAttribLocation(programId, "aPosition") + aTexCoordLocation = GLES20.glGetAttribLocation(programId, "aTexCoord") + + // Get uniform locations + uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture") + uModeLocation = GLES20.glGetUniformLocation(programId, "uMode") + uIsFrontCameraLocation = GLES20.glGetUniformLocation(programId, "uIsFrontCamera") + 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") + uCosAngleLocation = GLES20.glGetUniformLocation(programId, "uCosAngle") + uSinAngleLocation = GLES20.glGetUniformLocation(programId, "uSinAngle") + + // Clean up shaders (they're linked into program now) + GLES20.glDeleteShader(vertexShader) + GLES20.glDeleteShader(fragmentShader) + } + + /** + * Uses the shader program and sets uniforms. + */ + fun use(textureId: Int, params: BlurParameters, width: Int, height: Int, isFrontCamera: Boolean = false) { + GLES20.glUseProgram(programId) + + // Bind camera texture + GLES20.glActiveTexture(GLES20.GL_TEXTURE0) + GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId) + GLES20.glUniform1i(uTextureLocation, 0) + + // Set effect parameters + GLES20.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0) + GLES20.glUniform1i(uIsFrontCameraLocation, if (isFrontCamera) 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()) + + // Precompute angle trig on CPU to avoid per-fragment transcendental calls. + // The adjusted angle accounts for the 90deg coordinate transform. + val adjustedAngle = if (isFrontCamera) { + -params.angle - (Math.PI / 2).toFloat() + } else { + params.angle + (Math.PI / 2).toFloat() + } + GLES20.glUniform1f(uCosAngleLocation, cos(adjustedAngle)) + GLES20.glUniform1f(uSinAngleLocation, sin(adjustedAngle)) + } + + /** + * Releases shader resources. + */ + fun release() { + if (programId != 0) { + GLES20.glDeleteProgram(programId) + programId = 0 + } } private fun loadShaderSource(resourceId: Int): String { @@ -187,6 +142,7 @@ class TiltShiftShader(private val context: Context) { GLES20.glShaderSource(shader, source) GLES20.glCompileShader(shader) + // Check for compile errors val compileStatus = IntArray(1) GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0) if (compileStatus[0] == 0) { diff --git a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt index 7841f90..8facb4c 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -17,6 +17,18 @@ import java.time.format.DateTimeFormatter import java.util.Locale +/** + * Result of a photo save operation. + */ +sealed class SaveResult { + data class Success( + val uri: Uri, + val originalUri: Uri? = null, + val thumbnail: android.graphics.Bitmap? = null + ) : SaveResult() + data class Error(val message: String, val exception: Exception? = null) : SaveResult() +} + /** * Handles saving captured photos to the device gallery. */ diff --git a/app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt b/app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt deleted file mode 100644 index 4e5e700..0000000 --- a/app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt +++ /dev/null @@ -1,16 +0,0 @@ -package no.naiv.tiltshift.storage - -import android.graphics.Bitmap -import android.net.Uri - -/** - * Result of a photo save operation. - */ -sealed class SaveResult { - data class Success( - val uri: Uri, - val originalUri: Uri? = null, - val thumbnail: Bitmap? = null - ) : SaveResult() - data class Error(val message: String, val exception: Exception? = null) : SaveResult() -} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index bbb706d..8012961 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,16 +1,19 @@ package no.naiv.tiltshift.ui import android.content.Intent +import android.graphics.Bitmap import android.graphics.SurfaceTexture import android.opengl.GLSurfaceView import android.util.Log +import android.view.Surface import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.Image +import androidx.compose.animation.scaleIn import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.systemGestureExclusion import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,21 +29,24 @@ 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.foundation.systemGestureExclusion import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image 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.FlipCameraAndroid +import androidx.compose.material.icons.filled.PhotoLibrary +import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.LocationOff import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.PhotoLibrary import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.CircularProgressIndicator 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 @@ -59,9 +65,10 @@ import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.LiveRegionMode -import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp @@ -72,6 +79,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest +import no.naiv.tiltshift.effect.BlurMode import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.ui.theme.AppColors @@ -600,3 +608,265 @@ 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(AppColors.OverlayDark) + .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) AppColors.Accent else Color.Transparent) + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp) + .semantics { + stateDescription = if (isSelected) "Selected" else "Not selected" + contentDescription = "$text blur mode" + }, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + color = if (isSelected) Color.Black else Color.White, + fontSize = 14.sp + ) + } +} + +/** + * Control panel with sliders for blur parameters. + * Includes position/size/angle sliders as gesture alternatives for accessibility. + */ +@Composable +private fun ControlPanel( + params: BlurParameters, + onParamsChange: (BlurParameters) -> Unit, + onReset: () -> Unit, + modifier: Modifier = Modifier +) { + val currentParams by rememberUpdatedState(params) + val currentOnParamsChange by rememberUpdatedState(onParamsChange) + + Column( + modifier = modifier + .width(200.dp) + .clip(RoundedCornerShape(16.dp)) + .background(AppColors.OverlayDarker) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Reset button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + IconButton( + onClick = onReset, + modifier = Modifier.size(32.dp) + ) { + Icon( + imageVector = Icons.Default.RestartAlt, + contentDescription = "Reset all parameters to defaults", + tint = Color.White, + modifier = Modifier.size(18.dp) + ) + } + } + + // Blur intensity slider + SliderControl( + label = "Blur", + value = params.blurAmount, + valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, + formatValue = { "${(it * 100).toInt()}%" }, + onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } + ) + + // Falloff slider + SliderControl( + label = "Falloff", + value = params.falloff, + valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, + formatValue = { "${(it * 100).toInt()}%" }, + onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) } + ) + + // Size slider (gesture alternative for pinch-to-resize) + SliderControl( + label = "Size", + value = params.size, + valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE, + formatValue = { "${(it * 100).toInt()}%" }, + onValueChange = { currentOnParamsChange(currentParams.copy(size = 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, + formatValue = { "%.1f:1".format(it) }, + onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } + ) + } + + // Angle slider (gesture alternative for two-finger rotate) + SliderControl( + label = "Angle", + value = params.angle, + valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(), + formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" }, + onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) } + ) + } +} + +@Composable +private fun SliderControl( + label: String, + value: Float, + valueRange: ClosedFloatingPointRange, + formatValue: (Float) -> String = { "${(it * 100).toInt()}%" }, + onValueChange: (Float) -> Unit +) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = label, + color = Color.White, + fontSize = 12.sp + ) + Text( + text = formatValue(value), + color = Color.White.copy(alpha = 0.7f), + fontSize = 12.sp + ) + } + Slider( + value = value, + onValueChange = onValueChange, + valueRange = valueRange, + colors = SliderDefaults.colors( + thumbColor = AppColors.Accent, + activeTrackColor = AppColors.Accent, + inactiveTrackColor = Color.White.copy(alpha = 0.3f) + ), + modifier = Modifier + .height(24.dp) + .semantics { contentDescription = "$label: ${formatValue(value)}" } + ) + } +} + +/** + * Capture button with processing indicator. + */ +@Composable +private fun CaptureButton( + isCapturing: Boolean, + isProcessing: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val outerSize = 72.dp + val innerSize = if (isCapturing) 48.dp else 60.dp + + Box( + modifier = modifier + .size(outerSize) + .clip(CircleShape) + .border(4.dp, Color.White, CircleShape) + .clickable( + enabled = !isCapturing, + role = Role.Button, + onClick = onClick + ) + .semantics { + contentDescription = "Capture photo with tilt-shift effect" + if (isCapturing) stateDescription = "Processing photo" + }, + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(innerSize) + .clip(CircleShape) + .background(if (isCapturing) AppColors.Accent else Color.White), + contentAlignment = Alignment.Center + ) { + if (isProcessing) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + color = Color.Black, + strokeWidth = 3.dp + ) + } + } + } +} + +/** + * Rounded thumbnail of the last captured photo. + * Tapping opens the image in the default photo viewer. + */ +@Composable +private fun LastPhotoThumbnail( + thumbnail: Bitmap?, + onTap: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = thumbnail != null, + enter = fadeIn() + scaleIn(initialScale = 0.6f), + exit = fadeOut(), + modifier = modifier + ) { + thumbnail?.let { bmp -> + Image( + bitmap = bmp.asImageBitmap(), + contentDescription = "Last captured photo. Tap to open in viewer.", + contentScale = ContentScale.Crop, + modifier = Modifier + .size(52.dp) + .clip(RoundedCornerShape(10.dp)) + .border(2.dp, Color.White, RoundedCornerShape(10.dp)) + .clickable(role = Role.Button, onClick = onTap) + ) + } + } +} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt b/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt deleted file mode 100644 index efec080..0000000 --- a/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt +++ /dev/null @@ -1,107 +0,0 @@ -package no.naiv.tiltshift.ui - -import android.graphics.Bitmap -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.unit.dp -import no.naiv.tiltshift.ui.theme.AppColors - -/** - * Capture button with processing indicator. - */ -@Composable -fun CaptureButton( - isCapturing: Boolean, - isProcessing: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier -) { - val outerSize = 72.dp - val innerSize = if (isCapturing) 48.dp else 60.dp - - Box( - modifier = modifier - .size(outerSize) - .clip(CircleShape) - .border(4.dp, Color.White, CircleShape) - .clickable( - enabled = !isCapturing, - role = Role.Button, - onClick = onClick - ) - .semantics { - contentDescription = "Capture photo with tilt-shift effect" - if (isCapturing) stateDescription = "Processing photo" - }, - contentAlignment = Alignment.Center - ) { - Box( - modifier = Modifier - .size(innerSize) - .clip(CircleShape) - .background(if (isCapturing) AppColors.Accent else Color.White), - contentAlignment = Alignment.Center - ) { - if (isProcessing) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = Color.Black, - strokeWidth = 3.dp - ) - } - } - } -} - -/** - * Rounded thumbnail of the last captured photo. - * Tapping opens the image in the default photo viewer. - */ -@Composable -fun LastPhotoThumbnail( - thumbnail: Bitmap?, - onTap: () -> Unit, - modifier: Modifier = Modifier -) { - AnimatedVisibility( - visible = thumbnail != null, - enter = fadeIn() + scaleIn(initialScale = 0.6f), - exit = fadeOut(), - modifier = modifier - ) { - thumbnail?.let { bmp -> - Image( - bitmap = bmp.asImageBitmap(), - contentDescription = "Last captured photo. Tap to open in viewer.", - contentScale = ContentScale.Crop, - modifier = Modifier - .size(52.dp) - .clip(RoundedCornerShape(10.dp)) - .border(2.dp, Color.White, RoundedCornerShape(10.dp)) - .clickable(role = Role.Button, onClick = onTap) - ) - } - } -} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt deleted file mode 100644 index ad94ad0..0000000 --- a/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt +++ /dev/null @@ -1,218 +0,0 @@ -package no.naiv.tiltshift.ui - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.RestartAlt -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.getValue -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.stateDescription -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import no.naiv.tiltshift.effect.BlurMode -import no.naiv.tiltshift.effect.BlurParameters -import no.naiv.tiltshift.ui.theme.AppColors - -/** - * Mode toggle for Linear / Radial blur modes. - */ -@Composable -fun ModeToggle( - currentMode: BlurMode, - onModeChange: (BlurMode) -> Unit, - modifier: Modifier = Modifier -) { - Row( - modifier = modifier - .clip(RoundedCornerShape(20.dp)) - .background(AppColors.OverlayDark) - .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) AppColors.Accent else Color.Transparent) - .clickable(role = Role.Button, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp) - .semantics { - stateDescription = if (isSelected) "Selected" else "Not selected" - contentDescription = "$text blur mode" - }, - contentAlignment = Alignment.Center - ) { - Text( - text = text, - color = if (isSelected) Color.Black else Color.White, - fontSize = 14.sp - ) - } -} - -/** - * Control panel with sliders for blur parameters. - * Includes position/size/angle sliders as gesture alternatives for accessibility. - */ -@Composable -fun ControlPanel( - params: BlurParameters, - onParamsChange: (BlurParameters) -> Unit, - onReset: () -> Unit, - modifier: Modifier = Modifier -) { - val currentParams by rememberUpdatedState(params) - val currentOnParamsChange by rememberUpdatedState(onParamsChange) - - Column( - modifier = modifier - .width(200.dp) - .clip(RoundedCornerShape(16.dp)) - .background(AppColors.OverlayDarker) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - // Reset button - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - IconButton( - onClick = onReset, - modifier = Modifier.size(32.dp) - ) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = "Reset all parameters to defaults", - tint = Color.White, - modifier = Modifier.size(18.dp) - ) - } - } - - SliderControl( - label = "Blur", - value = params.blurAmount, - valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR, - formatValue = { "${(it * 100).toInt()}%" }, - onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) } - ) - - SliderControl( - label = "Falloff", - value = params.falloff, - valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF, - formatValue = { "${(it * 100).toInt()}%" }, - onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) } - ) - - SliderControl( - label = "Size", - value = params.size, - valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE, - formatValue = { "${(it * 100).toInt()}%" }, - onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) } - ) - - if (params.mode == BlurMode.RADIAL) { - SliderControl( - label = "Shape", - value = params.aspectRatio, - valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT, - formatValue = { "%.1f:1".format(it) }, - onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) } - ) - } - - SliderControl( - label = "Angle", - value = params.angle, - valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(), - formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" }, - onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) } - ) - } -} - -@Composable -private fun SliderControl( - label: String, - value: Float, - valueRange: ClosedFloatingPointRange, - formatValue: (Float) -> String = { "${(it * 100).toInt()}%" }, - onValueChange: (Float) -> Unit -) { - Column { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = label, - color = Color.White, - fontSize = 12.sp - ) - Text( - text = formatValue(value), - color = Color.White.copy(alpha = 0.7f), - fontSize = 12.sp - ) - } - Slider( - value = value, - onValueChange = onValueChange, - valueRange = valueRange, - colors = SliderDefaults.colors( - thumbColor = AppColors.Accent, - activeTrackColor = AppColors.Accent, - inactiveTrackColor = Color.White.copy(alpha = 0.3f) - ), - modifier = Modifier - .height(24.dp) - .semantics { contentDescription = "$label: ${formatValue(value)}" } - ) - } -} diff --git a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt index c74c8d7..d7e6e4a 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt @@ -232,9 +232,9 @@ private fun determineGestureType( return when { // Very center of focus zone -> rotation (small area) distFromCenter < focusSize * 0.3f -> GestureType.ROTATE - // Near the blur boundary -> size adjustment - distFromCenter < focusSize * 1.3f -> GestureType.PINCH_SIZE - // Outside the effect -> camera zoom + // Near the blur effect -> size adjustment (large area) + distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE + // Far outside -> camera zoom else -> GestureType.PINCH_ZOOM } } diff --git a/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt b/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt deleted file mode 100644 index 0c1d363..0000000 --- a/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt +++ /dev/null @@ -1,248 +0,0 @@ -package no.naiv.tiltshift.util - -import android.graphics.Bitmap - -/** - * Fast stack blur algorithm for CPU-based bitmap blurring. - * - * Used by the capture/gallery pipeline where GPU shaders aren't available. - * Stack blur is an approximation of Gaussian blur that runs in O(n) per pixel - * regardless of radius, making it suitable for large images. - */ -object StackBlur { - - /** - * Applies stack blur to a bitmap and returns a new blurred bitmap. - * The source bitmap is not modified. - * - * @param bitmap Source bitmap to blur. - * @param radius Blur radius (1-25). Larger = more blur. - * @return A new blurred bitmap. The caller owns it and must recycle it. - */ - fun blur(bitmap: Bitmap, radius: Int): Bitmap { - if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true) - - val w = bitmap.width - val h = bitmap.height - val pix = IntArray(w * h) - bitmap.getPixels(pix, 0, w, 0, 0, w, h) - - val wm = w - 1 - val hm = h - 1 - val wh = w * h - val div = radius + radius + 1 - - val r = IntArray(wh) - val g = IntArray(wh) - val b = IntArray(wh) - var rsum: Int - var gsum: Int - var bsum: Int - var x: Int - var y: Int - var i: Int - var p: Int - var yp: Int - var yi: Int - var yw: Int - val vmin = IntArray(maxOf(w, h)) - - var divsum = (div + 1) shr 1 - divsum *= divsum - val dv = IntArray(256 * divsum) - for (i2 in 0 until 256 * divsum) { - dv[i2] = (i2 / divsum) - } - - yi = 0 - yw = 0 - - val stack = Array(div) { IntArray(3) } - var stackpointer: Int - var stackstart: Int - var sir: IntArray - var rbs: Int - val r1 = radius + 1 - var routsum: Int - var goutsum: Int - var boutsum: Int - var rinsum: Int - var ginsum: Int - var binsum: Int - - // Horizontal pass - for (y2 in 0 until h) { - rinsum = 0 - ginsum = 0 - binsum = 0 - routsum = 0 - goutsum = 0 - boutsum = 0 - rsum = 0 - gsum = 0 - bsum = 0 - for (i2 in -radius..radius) { - p = pix[yi + minOf(wm, maxOf(i2, 0))] - sir = stack[i2 + radius] - sir[0] = (p and 0xff0000) shr 16 - sir[1] = (p and 0x00ff00) shr 8 - sir[2] = (p and 0x0000ff) - rbs = r1 - kotlin.math.abs(i2) - rsum += sir[0] * rbs - gsum += sir[1] * rbs - bsum += sir[2] * rbs - if (i2 > 0) { - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - } else { - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - } - } - stackpointer = radius - - for (x2 in 0 until w) { - r[yi] = dv[rsum] - g[yi] = dv[gsum] - b[yi] = dv[bsum] - - rsum -= routsum - gsum -= goutsum - bsum -= boutsum - - stackstart = stackpointer - radius + div - sir = stack[stackstart % div] - - routsum -= sir[0] - goutsum -= sir[1] - boutsum -= sir[2] - - if (y2 == 0) { - vmin[x2] = minOf(x2 + radius + 1, wm) - } - p = pix[yw + vmin[x2]] - - sir[0] = (p and 0xff0000) shr 16 - sir[1] = (p and 0x00ff00) shr 8 - sir[2] = (p and 0x0000ff) - - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - - rsum += rinsum - gsum += ginsum - bsum += binsum - - stackpointer = (stackpointer + 1) % div - sir = stack[(stackpointer) % div] - - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - - rinsum -= sir[0] - ginsum -= sir[1] - binsum -= sir[2] - - yi++ - } - yw += w - } - - // Vertical pass - for (x2 in 0 until w) { - rinsum = 0 - ginsum = 0 - binsum = 0 - routsum = 0 - goutsum = 0 - boutsum = 0 - rsum = 0 - gsum = 0 - bsum = 0 - yp = -radius * w - for (i2 in -radius..radius) { - yi = maxOf(0, yp) + x2 - - sir = stack[i2 + radius] - - sir[0] = r[yi] - sir[1] = g[yi] - sir[2] = b[yi] - - rbs = r1 - kotlin.math.abs(i2) - - rsum += r[yi] * rbs - gsum += g[yi] * rbs - bsum += b[yi] * rbs - - if (i2 > 0) { - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - } else { - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - } - - if (i2 < hm) { - yp += w - } - } - yi = x2 - stackpointer = radius - for (y2 in 0 until h) { - pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum] - - rsum -= routsum - gsum -= goutsum - bsum -= boutsum - - stackstart = stackpointer - radius + div - sir = stack[stackstart % div] - - routsum -= sir[0] - goutsum -= sir[1] - boutsum -= sir[2] - - if (x2 == 0) { - vmin[y2] = minOf(y2 + r1, hm) * w - } - p = x2 + vmin[y2] - - sir[0] = r[p] - sir[1] = g[p] - sir[2] = b[p] - - rinsum += sir[0] - ginsum += sir[1] - binsum += sir[2] - - rsum += rinsum - gsum += ginsum - bsum += binsum - - stackpointer = (stackpointer + 1) % div - sir = stack[stackpointer] - - routsum += sir[0] - goutsum += sir[1] - boutsum += sir[2] - - rinsum -= sir[0] - ginsum -= sir[1] - binsum -= sir[2] - - yi += w - } - } - - val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888) - result.setPixels(pix, 0, w, 0, 0, w, h) - return result - } -} diff --git a/app/src/main/res/raw/tiltshift_fragment.glsl b/app/src/main/res/raw/tiltshift_fragment.glsl index 0caa115..f1618e4 100644 --- a/app/src/main/res/raw/tiltshift_fragment.glsl +++ b/app/src/main/res/raw/tiltshift_fragment.glsl @@ -1,58 +1,71 @@ -// Fragment shader for tilt-shift blur pass (two-pass separable Gaussian) -// Reads from a sampler2D (FBO texture already in screen orientation). -// Used twice: once for horizontal blur, once for vertical blur. +#extension GL_OES_EGL_image_external : require + +// Fragment shader for tilt-shift effect +// Supports both linear and radial blur modes precision mediump float; -uniform sampler2D uTexture; +// Camera texture (external texture for camera preview) +uniform samplerExternalOES uTexture; // Effect parameters uniform int uMode; // 0 = linear, 1 = radial -uniform float uPositionX; // Horizontal center of focus (0-1, screen space) -uniform float uPositionY; // Vertical center of focus (0-1, screen space, 0 = top) +uniform int uIsFrontCamera; // 0 = back camera, 1 = front camera +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; // Surface resolution for proper sampling +uniform vec2 uResolution; // Texture resolution for proper sampling -// Precomputed trig for the raw screen-space angle +// Precomputed trig for the adjusted angle (avoids per-fragment cos/sin calls) uniform float uCosAngle; uniform float uSinAngle; -// Blur direction: (1,0) for horizontal pass, (0,1) for vertical pass -uniform vec2 uBlurDirection; - varying vec2 vTexCoord; -// Calculate distance from the focus region for LINEAR mode -// Works in screen space: X right (0-1), Y down (0-1) -// Distances are normalized to the Y axis (height) to match the overlay, -// which defines focus size as a fraction of screen height. -float linearFocusDistance(vec2 screenPos) { - vec2 center = vec2(uPositionX, uPositionY); - vec2 offset = screenPos - center; +// Calculate signed distance from the focus region for LINEAR mode +float linearFocusDistance(vec2 uv) { + // Center point of the focus region + // Transform from screen coordinates to texture coordinates + // Back camera: Screen (x,y) -> Texture (y, 1-x) + // Front camera: Screen (x,y) -> Texture (1-y, 1-x) (additional X flip for mirror) + vec2 center; + if (uIsFrontCamera == 1) { + center = vec2(1.0 - uPositionY, 1.0 - uPositionX); + } else { + center = vec2(uPositionY, 1.0 - uPositionX); + } + vec2 offset = uv - center; - // Scale X into the same physical units as Y (height-normalized) + // Correct for screen aspect ratio to make coordinate space square float screenAspect = uResolution.x / uResolution.y; - offset.x *= screenAspect; + offset.y *= screenAspect; - // Perpendicular distance to the rotated focus line + // Use precomputed cos/sin for the adjusted angle float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle; return abs(rotatedY); } -// Calculate distance from the focus region for RADIAL mode -float radialFocusDistance(vec2 screenPos) { - vec2 center = vec2(uPositionX, uPositionY); - vec2 offset = screenPos - center; +// Calculate signed distance from the focus region for RADIAL mode +float radialFocusDistance(vec2 uv) { + // Center point of the focus region + vec2 center; + if (uIsFrontCamera == 1) { + center = vec2(1.0 - uPositionY, 1.0 - uPositionX); + } else { + center = vec2(uPositionY, 1.0 - uPositionX); + } + vec2 offset = uv - center; - // Scale X into the same physical units as Y (height-normalized) + // Correct for screen aspect ratio float screenAspect = uResolution.x / uResolution.y; - offset.x *= screenAspect; + offset.y *= screenAspect; - // Rotate offset + // Use precomputed cos/sin for rotation vec2 rotated = vec2( offset.x * uCosAngle - offset.y * uSinAngle, offset.x * uSinAngle + offset.y * uCosAngle @@ -61,59 +74,83 @@ float radialFocusDistance(vec2 screenPos) { // 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) { float halfSize = uSize * 0.5; - float transitionSize = halfSize * uFalloff * 3.0; + // Falloff range scales with the falloff parameter + float transitionSize = halfSize * uFalloff; if (dist < halfSize) { - return 0.0; + return 0.0; // In focus region } + // Smooth falloff using smoothstep float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001); return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount; } -void main() { - // Convert FBO texture coords to screen space (flip Y: GL bottom-up → screen top-down) - vec2 screenPos = vec2(vTexCoord.x, 1.0 - vTexCoord.y); +// Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility) +vec4 sampleBlurred(vec2 uv, float blur) { + if (blur < 0.01) { + return texture2D(uTexture, uv); + } + vec2 texelSize = 1.0 / uResolution; + + // 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; + if (uIsFrontCamera == 1) { + center = vec2(1.0 - uPositionY, 1.0 - uPositionX); + } else { + center = vec2(uPositionY, 1.0 - uPositionX); + } + 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 using precomputed trig + blurDir = vec2(uCosAngle, uSinAngle); + } + + // Scale blur radius by blur amount + float radius = blur * 20.0; + vec2 step = blurDir * texelSize * radius; + + // Unrolled 9-tap Gaussian blur (avoids integer-branched weight lookup) + vec4 color = vec4(0.0); + color += texture2D(uTexture, uv + step * -4.0) * 0.0162; + color += texture2D(uTexture, uv + step * -3.0) * 0.0540; + color += texture2D(uTexture, uv + step * -2.0) * 0.1216; + color += texture2D(uTexture, uv + step * -1.0) * 0.1933; + color += texture2D(uTexture, uv) * 0.2258; + color += texture2D(uTexture, uv + step * 1.0) * 0.1933; + color += texture2D(uTexture, uv + step * 2.0) * 0.1216; + color += texture2D(uTexture, uv + step * 3.0) * 0.0540; + color += texture2D(uTexture, uv + step * 4.0) * 0.0162; + + return color; +} + +void main() { float dist; if (uMode == 1) { - dist = radialFocusDistance(screenPos); + dist = radialFocusDistance(vTexCoord); } else { - dist = linearFocusDistance(screenPos); + dist = linearFocusDistance(vTexCoord); } float blur = blurFactor(dist); - if (blur < 0.01) { - gl_FragColor = texture2D(uTexture, vTexCoord); - return; - } - - // 13-tap separable Gaussian (sigma ~= 2.5) - // Each pass blurs in one direction; combined gives a full 2D Gaussian. - vec2 texelSize = 1.0 / uResolution; - float radius = blur * 20.0; - vec2 step = uBlurDirection * texelSize * radius; - - vec4 color = vec4(0.0); - color += texture2D(uTexture, vTexCoord + step * -6.0) * 0.0090; - color += texture2D(uTexture, vTexCoord + step * -5.0) * 0.0218; - color += texture2D(uTexture, vTexCoord + step * -4.0) * 0.0448; - color += texture2D(uTexture, vTexCoord + step * -3.0) * 0.0784; - color += texture2D(uTexture, vTexCoord + step * -2.0) * 0.1169; - color += texture2D(uTexture, vTexCoord + step * -1.0) * 0.1486; - color += texture2D(uTexture, vTexCoord) * 0.1610; - color += texture2D(uTexture, vTexCoord + step * 1.0) * 0.1486; - color += texture2D(uTexture, vTexCoord + step * 2.0) * 0.1169; - color += texture2D(uTexture, vTexCoord + step * 3.0) * 0.0784; - color += texture2D(uTexture, vTexCoord + step * 4.0) * 0.0448; - color += texture2D(uTexture, vTexCoord + step * 5.0) * 0.0218; - color += texture2D(uTexture, vTexCoord + step * 6.0) * 0.0090; - - gl_FragColor = color; + gl_FragColor = sampleBlurred(vTexCoord, blur); } diff --git a/app/src/main/res/raw/tiltshift_passthrough_fragment.glsl b/app/src/main/res/raw/tiltshift_passthrough_fragment.glsl deleted file mode 100644 index 4d5edcd..0000000 --- a/app/src/main/res/raw/tiltshift_passthrough_fragment.glsl +++ /dev/null @@ -1,15 +0,0 @@ -#extension GL_OES_EGL_image_external : require - -// Passthrough fragment shader: copies camera texture to FBO -// This separates the camera coordinate transform (handled by vertex/texcoord setup) -// from the blur passes, which then work entirely in screen space. - -precision mediump float; - -uniform samplerExternalOES uTexture; - -varying vec2 vTexCoord; - -void main() { - gl_FragColor = texture2D(uTexture, vTexCoord); -} diff --git a/app/src/test/java/no/naiv/tiltshift/camera/LensControllerTest.kt b/app/src/test/java/no/naiv/tiltshift/camera/LensControllerTest.kt deleted file mode 100644 index 6a5ec20..0000000 --- a/app/src/test/java/no/naiv/tiltshift/camera/LensControllerTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package no.naiv.tiltshift.camera - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -class LensControllerTest { - - @Test - fun `getCurrentLens returns null before initialization`() { - val controller = LensController() - assertNull(controller.getCurrentLens()) - } - - @Test - fun `getAvailableLenses returns empty before initialization`() { - val controller = LensController() - assertTrue(controller.getAvailableLenses().isEmpty()) - } - - @Test - fun `selectLens returns false for unknown lens`() { - val controller = LensController() - assertFalse(controller.selectLens("nonexistent")) - } - - @Test - fun `cycleToNextLens returns null when no lenses`() { - val controller = LensController() - assertNull(controller.cycleToNextLens()) - } - - // Note: initialize() requires CameraInfo instances which need Android framework. - // Integration tests with Robolectric or on-device tests would cover that path. -} diff --git a/app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt b/app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt deleted file mode 100644 index d133f09..0000000 --- a/app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt +++ /dev/null @@ -1,151 +0,0 @@ -package no.naiv.tiltshift.effect - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotEquals -import org.junit.Test -import kotlin.math.PI - -class BlurParametersTest { - - @Test - fun `DEFAULT has expected values`() { - val default = BlurParameters.DEFAULT - assertEquals(BlurMode.LINEAR, default.mode) - assertEquals(0f, default.angle, 0f) - assertEquals(0.5f, default.positionX, 0f) - assertEquals(0.5f, default.positionY, 0f) - assertEquals(0.3f, default.size, 0f) - assertEquals(0.8f, default.blurAmount, 0f) - assertEquals(0.5f, default.falloff, 0f) - assertEquals(1.0f, default.aspectRatio, 0f) - } - - // --- withSize --- - - @Test - fun `withSize clamps below minimum`() { - val params = BlurParameters.DEFAULT.withSize(0.01f) - assertEquals(BlurParameters.MIN_SIZE, params.size, 0f) - } - - @Test - fun `withSize clamps above maximum`() { - val params = BlurParameters.DEFAULT.withSize(5.0f) - assertEquals(BlurParameters.MAX_SIZE, params.size, 0f) - } - - @Test - fun `withSize accepts value in range`() { - val params = BlurParameters.DEFAULT.withSize(0.5f) - assertEquals(0.5f, params.size, 0f) - } - - // --- withBlurAmount --- - - @Test - fun `withBlurAmount clamps below minimum`() { - val params = BlurParameters.DEFAULT.withBlurAmount(-1f) - assertEquals(BlurParameters.MIN_BLUR, params.blurAmount, 0f) - } - - @Test - fun `withBlurAmount clamps above maximum`() { - val params = BlurParameters.DEFAULT.withBlurAmount(2f) - assertEquals(BlurParameters.MAX_BLUR, params.blurAmount, 0f) - } - - // --- withFalloff --- - - @Test - fun `withFalloff clamps below minimum`() { - val params = BlurParameters.DEFAULT.withFalloff(0f) - assertEquals(BlurParameters.MIN_FALLOFF, params.falloff, 0f) - } - - @Test - fun `withFalloff clamps above maximum`() { - val params = BlurParameters.DEFAULT.withFalloff(5f) - assertEquals(BlurParameters.MAX_FALLOFF, params.falloff, 0f) - } - - // --- withAspectRatio --- - - @Test - fun `withAspectRatio clamps below minimum`() { - val params = BlurParameters.DEFAULT.withAspectRatio(0.1f) - assertEquals(BlurParameters.MIN_ASPECT, params.aspectRatio, 0f) - } - - @Test - fun `withAspectRatio clamps above maximum`() { - val params = BlurParameters.DEFAULT.withAspectRatio(10f) - assertEquals(BlurParameters.MAX_ASPECT, params.aspectRatio, 0f) - } - - // --- withPosition --- - - @Test - fun `withPosition clamps to 0-1 range`() { - val params = BlurParameters.DEFAULT.withPosition(-0.5f, 1.5f) - assertEquals(0f, params.positionX, 0f) - assertEquals(1f, params.positionY, 0f) - } - - @Test - fun `withPosition accepts values in range`() { - val params = BlurParameters.DEFAULT.withPosition(0.3f, 0.7f) - assertEquals(0.3f, params.positionX, 0f) - assertEquals(0.7f, params.positionY, 0f) - } - - // --- withAngle --- - - @Test - fun `withAngle sets arbitrary angle`() { - val angle = PI.toFloat() / 4 - val params = BlurParameters.DEFAULT.withAngle(angle) - assertEquals(angle, params.angle, 0f) - } - - // --- copy preserves other fields --- - - @Test - fun `with methods preserve other fields`() { - val custom = BlurParameters( - mode = BlurMode.RADIAL, - angle = 1.5f, - positionX = 0.2f, - positionY = 0.8f, - size = 0.4f, - blurAmount = 0.6f, - falloff = 0.7f, - aspectRatio = 2.0f - ) - - val updated = custom.withSize(0.5f) - assertEquals(BlurMode.RADIAL, updated.mode) - assertEquals(1.5f, updated.angle, 0f) - assertEquals(0.2f, updated.positionX, 0f) - assertEquals(0.8f, updated.positionY, 0f) - assertEquals(0.5f, updated.size, 0f) - assertEquals(0.6f, updated.blurAmount, 0f) - assertEquals(0.7f, updated.falloff, 0f) - assertEquals(2.0f, updated.aspectRatio, 0f) - } - - // --- data class equality --- - - @Test - fun `data class equality works`() { - val a = BlurParameters(mode = BlurMode.LINEAR, size = 0.5f) - val b = BlurParameters(mode = BlurMode.LINEAR, size = 0.5f) - assertEquals(a, b) - } - - @Test - fun `different params are not equal`() { - val a = BlurParameters(mode = BlurMode.LINEAR) - val b = BlurParameters(mode = BlurMode.RADIAL) - assertNotEquals(a, b) - } -} diff --git a/build.gradle.kts b/build.gradle.kts index b546c74..5c98ad0 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.compose) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c96e0c6..37e1882 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,14 +1,14 @@ [versions] -agp = "9.1.0" -kotlin = "2.3.20" -coreKtx = "1.18.0" -lifecycleRuntimeKtx = "2.10.0" -activityCompose = "1.13.0" -composeBom = "2026.03.00" -camerax = "1.5.1" -exifinterface = "1.4.2" +agp = "8.7.3" +kotlin = "2.0.21" +coreKtx = "1.15.0" +lifecycleRuntimeKtx = "2.8.7" +activityCompose = "1.9.3" +composeBom = "2024.12.01" +camerax = "1.4.1" +accompanist = "0.36.0" +exifinterface = "1.3.7" playServicesLocation = "21.3.0" -junit = "4.13.2" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,9 +36,10 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa # Location play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } -# Test -junit = { group = "junit", name = "junit", version.ref = "junit" } +# Accompanist for permissions +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index f8f7c1f..794ea4f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip -distributionSha256Sum=0f6ba231b986276d8221d7a870b4d98e0df76e6daf1f42e7c0baec5032fb7d17 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists diff --git a/version.properties b/version.properties index 5091055..a074999 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=5 -versionCode=7 +versionPatch=1 +versionCode=3