Compare commits

..

No commits in common. "main" and "v1.1.1" have entirely different histories.

23 changed files with 865 additions and 1273 deletions

View file

@ -99,5 +99,6 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after
## Known limitations / future work ## Known limitations / future work
- `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing. - `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing.
- Dependencies updated to March 2026 versions (AGP 9.1, Kotlin 2.3, Compose BOM 2026.03). - Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API.
- Dependencies are pinned to late-2024 versions; periodic bumps recommended.
- Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00. - Fragment shader uses `int` uniform branching in GLSL ES 1.00 — works but could be cleaner with ES 3.00.

View file

@ -2,6 +2,7 @@ import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
} }
@ -24,7 +25,7 @@ val vCode = versionProperties["versionCode"].toString().toInt()
android { android {
namespace = "no.naiv.tiltshift" namespace = "no.naiv.tiltshift"
compileSdk = 36 compileSdk = 35
signingConfigs { signingConfigs {
create("release") { create("release") {
@ -41,7 +42,7 @@ android {
defaultConfig { defaultConfig {
applicationId = "no.naiv.tiltshift" applicationId = "no.naiv.tiltshift"
minSdk = 35 minSdk = 35
targetSdk = 36 targetSdk = 35
versionCode = vCode versionCode = vCode
versionName = "$vMajor.$vMinor.$vPatch" versionName = "$vMajor.$vMinor.$vPatch"
@ -69,6 +70,10 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
} }
kotlinOptions {
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
} }
@ -107,8 +112,8 @@ dependencies {
// Location // Location
implementation(libs.play.services.location) implementation(libs.play.services.location)
// Test // Permissions
testImplementation(libs.junit) implementation(libs.accompanist.permissions)
// Debug // Debug
debugImplementation(libs.androidx.ui.tooling) debugImplementation(libs.androidx.ui.tooling)

View file

@ -1,2 +1,5 @@
# Add project specific ProGuard rules here. # Add project specific ProGuard rules here.
# CameraX and GMS Location ship their own consumer ProGuard rules. # 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.** { *; }

View file

@ -2,15 +2,12 @@ package no.naiv.tiltshift
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -29,24 +26,23 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat 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.CameraScreen
import no.naiv.tiltshift.ui.theme.AppColors import no.naiv.tiltshift.ui.theme.AppColors
@ -70,47 +66,21 @@ class MainActivity : ComponentActivity() {
} }
} }
@OptIn(ExperimentalPermissionsApi::class)
@Composable @Composable
private fun TiltShiftApp() { private fun TiltShiftApp() {
val context = LocalContext.current val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
val activity = context as? ComponentActivity val locationPermissions = rememberMultiplePermissionsState(
listOf(
var cameraGranted by remember { Manifest.permission.ACCESS_FINE_LOCATION,
mutableStateOf( Manifest.permission.ACCESS_COARSE_LOCATION
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 // Request camera permission on launch
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!cameraGranted) { if (!cameraPermission.status.isGranted) {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA) cameraPermission.launchPermissionRequest()
} }
} }
@ -120,47 +90,28 @@ private fun TiltShiftApp() {
.background(Color.Black) .background(Color.Black)
) { ) {
when { when {
cameraGranted -> { cameraPermission.status.isGranted -> {
// Camera permission granted - show camera // Camera permission granted - show camera
CameraScreen() CameraScreen()
// Request location in background (for EXIF GPS) // Request location in background (for EXIF GPS)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (!locationGranted) { if (!locationPermissions.allPermissionsGranted) {
locationPermissionLauncher.launch( locationPermissions.launchMultiplePermissionRequest()
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
} }
} }
} }
else -> { else -> {
// Permanently denied: user has responded to the dialog, but permission // Permanently denied: not granted AND rationale not shown
// is still denied and the system won't show the dialog again val cameraPermanentlyDenied = !cameraPermission.status.isGranted &&
val cameraPermanentlyDenied = cameraResultReceived && !cameraPermission.status.shouldShowRationale
activity?.let {
!ActivityCompat.shouldShowRequestPermissionRationale(
it, Manifest.permission.CAMERA
)
} ?: false
// Show permission request UI // Show permission request UI
PermissionRequestScreen( PermissionRequestScreen(
onRequestCamera = { onRequestCamera = { cameraPermission.launchPermissionRequest() },
cameraPermissionLauncher.launch(Manifest.permission.CAMERA) onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
}, cameraGranted = cameraPermission.status.isGranted,
onRequestLocation = { locationGranted = locationPermissions.allPermissionsGranted,
locationPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
},
cameraGranted = false,
locationGranted = locationGranted,
cameraPermanentlyDenied = cameraPermanentlyDenied cameraPermanentlyDenied = cameraPermanentlyDenied
) )
} }

View file

@ -174,14 +174,24 @@ class CameraManager(private val context: Context) {
} }
/** /**
* Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom * Sets the zoom ratio. Updates UI state only after the camera confirms the change.
* 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) { fun setZoom(ratio: Float) {
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value) val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
_zoomRatio.value = clamped val future = camera?.cameraControl?.setZoomRatio(clamped)
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
}
} }
/** /**

View file

@ -18,7 +18,6 @@ import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.storage.PhotoSaver import no.naiv.tiltshift.storage.PhotoSaver
import no.naiv.tiltshift.storage.SaveResult import no.naiv.tiltshift.storage.SaveResult
import no.naiv.tiltshift.util.StackBlur
import java.util.concurrent.Executor import java.util.concurrent.Executor
import kotlin.coroutines.resume import kotlin.coroutines.resume
import kotlin.math.cos import kotlin.math.cos
@ -387,7 +386,7 @@ class ImageCaptureHandler(
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true) scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
blurred = StackBlur.blur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25)) blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
scaled.recycle() scaled.recycle()
scaled = null scaled = null
@ -531,4 +530,230 @@ class ImageCaptureHandler(
return fullPixels 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
}
} }

View file

@ -5,7 +5,6 @@ import android.graphics.SurfaceTexture
import android.opengl.GLES11Ext import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.nio.FloatBuffer import java.nio.FloatBuffer
@ -13,16 +12,10 @@ import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10 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.
* *
* Rendering pipeline (3 draw calls per frame): * This renderer receives camera frames via SurfaceTexture and applies
* 1. **Passthrough**: camera texture FBO-A (handles coordinate transform via vertex/texcoord) * the tilt-shift blur effect using GLSL shaders.
* 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( class TiltShiftRenderer(
private val context: Context, private val context: Context,
@ -30,30 +23,16 @@ class TiltShiftRenderer(
private val onFrameAvailable: () -> Unit private val onFrameAvailable: () -> Unit
) : GLSurfaceView.Renderer { ) : GLSurfaceView.Renderer {
companion object {
private const val TAG = "TiltShiftRenderer"
}
private lateinit var shader: TiltShiftShader private lateinit var shader: TiltShiftShader
private var surfaceTexture: SurfaceTexture? = null private var surfaceTexture: SurfaceTexture? = null
private var cameraTextureId: Int = 0 private var cameraTextureId: Int = 0
// Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only) private lateinit var vertexBuffer: FloatBuffer
private lateinit var cameraVertexBuffer: FloatBuffer private lateinit var texCoordBuffer: 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 surfaceWidth: Int = 0
private var surfaceHeight: 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) // Current effect parameters (updated from UI thread)
@Volatile @Volatile
var blurParameters: BlurParameters = BlurParameters.DEFAULT var blurParameters: BlurParameters = BlurParameters.DEFAULT
@ -90,33 +69,27 @@ class TiltShiftRenderer(
@Volatile @Volatile
private var currentTexCoords = texCoordsBack private var currentTexCoords = texCoordsBack
@Volatile
private var updateTexCoordBuffer = false
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) { override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
GLES20.glClearColor(0f, 0f, 0f, 1f) GLES20.glClearColor(0f, 0f, 0f, 1f)
// Initialize shader
shader = TiltShiftShader(context) shader = TiltShiftShader(context)
shader.initialize() shader.initialize()
// Camera quad vertex buffer (crop-to-fill, recomputed when resolution is known) // Allocate vertex buffer (8 floats = 4 vertices × 2 components)
cameraVertexBuffer = allocateFloatBuffer(8) vertexBuffer = ByteBuffer.allocateDirect(8 * 4)
cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f)) .order(ByteOrder.nativeOrder())
cameraVertexBuffer.position(0) .asFloatBuffer()
// Fill with default full-screen quad; will be recomputed when camera resolution is known
vertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
vertexBuffer.position(0)
// Camera texcoord buffer (rotated for portrait) // Create texture coordinate buffer
cameraTexCoordBuffer = allocateFloatBuffer(8) texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4)
cameraTexCoordBuffer.put(currentTexCoords) .order(ByteOrder.nativeOrder())
cameraTexCoordBuffer.position(0) .asFloatBuffer()
.put(currentTexCoords)
// Fullscreen quad for blur passes (standard coords) texCoordBuffer.position(0)
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 // Create camera texture
val textures = IntArray(1) val textures = IntArray(1)
@ -141,75 +114,88 @@ class TiltShiftRenderer(
surfaceWidth = width surfaceWidth = width
surfaceHeight = height surfaceHeight = height
vertexBufferDirty = true vertexBufferDirty = true
recreateFBOs(width, height)
} }
override fun onDrawFrame(gl: GL10?) { override fun onDrawFrame(gl: GL10?) {
// Update texture with latest camera frame
surfaceTexture?.updateTexImage() surfaceTexture?.updateTexImage()
// Recompute vertex buffer for crop-to-fill when camera or surface dimensions change
if (vertexBufferDirty) { if (vertexBufferDirty) {
recomputeVertices() recomputeVertices()
vertexBufferDirty = false vertexBufferDirty = false
} }
// Update texture coordinate buffer if camera changed
if (updateTexCoordBuffer) { if (updateTexCoordBuffer) {
cameraTexCoordBuffer.clear() texCoordBuffer.clear()
cameraTexCoordBuffer.put(currentTexCoords) texCoordBuffer.put(currentTexCoords)
cameraTexCoordBuffer.position(0) texCoordBuffer.position(0)
updateTexCoordBuffer = false 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) GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
shader.usePassthrough(cameraTextureId)
drawQuad( // Use shader and set parameters
shader.passthroughPositionLoc, shader.passthroughTexCoordLoc, shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera)
cameraVertexBuffer, cameraTexCoordBuffer
// Set vertex positions
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
GLES20.glVertexAttribPointer(
shader.aPositionLocation,
2,
GLES20.GL_FLOAT,
false,
0,
vertexBuffer
) )
// --- Pass 2: FBO-A → FBO-B (horizontal blur) --- // Set texture coordinates
GLES20.glFramebufferTexture2D( GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0, GLES20.glVertexAttribPointer(
GLES20.GL_TEXTURE_2D, fboTexB, 0 shader.aTexCoordLocation,
) 2,
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) GLES20.GL_FLOAT,
shader.useBlurPass(fboTexA, params, surfaceWidth, surfaceHeight, 1f, 0f) false,
drawQuad( 0,
shader.blurPositionLoc, shader.blurTexCoordLoc, texCoordBuffer
fullscreenVertexBuffer, fullscreenTexCoordBuffer
) )
// --- Pass 3: FBO-B → screen (vertical blur) --- // Draw quad
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0) GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT) // Cleanup
shader.useBlurPass(fboTexB, params, surfaceWidth, surfaceHeight, 0f, 1f) GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
drawQuad( GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
shader.blurPositionLoc, shader.blurTexCoordLoc,
fullscreenVertexBuffer, fullscreenTexCoordBuffer
)
} }
/**
* Updates blur parameters. Thread-safe.
*/
fun updateParameters(params: BlurParameters) { fun updateParameters(params: BlurParameters) {
blurParameters = params blurParameters = params
} }
/**
* Sets whether using front camera. Updates texture coordinates accordingly.
* Thread-safe - actual buffer update happens on next frame.
*/
fun setFrontCamera(front: Boolean) { fun setFrontCamera(front: Boolean) {
if (isFrontCamera != front) { if (isFrontCamera != front) {
isFrontCamera = front isFrontCamera = front
currentTexCoords = if (front) texCoordsFront else texCoordsBack currentTexCoords = if (front) texCoordsFront else texCoordsBack
// Buffer will be updated on next draw
updateTexCoordBuffer = true 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) { fun setCameraResolution(width: Int, height: Int) {
if (cameraWidth != width || cameraHeight != height) { if (cameraWidth != width || cameraHeight != height) {
cameraWidth = width cameraWidth = width
@ -218,6 +204,45 @@ class TiltShiftRenderer(
} }
} }
/**
* Recomputes vertex positions to achieve crop-to-fill.
*
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates,
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex
* quad so the camera frame fills the screen without stretching the GPU clips the overflow.
*/
private fun recomputeVertices() {
var scaleX = 1f
var scaleY = 1f
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
// After 90° rotation: portrait width = cameraHeight, portrait height = cameraWidth
val cameraRatio = cameraHeight.toFloat() / cameraWidth
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
if (cameraRatio > screenRatio) {
// Camera wider than screen → crop sides
scaleX = cameraRatio / screenRatio
} else {
// Camera taller than screen → crop top/bottom
scaleY = screenRatio / cameraRatio
}
}
vertexBuffer.clear()
vertexBuffer.put(floatArrayOf(
-scaleX, -scaleY,
scaleX, -scaleY,
-scaleX, scaleY,
scaleX, scaleY
))
vertexBuffer.position(0)
}
/**
* Releases OpenGL resources.
* Must be called from GL thread.
*/
fun release() { fun release() {
shader.release() shader.release()
surfaceTexture?.release() surfaceTexture?.release()
@ -227,117 +252,5 @@ class TiltShiftRenderer(
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0) GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
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()
} }
} }

View file

@ -4,167 +4,57 @@ import android.content.Context
import android.opengl.GLES11Ext import android.opengl.GLES11Ext
import android.opengl.GLES20 import android.opengl.GLES20
import no.naiv.tiltshift.R import no.naiv.tiltshift.R
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlin.math.cos import kotlin.math.cos
import kotlin.math.sin import kotlin.math.sin
import java.io.BufferedReader
import java.io.InputStreamReader
/** /**
* Manages OpenGL shader programs for the two-pass tilt-shift effect. * Manages OpenGL shader programs for the 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) { class TiltShiftShader(private val context: Context) {
// --- Passthrough program (camera → FBO) --- var programId: Int = 0
private var passthroughProgramId: Int = 0
var passthroughPositionLoc: Int = 0
private set private set
var passthroughTexCoordLoc: Int = 0
private set
private var passthroughTextureLoc: Int = 0
// --- Blur program (FBO → FBO/screen) --- // Attribute locations
var aPositionLocation: Int = 0
private var blurProgramId: Int = 0
var blurPositionLoc: Int = 0
private set private set
var blurTexCoordLoc: Int = 0 var aTexCoordLocation: Int = 0
private set private set
private var blurTextureLoc: Int = 0
private var blurModeLoc: Int = 0 // Uniform locations
private var blurPositionXLoc: Int = 0 private var uTextureLocation: Int = 0
private var blurPositionYLoc: Int = 0 private var uModeLocation: Int = 0
private var blurSizeLoc: Int = 0 private var uIsFrontCameraLocation: Int = 0
private var blurAmountLoc: Int = 0 private var uAngleLocation: Int = 0
private var blurFalloffLoc: Int = 0 private var uPositionXLocation: Int = 0
private var blurAspectRatioLoc: Int = 0 private var uPositionYLocation: Int = 0
private var blurResolutionLoc: Int = 0 private var uSizeLocation: Int = 0
private var blurCosAngleLoc: Int = 0 private var uBlurAmountLocation: Int = 0
private var blurSinAngleLoc: Int = 0 private var uFalloffLocation: Int = 0
private var blurDirectionLoc: Int = 0 private var uAspectRatioLocation: Int = 0
private var uResolutionLocation: Int = 0
private var uCosAngleLocation: Int = 0
private var uSinAngleLocation: Int = 0
/** /**
* Compiles and links both shader programs. * Compiles and links the shader program.
* Must be called from GL thread. * Must be called from GL thread.
*/ */
fun initialize() { fun initialize() {
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex) val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment)
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource) val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
// Passthrough program programId = GLES20.glCreateProgram()
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, vertexShader)
GLES20.glAttachShader(programId, fragmentShader) GLES20.glAttachShader(programId, fragmentShader)
GLES20.glLinkProgram(programId) GLES20.glLinkProgram(programId)
// Check for link errors
val linkStatus = IntArray(1) val linkStatus = IntArray(1)
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0) GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
if (linkStatus[0] == 0) { if (linkStatus[0] == 0) {
@ -173,7 +63,72 @@ class TiltShiftShader(private val context: Context) {
throw RuntimeException("Shader program link failed: $error") throw RuntimeException("Shader program link failed: $error")
} }
return programId // Get attribute locations
aPositionLocation = GLES20.glGetAttribLocation(programId, "aPosition")
aTexCoordLocation = GLES20.glGetAttribLocation(programId, "aTexCoord")
// Get uniform locations
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
uModeLocation = GLES20.glGetUniformLocation(programId, "uMode")
uIsFrontCameraLocation = GLES20.glGetUniformLocation(programId, "uIsFrontCamera")
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
uPositionXLocation = GLES20.glGetUniformLocation(programId, "uPositionX")
uPositionYLocation = GLES20.glGetUniformLocation(programId, "uPositionY")
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
uFalloffLocation = GLES20.glGetUniformLocation(programId, "uFalloff")
uAspectRatioLocation = GLES20.glGetUniformLocation(programId, "uAspectRatio")
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
uCosAngleLocation = GLES20.glGetUniformLocation(programId, "uCosAngle")
uSinAngleLocation = GLES20.glGetUniformLocation(programId, "uSinAngle")
// Clean up shaders (they're linked into program now)
GLES20.glDeleteShader(vertexShader)
GLES20.glDeleteShader(fragmentShader)
}
/**
* Uses the shader program and sets uniforms.
*/
fun use(textureId: Int, params: BlurParameters, width: Int, height: Int, isFrontCamera: Boolean = false) {
GLES20.glUseProgram(programId)
// Bind camera texture
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
GLES20.glUniform1i(uTextureLocation, 0)
// Set effect parameters
GLES20.glUniform1i(uModeLocation, if (params.mode == BlurMode.RADIAL) 1 else 0)
GLES20.glUniform1i(uIsFrontCameraLocation, if (isFrontCamera) 1 else 0)
GLES20.glUniform1f(uAngleLocation, params.angle)
GLES20.glUniform1f(uPositionXLocation, params.positionX)
GLES20.glUniform1f(uPositionYLocation, params.positionY)
GLES20.glUniform1f(uSizeLocation, params.size)
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
GLES20.glUniform1f(uFalloffLocation, params.falloff)
GLES20.glUniform1f(uAspectRatioLocation, params.aspectRatio)
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
// Precompute angle trig on CPU to avoid per-fragment transcendental calls.
// The adjusted angle accounts for the 90deg coordinate transform.
val adjustedAngle = if (isFrontCamera) {
-params.angle - (Math.PI / 2).toFloat()
} else {
params.angle + (Math.PI / 2).toFloat()
}
GLES20.glUniform1f(uCosAngleLocation, cos(adjustedAngle))
GLES20.glUniform1f(uSinAngleLocation, sin(adjustedAngle))
}
/**
* Releases shader resources.
*/
fun release() {
if (programId != 0) {
GLES20.glDeleteProgram(programId)
programId = 0
}
} }
private fun loadShaderSource(resourceId: Int): String { private fun loadShaderSource(resourceId: Int): String {
@ -187,6 +142,7 @@ class TiltShiftShader(private val context: Context) {
GLES20.glShaderSource(shader, source) GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader) GLES20.glCompileShader(shader)
// Check for compile errors
val compileStatus = IntArray(1) val compileStatus = IntArray(1)
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0) GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
if (compileStatus[0] == 0) { if (compileStatus[0] == 0) {

View file

@ -17,6 +17,18 @@ import java.time.format.DateTimeFormatter
import java.util.Locale 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. * Handles saving captured photos to the device gallery.
*/ */

View file

@ -1,16 +0,0 @@
package no.naiv.tiltshift.storage
import android.graphics.Bitmap
import android.net.Uri
/**
* Result of a photo save operation.
*/
sealed class SaveResult {
data class Success(
val uri: Uri,
val originalUri: Uri? = null,
val thumbnail: Bitmap? = null
) : SaveResult()
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
}

View file

@ -1,16 +1,19 @@
package no.naiv.tiltshift.ui package no.naiv.tiltshift.ui
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.SurfaceTexture import android.graphics.SurfaceTexture
import android.opengl.GLSurfaceView import android.opengl.GLSurfaceView
import android.util.Log import android.util.Log
import android.view.Surface
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image import androidx.compose.animation.scaleIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.systemGestureExclusion
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -26,21 +29,24 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.systemGestureExclusion
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FlipCameraAndroid 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.LocationOff
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -59,9 +65,10 @@ import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion 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.semantics
import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -72,6 +79,7 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.effect.TiltShiftRenderer import no.naiv.tiltshift.effect.TiltShiftRenderer
import no.naiv.tiltshift.ui.theme.AppColors import no.naiv.tiltshift.ui.theme.AppColors
@ -600,3 +608,265 @@ fun CameraScreen(
} }
} }
/**
* Mode toggle for Linear / Radial blur modes.
*/
@Composable
private fun ModeToggle(
currentMode: BlurMode,
onModeChange: (BlurMode) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.background(AppColors.OverlayDark)
.padding(4.dp),
horizontalArrangement = Arrangement.Center
) {
ModeButton(
text = "Linear",
isSelected = currentMode == BlurMode.LINEAR,
onClick = { onModeChange(BlurMode.LINEAR) }
)
Spacer(modifier = Modifier.width(4.dp))
ModeButton(
text = "Radial",
isSelected = currentMode == BlurMode.RADIAL,
onClick = { onModeChange(BlurMode.RADIAL) }
)
}
}
@Composable
private fun ModeButton(
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(if (isSelected) AppColors.Accent else Color.Transparent)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
stateDescription = if (isSelected) "Selected" else "Not selected"
contentDescription = "$text blur mode"
},
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = if (isSelected) Color.Black else Color.White,
fontSize = 14.sp
)
}
}
/**
* Control panel with sliders for blur parameters.
* Includes position/size/angle sliders as gesture alternatives for accessibility.
*/
@Composable
private fun ControlPanel(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
Column(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Reset button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onReset,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = "Reset all parameters to defaults",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
// Blur intensity slider
SliderControl(
label = "Blur",
value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
)
// Falloff slider
SliderControl(
label = "Falloff",
value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
)
// Size slider (gesture alternative for pinch-to-resize)
SliderControl(
label = "Size",
value = params.size,
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
)
// Aspect ratio slider (radial mode only)
if (params.mode == BlurMode.RADIAL) {
SliderControl(
label = "Shape",
value = params.aspectRatio,
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
formatValue = { "%.1f:1".format(it) },
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
)
}
// Angle slider (gesture alternative for two-finger rotate)
SliderControl(
label = "Angle",
value = params.angle,
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
)
}
}
@Composable
private fun SliderControl(
label: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
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)
)
}
}
}

