diff --git a/CLAUDE.md b/CLAUDE.md index f405c1b..00c67af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,6 +99,5 @@ 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. -- 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 5e3c123..26ef662 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,8 +112,8 @@ dependencies { // Location implementation(libs.play.services.location) - // Permissions - implementation(libs.accompanist.permissions) + // Test + testImplementation(libs.junit) // Debug debugImplementation(libs.androidx.ui.tooling) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 2fb4752..7312d90 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,5 +1,2 @@ # 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 ab0cca3..7a379a2 100644 --- a/app/src/main/java/no/naiv/tiltshift/MainActivity.kt +++ b/app/src/main/java/no/naiv/tiltshift/MainActivity.kt @@ -2,12 +2,15 @@ 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 @@ -26,23 +29,24 @@ 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 @@ -66,21 +70,47 @@ class MainActivity : ComponentActivity() { } } -@OptIn(ExperimentalPermissionsApi::class) @Composable private fun TiltShiftApp() { - val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) - val locationPermissions = rememberMultiplePermissionsState( - listOf( - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION + val context = LocalContext.current + val activity = context as? ComponentActivity + + var cameraGranted by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED ) - ) + } + 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 (!cameraPermission.status.isGranted) { - cameraPermission.launchPermissionRequest() + if (!cameraGranted) { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } } @@ -90,28 +120,47 @@ private fun TiltShiftApp() { .background(Color.Black) ) { when { - cameraPermission.status.isGranted -> { + cameraGranted -> { // Camera permission granted - show camera CameraScreen() // Request location in background (for EXIF GPS) LaunchedEffect(Unit) { - if (!locationPermissions.allPermissionsGranted) { - locationPermissions.launchMultiplePermissionRequest() + if (!locationGranted) { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) } } } else -> { - // Permanently denied: not granted AND rationale not shown - val cameraPermanentlyDenied = !cameraPermission.status.isGranted && - !cameraPermission.status.shouldShowRationale + // 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 // Show permission request UI PermissionRequestScreen( - onRequestCamera = { cameraPermission.launchPermissionRequest() }, - onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() }, - cameraGranted = cameraPermission.status.isGranted, - locationGranted = locationPermissions.allPermissionsGranted, + onRequestCamera = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + }, + onRequestLocation = { + locationPermissionLauncher.launch( + arrayOf( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + ) + ) + }, + cameraGranted = false, + locationGranted = locationGranted, 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 00f9107..51d42ed 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt @@ -174,24 +174,14 @@ class CameraManager(private val context: Context) { } /** - * Sets the zoom ratio. Updates UI state only after the camera confirms the change. + * 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. */ fun setZoom(ratio: Float) { val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value) - 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 - } + _zoomRatio.value = clamped + camera?.cameraControl?.setZoomRatio(clamped) } /** 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 3258fa8..d170d58 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt @@ -5,6 +5,7 @@ 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 @@ -12,10 +13,16 @@ import javax.microedition.khronos.egl.EGLConfig import javax.microedition.khronos.opengles.GL10 /** - * OpenGL renderer for applying tilt-shift effect to camera preview. + * OpenGL renderer for applying tilt-shift effect to camera preview + * using a two-pass separable Gaussian blur. * - * This renderer receives camera frames via SurfaceTexture and applies - * the tilt-shift blur effect using GLSL shaders. + * 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. */ class TiltShiftRenderer( private val context: Context, @@ -23,16 +30,30 @@ 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 - private lateinit var vertexBuffer: FloatBuffer - private lateinit var texCoordBuffer: FloatBuffer + // 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 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 @@ -69,27 +90,33 @@ 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() - // 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 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) - // Create texture coordinate buffer - texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4) - .order(ByteOrder.nativeOrder()) - .asFloatBuffer() - .put(currentTexCoords) - texCoordBuffer.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 camera texture val textures = IntArray(1) @@ -114,88 +141,75 @@ 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) { - texCoordBuffer.clear() - texCoordBuffer.put(currentTexCoords) - texCoordBuffer.position(0) + cameraTexCoordBuffer.clear() + cameraTexCoordBuffer.put(currentTexCoords) + cameraTexCoordBuffer.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) - - // 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 + shader.usePassthrough(cameraTextureId) + drawQuad( + shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, + cameraVertexBuffer, cameraTexCoordBuffer ) - // Set texture coordinates - GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation) - GLES20.glVertexAttribPointer( - shader.aTexCoordLocation, - 2, - GLES20.GL_FLOAT, - false, - 0, - texCoordBuffer + // --- 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 ) - // Draw quad - GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4) - - // Cleanup - GLES20.glDisableVertexAttribArray(shader.aPositionLocation) - GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation) + // --- 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 + ) } - /** - * 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 @@ -204,45 +218,6 @@ 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() @@ -252,5 +227,117 @@ 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 2c3a7d5..cdafd8f 100644 --- a/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt +++ b/app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt @@ -4,57 +4,167 @@ import android.content.Context import android.opengl.GLES11Ext import android.opengl.GLES20 import no.naiv.tiltshift.R -import kotlin.math.cos -import kotlin.math.sin import java.io.BufferedReader import java.io.InputStreamReader +import kotlin.math.cos +import kotlin.math.sin /** - * Manages OpenGL shader programs for the tilt-shift effect. + * 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. */ class TiltShiftShader(private val context: Context) { - var programId: Int = 0 - private set + // --- Passthrough program (camera → FBO) --- - // Attribute locations - var aPositionLocation: Int = 0 - private set - var aTexCoordLocation: Int = 0 - private set + private var passthroughProgramId: 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 + var passthroughPositionLoc: 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 + private set + var blurTexCoordLoc: 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 /** - * Compiles and links the shader program. + * Compiles and links both shader programs. * 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) - programId = GLES20.glCreateProgram() + // 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() 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) { @@ -63,72 +173,7 @@ class TiltShiftShader(private val context: Context) { throw RuntimeException("Shader program link failed: $error") } - // 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 - } + return programId } private fun loadShaderSource(resourceId: Int): String { @@ -142,7 +187,6 @@ 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 8facb4c..7841f90 100644 --- a/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt +++ b/app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt @@ -17,18 +17,6 @@ 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 new file mode 100644 index 0000000..4e5e700 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt @@ -0,0 +1,16 @@ +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/TiltShiftOverlay.kt b/app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt index d7e6e4a..c74c8d7 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 effect -> size adjustment (large area) - distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE - // Far outside -> camera zoom + // Near the blur boundary -> size adjustment + distFromCenter < focusSize * 1.3f -> GestureType.PINCH_SIZE + // Outside the effect -> camera zoom else -> GestureType.PINCH_ZOOM } } diff --git a/app/src/main/res/raw/tiltshift_fragment.glsl b/app/src/main/res/raw/tiltshift_fragment.glsl index f1618e4..0caa115 100644 --- a/app/src/main/res/raw/tiltshift_fragment.glsl +++ b/app/src/main/res/raw/tiltshift_fragment.glsl @@ -1,71 +1,58 @@ -#extension GL_OES_EGL_image_external : require - -// Fragment shader for tilt-shift effect -// Supports both linear and radial blur modes +// 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. precision mediump float; -// Camera texture (external texture for camera preview) -uniform samplerExternalOES uTexture; +uniform sampler2D uTexture; // Effect parameters uniform int uMode; // 0 = linear, 1 = radial -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 uPositionX; // Horizontal center of focus (0-1, screen space) +uniform float uPositionY; // Vertical center of focus (0-1, screen space, 0 = top) uniform float uSize; // Size of in-focus region (0-1) uniform float uBlurAmount; // Maximum blur intensity (0-1) uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual) uniform float uAspectRatio; // Ellipse aspect ratio for radial mode -uniform vec2 uResolution; // Texture resolution for proper sampling +uniform vec2 uResolution; // Surface resolution for proper sampling -// Precomputed trig for the adjusted angle (avoids per-fragment cos/sin calls) +// Precomputed trig for the raw screen-space angle 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 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; +// 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; - // Correct for screen aspect ratio to make coordinate space square + // Scale X into the same physical units as Y (height-normalized) float screenAspect = uResolution.x / uResolution.y; - offset.y *= screenAspect; + offset.x *= screenAspect; - // Use precomputed cos/sin for the adjusted angle + // Perpendicular distance to the rotated focus line float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle; 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; - if (uIsFrontCamera == 1) { - center = vec2(1.0 - uPositionY, 1.0 - uPositionX); - } else { - center = vec2(uPositionY, 1.0 - uPositionX); - } - vec2 offset = uv - center; +// Calculate distance from the focus region for RADIAL mode +float radialFocusDistance(vec2 screenPos) { + vec2 center = vec2(uPositionX, uPositionY); + vec2 offset = screenPos - center; - // Correct for screen aspect ratio + // Scale X into the same physical units as Y (height-normalized) float screenAspect = uResolution.x / uResolution.y; - offset.y *= screenAspect; + offset.x *= screenAspect; - // Use precomputed cos/sin for rotation + // Rotate offset vec2 rotated = vec2( offset.x * uCosAngle - offset.y * uSinAngle, offset.x * uSinAngle + offset.y * uCosAngle @@ -74,83 +61,59 @@ float radialFocusDistance(vec2 uv) { // 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; - // Falloff range scales with the falloff parameter - float transitionSize = halfSize * uFalloff; + float transitionSize = halfSize * uFalloff * 3.0; if (dist < halfSize) { - return 0.0; // In focus region + return 0.0; } - // Smooth falloff using smoothstep float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001); return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount; } -// 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() { + // Convert FBO texture coords to screen space (flip Y: GL bottom-up → screen top-down) + vec2 screenPos = vec2(vTexCoord.x, 1.0 - vTexCoord.y); + float dist; if (uMode == 1) { - dist = radialFocusDistance(vTexCoord); + dist = radialFocusDistance(screenPos); } else { - dist = linearFocusDistance(vTexCoord); + dist = linearFocusDistance(screenPos); } float blur = blurFactor(dist); - gl_FragColor = sampleBlurred(vTexCoord, blur); + 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; } diff --git a/app/src/main/res/raw/tiltshift_passthrough_fragment.glsl b/app/src/main/res/raw/tiltshift_passthrough_fragment.glsl new file mode 100644 index 0000000..4d5edcd --- /dev/null +++ b/app/src/main/res/raw/tiltshift_passthrough_fragment.glsl @@ -0,0 +1,15 @@ +#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 new file mode 100644 index 0000000..6a5ec20 --- /dev/null +++ b/app/src/test/java/no/naiv/tiltshift/camera/LensControllerTest.kt @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..d133f09 --- /dev/null +++ b/app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt @@ -0,0 +1,151 @@ +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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37e1882..c0b8626 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,9 +6,9 @@ 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,8 +36,8 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa # Location play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } -# Accompanist for permissions -accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } +# Test +junit = { group = "junit", name = "junit", version.ref = "junit" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/version.properties b/version.properties index a074999..1046899 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=1 -versionCode=3 +versionPatch=3 +versionCode=5