From 878c23bf89eddd678be96daf9ef8ab4c0e9f292a Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 16:43:56 +0100 Subject: [PATCH 1/7] Replace Accompanist Permissions with first-party activity-compose API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accompanist Permissions (0.36.0) is deprecated and experimental. Migrate to the stable ActivityResultContracts.RequestPermission / RequestMultiplePermissions APIs already available via activity-compose. Adds explicit state tracking with a cameraResultReceived flag to correctly distinguish "never asked" from "permanently denied" — an improvement over the previous Accompanist-based detection. Bump version to 1.1.2. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 - app/build.gradle.kts | 3 - .../java/no/naiv/tiltshift/MainActivity.kt | 99 ++++++++++++++----- gradle/libs.versions.toml | 4 - version.properties | 4 +- 5 files changed, 76 insertions(+), 35 deletions(-) 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..450cc7b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,9 +112,6 @@ dependencies { // Location implementation(libs.play.services.location) - // Permissions - implementation(libs.accompanist.permissions) - // Debug debugImplementation(libs.androidx.ui.tooling) } 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37e1882..000df80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,6 @@ 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" @@ -36,9 +35,6 @@ 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" } - [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/version.properties b/version.properties index a074999..dca2f22 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=1 -versionCode=3 +versionPatch=2 +versionCode=4 From c58c45c52c22b9322a26317573b9a5b057dc1c4c Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 16:45:22 +0100 Subject: [PATCH 2/7] Remove unnecessary ProGuard keep rule and extract SaveResult to own file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The -keep rule for no.naiv.tiltshift.effect.** was based on the incorrect assumption that GLSL shaders use Java reflection. Shader source is loaded from raw resources — all effect classes are reached through normal Kotlin code and R8 can trace them. Removing the rule lets R8 properly optimize the effect package. Also extract SaveResult sealed class from PhotoSaver.kt into its own file to match the documented architecture. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/proguard-rules.pro | 3 --- .../java/no/naiv/tiltshift/storage/PhotoSaver.kt | 12 ------------ .../java/no/naiv/tiltshift/storage/SaveResult.kt | 16 ++++++++++++++++ 3 files changed, 16 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt 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/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() +} From aab1ff38a4e035efa3ae76cc752ed3138c5e3c8e Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 16:48:21 +0100 Subject: [PATCH 3/7] Add unit tests for BlurParameters and LensController First test coverage for the project: 17 tests covering BlurParameters constraint clamping (size, blur, falloff, aspect ratio, position), data class equality, field preservation across with* methods, and LensController pre-initialization edge cases. Adds JUnit 4.13.2 as a test dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/build.gradle.kts | 3 + .../tiltshift/camera/LensControllerTest.kt | 38 +++++ .../tiltshift/effect/BlurParametersTest.kt | 151 ++++++++++++++++++ gradle/libs.versions.toml | 4 + 4 files changed, 196 insertions(+) create mode 100644 app/src/test/java/no/naiv/tiltshift/camera/LensControllerTest.kt create mode 100644 app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 450cc7b..26ef662 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -112,6 +112,9 @@ dependencies { // Location implementation(libs.play.services.location) + // Test + testImplementation(libs.junit) + // Debug debugImplementation(libs.androidx.ui.tooling) } 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 000df80..c0b8626 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ composeBom = "2024.12.01" camerax = "1.4.1" 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" } @@ -35,6 +36,9 @@ 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" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } From f3baa723be3b1fcc0c88f3faa2767e3889476f6e Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 17:38:50 +0100 Subject: [PATCH 4/7] Implement two-pass separable Gaussian blur for camera preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single-pass 9-tap directional blur with a three-pass pipeline: passthrough (camera→FBO), horizontal blur (FBO-A→FBO-B), vertical blur (FBO-B→screen). This produces a true 2D Gaussian with a 13-tap kernel per pass, eliminating the visible banding/streaking of the old approach. Key changes: - TiltShiftRenderer: FBO ping-pong with two color textures, separate fullscreen quad for blur passes (no crop-to-fill), drawQuad helper - TiltShiftShader: manages two programs (passthrough + blur), blur program uses raw screen-space angle (no camera rotation adjustment) - tiltshift_fragment.glsl: rewritten for sampler2D in screen space, aspect correction on X axis (height-normalized), uBlurDirection uniform for H/V selection, wider falloff (3x multiplier) - New tiltshift_passthrough_fragment.glsl for camera→FBO copy - TiltShiftOverlay: shrink PINCH_SIZE zone (1.3x, was 2.0x) so pinch-to-zoom is reachable over more of the screen - CameraManager: optimistic zoom update fixes pinch-to-zoom stalling (stale zoomRatio base prevented delta accumulation) Bump version to 1.1.3. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../no/naiv/tiltshift/camera/CameraManager.kt | 20 +- .../tiltshift/effect/TiltShiftRenderer.kt | 301 +++++++++++------- .../naiv/tiltshift/effect/TiltShiftShader.kt | 238 ++++++++------ .../no/naiv/tiltshift/ui/TiltShiftOverlay.kt | 6 +- app/src/main/res/raw/tiltshift_fragment.glsl | 161 ++++------ .../raw/tiltshift_passthrough_fragment.glsl | 15 + version.properties | 4 +- 7 files changed, 422 insertions(+), 323 deletions(-) create mode 100644 app/src/main/res/raw/tiltshift_passthrough_fragment.glsl 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/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/version.properties b/version.properties index dca2f22..1046899 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=2 -versionCode=4 +versionPatch=3 +versionCode=5 From 5b9aedd109ad3b18880e336e3188e240329912eb Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 17:46:37 +0100 Subject: [PATCH 5/7] Update all dependencies to March 2026 versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major version bumps: - AGP 8.7.3 → 9.1.0 (remove kotlin-android plugin, now built-in) - Kotlin 2.0.21 → 2.3.20 - Gradle 8.9 → 9.4.0 - Compose BOM 2024.12.01 → 2026.03.00 - compileSdk/targetSdk 35 → 36 Library updates: - core-ktx 1.15.0 → 1.18.0 - lifecycle 2.8.7 → 2.10.0 - activity-compose 1.9.3 → 1.13.0 - CameraX 1.4.1 → 1.5.1 - exifinterface 1.3.7 → 1.4.2 AGP 9 migration: removed org.jetbrains.kotlin.android plugin (Kotlin support is now built into AGP), removed kotlinOptions block (JVM target handled by compileOptions). Bump version to 1.1.4. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- app/build.gradle.kts | 9 ++------- build.gradle.kts | 1 - gradle/libs.versions.toml | 17 ++++++++--------- gradle/wrapper/gradle-wrapper.properties | 4 ++-- version.properties | 4 ++-- 6 files changed, 15 insertions(+), 22 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 00c67af..09d9b5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,5 +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. -- Dependencies are pinned to late-2024 versions; periodic bumps recommended. +- Dependencies updated to March 2026 versions (AGP 9.1, Kotlin 2.3, Compose BOM 2026.03). - 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 26ef662..afba03e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,7 +2,6 @@ import java.util.Properties plugins { alias(libs.plugins.android.application) - alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) } @@ -25,7 +24,7 @@ val vCode = versionProperties["versionCode"].toString().toInt() android { namespace = "no.naiv.tiltshift" - compileSdk = 35 + compileSdk = 36 signingConfigs { create("release") { @@ -42,7 +41,7 @@ android { defaultConfig { applicationId = "no.naiv.tiltshift" minSdk = 35 - targetSdk = 35 + targetSdk = 36 versionCode = vCode versionName = "$vMajor.$vMinor.$vPatch" @@ -70,10 +69,6 @@ android { targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - buildFeatures { compose = true } diff --git a/build.gradle.kts b/build.gradle.kts index 5c98ad0..b546c74 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ // 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 c0b8626..c96e0c6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] -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" -exifinterface = "1.3.7" +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" playServicesLocation = "21.3.0" junit = "4.13.2" @@ -41,5 +41,4 @@ junit = { group = "junit", name = "junit", version.ref = "junit" } [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 794ea4f..f8f7c1f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip -distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip +distributionSha256Sum=0f6ba231b986276d8221d7a870b4d98e0df76e6daf1f42e7c0baec5032fb7d17 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStorePath=wrapper/dists diff --git a/version.properties b/version.properties index 1046899..bd3a999 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=3 -versionCode=5 +versionPatch=4 +versionCode=6 From 88d04515e2b47d6cfdb9a6ff21a21242bc85075d Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 17:50:30 +0100 Subject: [PATCH 6/7] Extract StackBlur and split CameraScreen into smaller files Move the ~220-line stack blur algorithm from ImageCaptureHandler into its own util/StackBlur.kt object, making it independently testable and reducing ImageCaptureHandler from 759 to 531 lines. Split CameraScreen.kt (873 lines) by extracting: - ControlPanel.kt: ModeToggle, ControlPanel, SliderControl - CaptureControls.kt: CaptureButton, LastPhotoThumbnail CameraScreen.kt is now 609 lines focused on layout and state wiring. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../tiltshift/camera/ImageCaptureHandler.kt | 229 +-------------- .../java/no/naiv/tiltshift/ui/CameraScreen.kt | 278 +----------------- .../no/naiv/tiltshift/ui/CaptureControls.kt | 107 +++++++ .../java/no/naiv/tiltshift/ui/ControlPanel.kt | 218 ++++++++++++++ .../java/no/naiv/tiltshift/util/StackBlur.kt | 248 ++++++++++++++++ 5 files changed, 579 insertions(+), 501 deletions(-) create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt create mode 100644 app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt 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 4736e52..b0401e7 100644 --- a/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt +++ b/app/src/main/java/no/naiv/tiltshift/camera/ImageCaptureHandler.kt @@ -18,6 +18,7 @@ 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 @@ -386,7 +387,7 @@ class ImageCaptureHandler( scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) - blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) + blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) scaled.recycle() scaled = null @@ -530,230 +531,4 @@ 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/ui/CameraScreen.kt b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt index 8012961..bbb706d 100644 --- a/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt +++ b/app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt @@ -1,19 +1,16 @@ 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.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.systemGestureExclusion import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -29,24 +26,21 @@ 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 @@ -65,10 +59,9 @@ 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 @@ -79,7 +72,6 @@ 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 @@ -608,265 +600,3 @@ 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 new file mode 100644 index 0000000..efec080 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt @@ -0,0 +1,107 @@ +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 new file mode 100644 index 0000000..ad94ad0 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt @@ -0,0 +1,218 @@ +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/util/StackBlur.kt b/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt new file mode 100644 index 0000000..0c1d363 --- /dev/null +++ b/app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt @@ -0,0 +1,248 @@ +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 + } +} From 2633f261efc9c7deaab937ac58fe455d0e53f59d Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Wed, 18 Mar 2026 17:52:33 +0100 Subject: [PATCH 7/7] Bump version to 1.1.5 Co-Authored-By: Claude Opus 4.6 (1M context) --- version.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/version.properties b/version.properties index bd3a999..5091055 100644 --- a/version.properties +++ b/version.properties @@ -1,4 +1,4 @@ versionMajor=1 versionMinor=1 -versionPatch=4 -versionCode=6 +versionPatch=5 +versionCode=7