View file

@ -1,107 +0,0 @@
package no.naiv.tiltshift.ui
import android.graphics.Bitmap
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import no.naiv.tiltshift.ui.theme.AppColors
/**
* Capture button with processing indicator.
*/
@Composable
fun CaptureButton(
isCapturing: Boolean,
isProcessing: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val outerSize = 72.dp
val innerSize = if (isCapturing) 48.dp else 60.dp
Box(
modifier = modifier
.size(outerSize)
.clip(CircleShape)
.border(4.dp, Color.White, CircleShape)
.clickable(
enabled = !isCapturing,
role = Role.Button,
onClick = onClick
)
.semantics {
contentDescription = "Capture photo with tilt-shift effect"
if (isCapturing) stateDescription = "Processing photo"
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.size(innerSize)
.clip(CircleShape)
.background(if (isCapturing) AppColors.Accent else Color.White),
contentAlignment = Alignment.Center
) {
if (isProcessing) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = Color.Black,
strokeWidth = 3.dp
)
}
}
}
}
/**
* Rounded thumbnail of the last captured photo.
* Tapping opens the image in the default photo viewer.
*/
@Composable
fun LastPhotoThumbnail(
thumbnail: Bitmap?,
onTap: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = thumbnail != null,
enter = fadeIn() + scaleIn(initialScale = 0.6f),
exit = fadeOut(),
modifier = modifier
) {
thumbnail?.let { bmp ->
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Last captured photo. Tap to open in viewer.",
contentScale = ContentScale.Crop,
modifier = Modifier
.size(52.dp)
.clip(RoundedCornerShape(10.dp))
.border(2.dp, Color.White, RoundedCornerShape(10.dp))
.clickable(role = Role.Button, onClick = onTap)
)
}
}
}

View file

@ -1,218 +0,0 @@
package no.naiv.tiltshift.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import no.naiv.tiltshift.effect.BlurMode
import no.naiv.tiltshift.effect.BlurParameters
import no.naiv.tiltshift.ui.theme.AppColors
/**
* Mode toggle for Linear / Radial blur modes.
*/
@Composable
fun ModeToggle(
currentMode: BlurMode,
onModeChange: (BlurMode) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.clip(RoundedCornerShape(20.dp))
.background(AppColors.OverlayDark)
.padding(4.dp),
horizontalArrangement = Arrangement.Center
) {
ModeButton(
text = "Linear",
isSelected = currentMode == BlurMode.LINEAR,
onClick = { onModeChange(BlurMode.LINEAR) }
)
Spacer(modifier = Modifier.width(4.dp))
ModeButton(
text = "Radial",
isSelected = currentMode == BlurMode.RADIAL,
onClick = { onModeChange(BlurMode.RADIAL) }
)
}
}
@Composable
private fun ModeButton(
text: String,
isSelected: Boolean,
onClick: () -> Unit
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(if (isSelected) AppColors.Accent else Color.Transparent)
.clickable(role = Role.Button, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp)
.semantics {
stateDescription = if (isSelected) "Selected" else "Not selected"
contentDescription = "$text blur mode"
},
contentAlignment = Alignment.Center
) {
Text(
text = text,
color = if (isSelected) Color.Black else Color.White,
fontSize = 14.sp
)
}
}
/**
* Control panel with sliders for blur parameters.
* Includes position/size/angle sliders as gesture alternatives for accessibility.
*/
@Composable
fun ControlPanel(
params: BlurParameters,
onParamsChange: (BlurParameters) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
val currentParams by rememberUpdatedState(params)
val currentOnParamsChange by rememberUpdatedState(onParamsChange)
Column(
modifier = modifier
.width(200.dp)
.clip(RoundedCornerShape(16.dp))
.background(AppColors.OverlayDarker)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Reset button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
IconButton(
onClick = onReset,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = "Reset all parameters to defaults",
tint = Color.White,
modifier = Modifier.size(18.dp)
)
}
}
SliderControl(
label = "Blur",
value = params.blurAmount,
valueRange = BlurParameters.MIN_BLUR..BlurParameters.MAX_BLUR,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(blurAmount = it)) }
)
SliderControl(
label = "Falloff",
value = params.falloff,
valueRange = BlurParameters.MIN_FALLOFF..BlurParameters.MAX_FALLOFF,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(falloff = it)) }
)
SliderControl(
label = "Size",
value = params.size,
valueRange = BlurParameters.MIN_SIZE..BlurParameters.MAX_SIZE,
formatValue = { "${(it * 100).toInt()}%" },
onValueChange = { currentOnParamsChange(currentParams.copy(size = it)) }
)
if (params.mode == BlurMode.RADIAL) {
SliderControl(
label = "Shape",
value = params.aspectRatio,
valueRange = BlurParameters.MIN_ASPECT..BlurParameters.MAX_ASPECT,
formatValue = { "%.1f:1".format(it) },
onValueChange = { currentOnParamsChange(currentParams.copy(aspectRatio = it)) }
)
}
SliderControl(
label = "Angle",
value = params.angle,
valueRange = (-Math.PI.toFloat())..Math.PI.toFloat(),
formatValue = { "${(it * 180f / Math.PI.toFloat()).toInt()}°" },
onValueChange = { currentOnParamsChange(currentParams.copy(angle = it)) }
)
}
}
@Composable
private fun SliderControl(
label: String,
value: Float,
valueRange: ClosedFloatingPointRange<Float>,
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)}" }
)
}
}

View file

@ -232,9 +232,9 @@ private fun determineGestureType(
return when { return when {
// Very center of focus zone -> rotation (small area) // Very center of focus zone -> rotation (small area)
distFromCenter < focusSize * 0.3f -> GestureType.ROTATE distFromCenter < focusSize * 0.3f -> GestureType.ROTATE
// Near the blur boundary -> size adjustment // Near the blur effect -> size adjustment (large area)
distFromCenter < focusSize * 1.3f -> GestureType.PINCH_SIZE distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE
// Outside the effect -> camera zoom // Far outside -> camera zoom
else -> GestureType.PINCH_ZOOM else -> GestureType.PINCH_ZOOM
} }
} }

View file

@ -1,248 +0,0 @@
package no.naiv.tiltshift.util
import android.graphics.Bitmap
/**
* Fast stack blur algorithm for CPU-based bitmap blurring.
*
* Used by the capture/gallery pipeline where GPU shaders aren't available.
* Stack blur is an approximation of Gaussian blur that runs in O(n) per pixel
* regardless of radius, making it suitable for large images.
*/
object StackBlur {
/**
* Applies stack blur to a bitmap and returns a new blurred bitmap.
* The source bitmap is not modified.
*
* @param bitmap Source bitmap to blur.
* @param radius Blur radius (1-25). Larger = more blur.
* @return A new blurred bitmap. The caller owns it and must recycle it.
*/
fun blur(bitmap: Bitmap, radius: Int): Bitmap {
if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true)
val w = bitmap.width
val h = bitmap.height
val pix = IntArray(w * h)
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
val wm = w - 1
val hm = h - 1
val wh = w * h
val div = radius + radius + 1
val r = IntArray(wh)
val g = IntArray(wh)
val b = IntArray(wh)
var rsum: Int
var gsum: Int
var bsum: Int
var x: Int
var y: Int
var i: Int
var p: Int
var yp: Int
var yi: Int
var yw: Int
val vmin = IntArray(maxOf(w, h))
var divsum = (div + 1) shr 1
divsum *= divsum
val dv = IntArray(256 * divsum)
for (i2 in 0 until 256 * divsum) {
dv[i2] = (i2 / divsum)
}
yi = 0
yw = 0
val stack = Array(div) { IntArray(3) }
var stackpointer: Int
var stackstart: Int
var sir: IntArray
var rbs: Int
val r1 = radius + 1
var routsum: Int
var goutsum: Int
var boutsum: Int
var rinsum: Int
var ginsum: Int
var binsum: Int
// Horizontal pass
for (y2 in 0 until h) {
rinsum = 0
ginsum = 0
binsum = 0
routsum = 0
goutsum = 0
boutsum = 0
rsum = 0
gsum = 0
bsum = 0
for (i2 in -radius..radius) {
p = pix[yi + minOf(wm, maxOf(i2, 0))]
sir = stack[i2 + radius]
sir[0] = (p and 0xff0000) shr 16
sir[1] = (p and 0x00ff00) shr 8
sir[2] = (p and 0x0000ff)
rbs = r1 - kotlin.math.abs(i2)
rsum += sir[0] * rbs
gsum += sir[1] * rbs
bsum += sir[2] * rbs
if (i2 > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
}
stackpointer = radius
for (x2 in 0 until w) {
r[yi] = dv[rsum]
g[yi] = dv[gsum]
b[yi] = dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (y2 == 0) {
vmin[x2] = minOf(x2 + radius + 1, wm)
}
p = pix[yw + vmin[x2]]
sir[0] = (p and 0xff0000) shr 16
sir[1] = (p and 0x00ff00) shr 8
sir[2] = (p and 0x0000ff)
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[(stackpointer) % div]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi++
}
yw += w
}
// Vertical pass
for (x2 in 0 until w) {
rinsum = 0
ginsum = 0
binsum = 0
routsum = 0
goutsum = 0
boutsum = 0
rsum = 0
gsum = 0
bsum = 0
yp = -radius * w
for (i2 in -radius..radius) {
yi = maxOf(0, yp) + x2
sir = stack[i2 + radius]
sir[0] = r[yi]
sir[1] = g[yi]
sir[2] = b[yi]
rbs = r1 - kotlin.math.abs(i2)
rsum += r[yi] * rbs
gsum += g[yi] * rbs
bsum += b[yi] * rbs
if (i2 > 0) {
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
} else {
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
}
if (i2 < hm) {
yp += w
}
}
yi = x2
stackpointer = radius
for (y2 in 0 until h) {
pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
rsum -= routsum
gsum -= goutsum
bsum -= boutsum
stackstart = stackpointer - radius + div
sir = stack[stackstart % div]
routsum -= sir[0]
goutsum -= sir[1]
boutsum -= sir[2]
if (x2 == 0) {
vmin[y2] = minOf(y2 + r1, hm) * w
}
p = x2 + vmin[y2]
sir[0] = r[p]
sir[1] = g[p]
sir[2] = b[p]
rinsum += sir[0]
ginsum += sir[1]
binsum += sir[2]
rsum += rinsum
gsum += ginsum
bsum += binsum
stackpointer = (stackpointer + 1) % div
sir = stack[stackpointer]
routsum += sir[0]
goutsum += sir[1]
boutsum += sir[2]
rinsum -= sir[0]
ginsum -= sir[1]
binsum -= sir[2]
yi += w
}
}
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
result.setPixels(pix, 0, w, 0, 0, w, h)
return result
}
}

View file

@ -1,58 +1,71 @@
// Fragment shader for tilt-shift blur pass (two-pass separable Gaussian) #extension GL_OES_EGL_image_external : require
// Reads from a sampler2D (FBO texture already in screen orientation).
// Used twice: once for horizontal blur, once for vertical blur. // Fragment shader for tilt-shift effect
// Supports both linear and radial blur modes
precision mediump float; precision mediump float;
uniform sampler2D uTexture; // Camera texture (external texture for camera preview)
uniform samplerExternalOES uTexture;
// Effect parameters // Effect parameters
uniform int uMode; // 0 = linear, 1 = radial uniform int uMode; // 0 = linear, 1 = radial
uniform float uPositionX; // Horizontal center of focus (0-1, screen space) uniform int uIsFrontCamera; // 0 = back camera, 1 = front camera
uniform float uPositionY; // Vertical center of focus (0-1, screen space, 0 = top) uniform float uAngle; // Rotation angle in radians
uniform float uPositionX; // Horizontal center of focus (0-1)
uniform float uPositionY; // Vertical center of focus (0-1)
uniform float uSize; // Size of in-focus region (0-1) uniform float uSize; // Size of in-focus region (0-1)
uniform float uBlurAmount; // Maximum blur intensity (0-1) uniform float uBlurAmount; // Maximum blur intensity (0-1)
uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual) uniform float uFalloff; // Transition sharpness (0-1, higher = more gradual)
uniform float uAspectRatio; // Ellipse aspect ratio for radial mode uniform float uAspectRatio; // Ellipse aspect ratio for radial mode
uniform vec2 uResolution; // Surface resolution for proper sampling uniform vec2 uResolution; // Texture resolution for proper sampling
// Precomputed trig for the raw screen-space angle // Precomputed trig for the adjusted angle (avoids per-fragment cos/sin calls)
uniform float uCosAngle; uniform float uCosAngle;
uniform float uSinAngle; uniform float uSinAngle;
// Blur direction: (1,0) for horizontal pass, (0,1) for vertical pass
uniform vec2 uBlurDirection;
varying vec2 vTexCoord; varying vec2 vTexCoord;
// Calculate distance from the focus region for LINEAR mode // Calculate signed distance from the focus region for LINEAR mode
// Works in screen space: X right (0-1), Y down (0-1) float linearFocusDistance(vec2 uv) {
// Distances are normalized to the Y axis (height) to match the overlay, // Center point of the focus region
// which defines focus size as a fraction of screen height. // Transform from screen coordinates to texture coordinates
float linearFocusDistance(vec2 screenPos) { // Back camera: Screen (x,y) -> Texture (y, 1-x)
vec2 center = vec2(uPositionX, uPositionY); // Front camera: Screen (x,y) -> Texture (1-y, 1-x) (additional X flip for mirror)
vec2 offset = screenPos - center; vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 offset = uv - center;
// Scale X into the same physical units as Y (height-normalized) // Correct for screen aspect ratio to make coordinate space square
float screenAspect = uResolution.x / uResolution.y; float screenAspect = uResolution.x / uResolution.y;
offset.x *= screenAspect; offset.y *= screenAspect;
// Perpendicular distance to the rotated focus line // Use precomputed cos/sin for the adjusted angle
float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle; float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
return abs(rotatedY); return abs(rotatedY);
} }
// Calculate distance from the focus region for RADIAL mode // Calculate signed distance from the focus region for RADIAL mode
float radialFocusDistance(vec2 screenPos) { float radialFocusDistance(vec2 uv) {
vec2 center = vec2(uPositionX, uPositionY); // Center point of the focus region
vec2 offset = screenPos - center; vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 offset = uv - center;
// Scale X into the same physical units as Y (height-normalized) // Correct for screen aspect ratio
float screenAspect = uResolution.x / uResolution.y; float screenAspect = uResolution.x / uResolution.y;
offset.x *= screenAspect; offset.y *= screenAspect;
// Rotate offset // Use precomputed cos/sin for rotation
vec2 rotated = vec2( vec2 rotated = vec2(
offset.x * uCosAngle - offset.y * uSinAngle, offset.x * uCosAngle - offset.y * uSinAngle,
offset.x * uSinAngle + offset.y * uCosAngle offset.x * uSinAngle + offset.y * uCosAngle
@ -61,59 +74,83 @@ float radialFocusDistance(vec2 screenPos) {
// Apply ellipse aspect ratio // Apply ellipse aspect ratio
rotated.x /= uAspectRatio; rotated.x /= uAspectRatio;
// Distance from center (elliptical)
return length(rotated); return length(rotated);
} }
// Calculate blur factor based on distance from focus // Calculate blur factor based on distance from focus
float blurFactor(float dist) { float blurFactor(float dist) {
float halfSize = uSize * 0.5; float halfSize = uSize * 0.5;
float transitionSize = halfSize * uFalloff * 3.0; // Falloff range scales with the falloff parameter
float transitionSize = halfSize * uFalloff;
if (dist < halfSize) { if (dist < halfSize) {
return 0.0; return 0.0; // In focus region
} }
// Smooth falloff using smoothstep
float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001); float normalizedDist = (dist - halfSize) / max(transitionSize, 0.001);
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount; return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
} }
void main() { // Sample with Gaussian blur (9-tap, sigma ~= 2.0, unrolled for GLSL ES 1.00 compatibility)
// Convert FBO texture coords to screen space (flip Y: GL bottom-up → screen top-down) vec4 sampleBlurred(vec2 uv, float blur) {
vec2 screenPos = vec2(vTexCoord.x, 1.0 - vTexCoord.y); if (blur < 0.01) {
return texture2D(uTexture, uv);
}
vec2 texelSize = 1.0 / uResolution;
// For radial mode, blur in radial direction from center
// For linear mode, blur perpendicular to focus line
vec2 blurDir;
if (uMode == 1) {
// Radial: blur away from center
vec2 center;
if (uIsFrontCamera == 1) {
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
} else {
center = vec2(uPositionY, 1.0 - uPositionX);
}
vec2 toCenter = uv - center;
float len = length(toCenter);
if (len > 0.001) {
blurDir = toCenter / len;
} else {
blurDir = vec2(1.0, 0.0);
}
} else {
// Linear: blur perpendicular to focus line using precomputed trig
blurDir = vec2(uCosAngle, uSinAngle);
}
// Scale blur radius by blur amount
float radius = blur * 20.0;
vec2 step = blurDir * texelSize * radius;
// Unrolled 9-tap Gaussian blur (avoids integer-branched weight lookup)
vec4 color = vec4(0.0);
color += texture2D(uTexture, uv + step * -4.0) * 0.0162;
color += texture2D(uTexture, uv + step * -3.0) * 0.0540;
color += texture2D(uTexture, uv + step * -2.0) * 0.1216;
color += texture2D(uTexture, uv + step * -1.0) * 0.1933;
color += texture2D(uTexture, uv) * 0.2258;
color += texture2D(uTexture, uv + step * 1.0) * 0.1933;
color += texture2D(uTexture, uv + step * 2.0) * 0.1216;
color += texture2D(uTexture, uv + step * 3.0) * 0.0540;
color += texture2D(uTexture, uv + step * 4.0) * 0.0162;
return color;
}
void main() {
float dist; float dist;
if (uMode == 1) { if (uMode == 1) {
dist = radialFocusDistance(screenPos); dist = radialFocusDistance(vTexCoord);
} else { } else {
dist = linearFocusDistance(screenPos); dist = linearFocusDistance(vTexCoord);
} }
float blur = blurFactor(dist); float blur = blurFactor(dist);
if (blur < 0.01) { gl_FragColor = sampleBlurred(vTexCoord, blur);
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;
} }

View file

@ -1,15 +0,0 @@
#extension GL_OES_EGL_image_external : require
// Passthrough fragment shader: copies camera texture to FBO
// This separates the camera coordinate transform (handled by vertex/texcoord setup)
// from the blur passes, which then work entirely in screen space.
precision mediump float;
uniform samplerExternalOES uTexture;
varying vec2 vTexCoord;
void main() {
gl_FragColor = texture2D(uTexture, vTexCoord);
}

View file

@ -1,38 +0,0 @@
package no.naiv.tiltshift.camera
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class LensControllerTest {
@Test
fun `getCurrentLens returns null before initialization`() {
val controller = LensController()
assertNull(controller.getCurrentLens())
}
@Test
fun `getAvailableLenses returns empty before initialization`() {
val controller = LensController()
assertTrue(controller.getAvailableLenses().isEmpty())
}
@Test
fun `selectLens returns false for unknown lens`() {
val controller = LensController()
assertFalse(controller.selectLens("nonexistent"))
}
@Test
fun `cycleToNextLens returns null when no lenses`() {
val controller = LensController()
assertNull(controller.cycleToNextLens())
}
// Note: initialize() requires CameraInfo instances which need Android framework.
// Integration tests with Robolectric or on-device tests would cover that path.
}

View file

@ -1,151 +0,0 @@
package no.naiv.tiltshift.effect
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
import kotlin.math.PI
class BlurParametersTest {
@Test
fun `DEFAULT has expected values`() {
val default = BlurParameters.DEFAULT
assertEquals(BlurMode.LINEAR, default.mode)
assertEquals(0f, default.angle, 0f)
assertEquals(0.5f, default.positionX, 0f)
assertEquals(0.5f, default.positionY, 0f)
assertEquals(0.3f, default.size, 0f)
assertEquals(0.8f, default.blurAmount, 0f)
assertEquals(0.5f, default.falloff, 0f)
assertEquals(1.0f, default.aspectRatio, 0f)
}
// --- withSize ---
@Test
fun `withSize clamps below minimum`() {
val params = BlurParameters.DEFAULT.withSize(0.01f)
assertEquals(BlurParameters.MIN_SIZE, params.size, 0f)
}
@Test
fun `withSize clamps above maximum`() {
val params = BlurParameters.DEFAULT.withSize(5.0f)
assertEquals(BlurParameters.MAX_SIZE, params.size, 0f)
}
@Test
fun `withSize accepts value in range`() {
val params = BlurParameters.DEFAULT.withSize(0.5f)
assertEquals(0.5f, params.size, 0f)
}
// --- withBlurAmount ---
@Test
fun `withBlurAmount clamps below minimum`() {
val params = BlurParameters.DEFAULT.withBlurAmount(-1f)
assertEquals(BlurParameters.MIN_BLUR, params.blurAmount, 0f)
}
@Test
fun `withBlurAmount clamps above maximum`() {
val params = BlurParameters.DEFAULT.withBlurAmount(2f)
assertEquals(BlurParameters.MAX_BLUR, params.blurAmount, 0f)
}
// --- withFalloff ---
@Test
fun `withFalloff clamps below minimum`() {
val params = BlurParameters.DEFAULT.withFalloff(0f)
assertEquals(BlurParameters.MIN_FALLOFF, params.falloff, 0f)
}
@Test
fun `withFalloff clamps above maximum`() {
val params = BlurParameters.DEFAULT.withFalloff(5f)
assertEquals(BlurParameters.MAX_FALLOFF, params.falloff, 0f)
}
// --- withAspectRatio ---
@Test
fun `withAspectRatio clamps below minimum`() {
val params = BlurParameters.DEFAULT.withAspectRatio(0.1f)
assertEquals(BlurParameters.MIN_ASPECT, params.aspectRatio, 0f)
}
@Test
fun `withAspectRatio clamps above maximum`() {
val params = BlurParameters.DEFAULT.withAspectRatio(10f)
assertEquals(BlurParameters.MAX_ASPECT, params.aspectRatio, 0f)
}
// --- withPosition ---
@Test
fun `withPosition clamps to 0-1 range`() {
val params = BlurParameters.DEFAULT.withPosition(-0.5f, 1.5f)
assertEquals(0f, params.positionX, 0f)
assertEquals(1f, params.positionY, 0f)
}
@Test
fun `withPosition accepts values in range`() {
val params = BlurParameters.DEFAULT.withPosition(0.3f, 0.7f)
assertEquals(0.3f, params.positionX, 0f)
assertEquals(0.7f, params.positionY, 0f)
}
// --- withAngle ---
@Test
fun `withAngle sets arbitrary angle`() {
val angle = PI.toFloat() / 4
val params = BlurParameters.DEFAULT.withAngle(angle)
assertEquals(angle, params.angle, 0f)
}
// --- copy preserves other fields ---
@Test
fun `with methods preserve other fields`() {
val custom = BlurParameters(
mode = BlurMode.RADIAL,
angle = 1.5f,
positionX = 0.2f,
positionY = 0.8f,
size = 0.4f,
blurAmount = 0.6f,
falloff = 0.7f,
aspectRatio = 2.0f
)
val updated = custom.withSize(0.5f)
assertEquals(BlurMode.RADIAL, updated.mode)
assertEquals(1.5f, updated.angle, 0f)
assertEquals(0.2f, updated.positionX, 0f)
assertEquals(0.8f, updated.positionY, 0f)
assertEquals(0.5f, updated.size, 0f)
assertEquals(0.6f, updated.blurAmount, 0f)
assertEquals(0.7f, updated.falloff, 0f)
assertEquals(2.0f, updated.aspectRatio, 0f)
}
// --- data class equality ---
@Test
fun `data class equality works`() {
val a = BlurParameters(mode = BlurMode.LINEAR, size = 0.5f)
val b = BlurParameters(mode = BlurMode.LINEAR, size = 0.5f)
assertEquals(a, b)
}
@Test
fun `different params are not equal`() {
val a = BlurParameters(mode = BlurMode.LINEAR)
val b = BlurParameters(mode = BlurMode.RADIAL)
assertNotEquals(a, b)
}
}

View file

@ -1,5 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules. // Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
} }

View file

@ -1,14 +1,14 @@
[versions] [versions]
agp = "9.1.0" agp = "8.7.3"
kotlin = "2.3.20" kotlin = "2.0.21"
coreKtx = "1.18.0" coreKtx = "1.15.0"
lifecycleRuntimeKtx = "2.10.0" lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.13.0" activityCompose = "1.9.3"
composeBom = "2026.03.00" composeBom = "2024.12.01"
camerax = "1.5.1" camerax = "1.4.1"
exifinterface = "1.4.2" accompanist = "0.36.0"
exifinterface = "1.3.7"
playServicesLocation = "21.3.0" playServicesLocation = "21.3.0"
junit = "4.13.2"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -36,9 +36,10 @@ androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterfa
# Location # Location
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" } play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
# Test # Accompanist for permissions
junit = { group = "junit", name = "junit", version.ref = "junit" } accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } 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" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

View file

@ -1,5 +1,5 @@
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
distributionSha256Sum=0f6ba231b986276d8221d7a870b4d98e0df76e6daf1f42e7c0baec5032fb7d17 distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

View file

@ -1,4 +1,4 @@
versionMajor=1 versionMajor=1
versionMinor=1 versionMinor=1
versionPatch=5 versionPatch=1
versionCode=7 versionCode=3