Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2633f261ef | |||
| 88d04515e2 | |||
| 5b9aedd109 | |||
| f3baa723be | |||
| aab1ff38a4 | |||
| c58c45c52c | |||
| 878c23bf89 |
23 changed files with 1273 additions and 865 deletions
|
|
@ -99,6 +99,5 @@ 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.
|
||||||
- Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API.
|
- Dependencies updated to March 2026 versions (AGP 9.1, Kotlin 2.3, Compose BOM 2026.03).
|
||||||
- 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.
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,7 +24,7 @@ val vCode = versionProperties["versionCode"].toString().toInt()
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "no.naiv.tiltshift"
|
namespace = "no.naiv.tiltshift"
|
||||||
compileSdk = 35
|
compileSdk = 36
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
|
|
@ -42,7 +41,7 @@ android {
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "no.naiv.tiltshift"
|
applicationId = "no.naiv.tiltshift"
|
||||||
minSdk = 35
|
minSdk = 35
|
||||||
targetSdk = 35
|
targetSdk = 36
|
||||||
versionCode = vCode
|
versionCode = vCode
|
||||||
versionName = "$vMajor.$vMinor.$vPatch"
|
versionName = "$vMajor.$vMinor.$vPatch"
|
||||||
|
|
||||||
|
|
@ -70,10 +69,6 @@ android {
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
@ -112,8 +107,8 @@ dependencies {
|
||||||
// Location
|
// Location
|
||||||
implementation(libs.play.services.location)
|
implementation(libs.play.services.location)
|
||||||
|
|
||||||
// Permissions
|
// Test
|
||||||
implementation(libs.accompanist.permissions)
|
testImplementation(libs.junit)
|
||||||
|
|
||||||
// Debug
|
// Debug
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
|
|
||||||
3
app/proguard-rules.pro
vendored
3
app/proguard-rules.pro
vendored
|
|
@ -1,5 +1,2 @@
|
||||||
# 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.** { *; }
|
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ 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
|
||||||
|
|
@ -26,23 +29,24 @@ 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
|
||||||
|
|
||||||
|
|
@ -66,21 +70,47 @@ class MainActivity : ComponentActivity() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalPermissionsApi::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TiltShiftApp() {
|
private fun TiltShiftApp() {
|
||||||
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
|
val context = LocalContext.current
|
||||||
val locationPermissions = rememberMultiplePermissionsState(
|
val activity = context as? ComponentActivity
|
||||||
listOf(
|
|
||||||
Manifest.permission.ACCESS_FINE_LOCATION,
|
var cameraGranted by remember {
|
||||||
Manifest.permission.ACCESS_COARSE_LOCATION
|
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
|
// Request camera permission on launch
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
if (!cameraPermission.status.isGranted) {
|
if (!cameraGranted) {
|
||||||
cameraPermission.launchPermissionRequest()
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,28 +120,47 @@ private fun TiltShiftApp() {
|
||||||
.background(Color.Black)
|
.background(Color.Black)
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
cameraPermission.status.isGranted -> {
|
cameraGranted -> {
|
||||||
// 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 (!locationPermissions.allPermissionsGranted) {
|
if (!locationGranted) {
|
||||||
locationPermissions.launchMultiplePermissionRequest()
|
locationPermissionLauncher.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
// Permanently denied: not granted AND rationale not shown
|
// Permanently denied: user has responded to the dialog, but permission
|
||||||
val cameraPermanentlyDenied = !cameraPermission.status.isGranted &&
|
// is still denied and the system won't show the dialog again
|
||||||
!cameraPermission.status.shouldShowRationale
|
val cameraPermanentlyDenied = cameraResultReceived &&
|
||||||
|
activity?.let {
|
||||||
|
!ActivityCompat.shouldShowRequestPermissionRationale(
|
||||||
|
it, Manifest.permission.CAMERA
|
||||||
|
)
|
||||||
|
} ?: false
|
||||||
|
|
||||||
// Show permission request UI
|
// Show permission request UI
|
||||||
PermissionRequestScreen(
|
PermissionRequestScreen(
|
||||||
onRequestCamera = { cameraPermission.launchPermissionRequest() },
|
onRequestCamera = {
|
||||||
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
cameraGranted = cameraPermission.status.isGranted,
|
},
|
||||||
locationGranted = locationPermissions.allPermissionsGranted,
|
onRequestLocation = {
|
||||||
|
locationPermissionLauncher.launch(
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
cameraGranted = false,
|
||||||
|
locationGranted = locationGranted,
|
||||||
cameraPermanentlyDenied = cameraPermanentlyDenied
|
cameraPermanentlyDenied = cameraPermanentlyDenied
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
fun setZoom(ratio: Float) {
|
||||||
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
||||||
val future = camera?.cameraControl?.setZoomRatio(clamped)
|
_zoomRatio.value = clamped
|
||||||
if (future != null) {
|
camera?.cameraControl?.setZoomRatio(clamped)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ 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
|
||||||
|
|
@ -386,7 +387,7 @@ class ImageCaptureHandler(
|
||||||
|
|
||||||
scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
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.recycle()
|
||||||
scaled = null
|
scaled = null
|
||||||
|
|
||||||
|
|
@ -530,230 +531,4 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ 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
|
||||||
|
|
@ -12,10 +13,16 @@ 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.
|
||||||
*
|
*
|
||||||
* This renderer receives camera frames via SurfaceTexture and applies
|
* Rendering pipeline (3 draw calls per frame):
|
||||||
* the tilt-shift blur effect using GLSL shaders.
|
* 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(
|
class TiltShiftRenderer(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
|
|
@ -23,16 +30,30 @@ 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
|
||||||
|
|
||||||
private lateinit var vertexBuffer: FloatBuffer
|
// Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only)
|
||||||
private lateinit var texCoordBuffer: FloatBuffer
|
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 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
|
||||||
|
|
@ -69,27 +90,33 @@ 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()
|
||||||
|
|
||||||
// Allocate vertex buffer (8 floats = 4 vertices × 2 components)
|
// Camera quad vertex buffer (crop-to-fill, recomputed when resolution is known)
|
||||||
vertexBuffer = ByteBuffer.allocateDirect(8 * 4)
|
cameraVertexBuffer = allocateFloatBuffer(8)
|
||||||
.order(ByteOrder.nativeOrder())
|
cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
|
||||||
.asFloatBuffer()
|
cameraVertexBuffer.position(0)
|
||||||
// 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)
|
|
||||||
|
|
||||||
// Create texture coordinate buffer
|
// Camera texcoord buffer (rotated for portrait)
|
||||||
texCoordBuffer = ByteBuffer.allocateDirect(currentTexCoords.size * 4)
|
cameraTexCoordBuffer = allocateFloatBuffer(8)
|
||||||
.order(ByteOrder.nativeOrder())
|
cameraTexCoordBuffer.put(currentTexCoords)
|
||||||
.asFloatBuffer()
|
cameraTexCoordBuffer.position(0)
|
||||||
.put(currentTexCoords)
|
|
||||||
texCoordBuffer.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
|
// Create camera texture
|
||||||
val textures = IntArray(1)
|
val textures = IntArray(1)
|
||||||
|
|
@ -114,88 +141,75 @@ 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) {
|
||||||
texCoordBuffer.clear()
|
cameraTexCoordBuffer.clear()
|
||||||
texCoordBuffer.put(currentTexCoords)
|
cameraTexCoordBuffer.put(currentTexCoords)
|
||||||
texCoordBuffer.position(0)
|
cameraTexCoordBuffer.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)
|
||||||
// Use shader and set parameters
|
drawQuad(
|
||||||
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight, isFrontCamera)
|
shader.passthroughPositionLoc, shader.passthroughTexCoordLoc,
|
||||||
|
cameraVertexBuffer, cameraTexCoordBuffer
|
||||||
// Set vertex positions
|
|
||||||
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
|
|
||||||
GLES20.glVertexAttribPointer(
|
|
||||||
shader.aPositionLocation,
|
|
||||||
2,
|
|
||||||
GLES20.GL_FLOAT,
|
|
||||||
false,
|
|
||||||
0,
|
|
||||||
vertexBuffer
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set texture coordinates
|
// --- Pass 2: FBO-A → FBO-B (horizontal blur) ---
|
||||||
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
|
GLES20.glFramebufferTexture2D(
|
||||||
GLES20.glVertexAttribPointer(
|
GLES20.GL_FRAMEBUFFER, GLES20.GL_COLOR_ATTACHMENT0,
|
||||||
shader.aTexCoordLocation,
|
GLES20.GL_TEXTURE_2D, fboTexB, 0
|
||||||
2,
|
)
|
||||||
GLES20.GL_FLOAT,
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||||
false,
|
shader.useBlurPass(fboTexA, params, surfaceWidth, surfaceHeight, 1f, 0f)
|
||||||
0,
|
drawQuad(
|
||||||
texCoordBuffer
|
shader.blurPositionLoc, shader.blurTexCoordLoc,
|
||||||
|
fullscreenVertexBuffer, fullscreenTexCoordBuffer
|
||||||
)
|
)
|
||||||
|
|
||||||
// Draw quad
|
// --- Pass 3: FBO-B → screen (vertical blur) ---
|
||||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
GLES20.glBindFramebuffer(GLES20.GL_FRAMEBUFFER, 0)
|
||||||
|
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
|
||||||
// Cleanup
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||||
GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
|
shader.useBlurPass(fboTexB, params, surfaceWidth, surfaceHeight, 0f, 1f)
|
||||||
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
|
drawQuad(
|
||||||
|
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
|
||||||
|
|
@ -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() {
|
fun release() {
|
||||||
shader.release()
|
shader.release()
|
||||||
surfaceTexture?.release()
|
surfaceTexture?.release()
|
||||||
|
|
@ -252,5 +227,117 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,57 +4,167 @@ 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 kotlin.math.cos
|
|
||||||
import kotlin.math.sin
|
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.InputStreamReader
|
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) {
|
class TiltShiftShader(private val context: Context) {
|
||||||
|
|
||||||
var programId: Int = 0
|
// --- Passthrough program (camera → FBO) ---
|
||||||
private set
|
|
||||||
|
|
||||||
// Attribute locations
|
private var passthroughProgramId: Int = 0
|
||||||
var aPositionLocation: Int = 0
|
|
||||||
private set
|
|
||||||
var aTexCoordLocation: Int = 0
|
|
||||||
private set
|
|
||||||
|
|
||||||
// Uniform locations
|
var passthroughPositionLoc: Int = 0
|
||||||
private var uTextureLocation: Int = 0
|
private set
|
||||||
private var uModeLocation: Int = 0
|
var passthroughTexCoordLoc: Int = 0
|
||||||
private var uIsFrontCameraLocation: Int = 0
|
private set
|
||||||
private var uAngleLocation: Int = 0
|
private var passthroughTextureLoc: Int = 0
|
||||||
private var uPositionXLocation: Int = 0
|
|
||||||
private var uPositionYLocation: Int = 0
|
// --- Blur program (FBO → FBO/screen) ---
|
||||||
private var uSizeLocation: Int = 0
|
|
||||||
private var uBlurAmountLocation: Int = 0
|
private var blurProgramId: Int = 0
|
||||||
private var uFalloffLocation: Int = 0
|
|
||||||
private var uAspectRatioLocation: Int = 0
|
var blurPositionLoc: Int = 0
|
||||||
private var uResolutionLocation: Int = 0
|
private set
|
||||||
private var uCosAngleLocation: Int = 0
|
var blurTexCoordLoc: Int = 0
|
||||||
private var uSinAngleLocation: 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.
|
* 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)
|
|
||||||
|
|
||||||
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, 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) {
|
||||||
|
|
@ -63,72 +173,7 @@ class TiltShiftShader(private val context: Context) {
|
||||||
throw RuntimeException("Shader program link failed: $error")
|
throw RuntimeException("Shader program link failed: $error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get attribute locations
|
return programId
|
||||||
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 {
|
||||||
|
|
@ -142,7 +187,6 @@ 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) {
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
16
app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt
Normal file
16
app/src/main/java/no/naiv/tiltshift/storage/SaveResult.kt
Normal file
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -1,19 +1,16 @@
|
||||||
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.animation.scaleIn
|
import androidx.compose.foundation.Image
|
||||||
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
|
||||||
|
|
@ -29,24 +26,21 @@ 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
|
||||||
|
|
@ -65,10 +59,9 @@ 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
|
||||||
|
|
@ -79,7 +72,6 @@ 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
|
||||||
|
|
@ -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<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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
107
app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt
Normal file
107
app/src/main/java/no/naiv/tiltshift/ui/CaptureControls.kt
Normal file
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt
Normal file
218
app/src/main/java/no/naiv/tiltshift/ui/ControlPanel.kt
Normal file
|
|
@ -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<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)}" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 effect -> size adjustment (large area)
|
// Near the blur boundary -> size adjustment
|
||||||
distFromCenter < focusSize * 2.0f -> GestureType.PINCH_SIZE
|
distFromCenter < focusSize * 1.3f -> GestureType.PINCH_SIZE
|
||||||
// Far outside -> camera zoom
|
// Outside the effect -> camera zoom
|
||||||
else -> GestureType.PINCH_ZOOM
|
else -> GestureType.PINCH_ZOOM
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
248
app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt
Normal file
248
app/src/main/java/no/naiv/tiltshift/util/StackBlur.kt
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,71 +1,58 @@
|
||||||
#extension GL_OES_EGL_image_external : require
|
// Fragment shader for tilt-shift blur pass (two-pass separable Gaussian)
|
||||||
|
// Reads from a sampler2D (FBO texture already in screen orientation).
|
||||||
// Fragment shader for tilt-shift effect
|
// Used twice: once for horizontal blur, once for vertical blur.
|
||||||
// Supports both linear and radial blur modes
|
|
||||||
|
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
|
|
||||||
// Camera texture (external texture for camera preview)
|
uniform sampler2D uTexture;
|
||||||
uniform samplerExternalOES uTexture;
|
|
||||||
|
|
||||||
// Effect parameters
|
// Effect parameters
|
||||||
uniform int uMode; // 0 = linear, 1 = radial
|
uniform int uMode; // 0 = linear, 1 = radial
|
||||||
uniform int uIsFrontCamera; // 0 = back camera, 1 = front camera
|
uniform float uPositionX; // Horizontal center of focus (0-1, screen space)
|
||||||
uniform float uAngle; // Rotation angle in radians
|
uniform float uPositionY; // Vertical center of focus (0-1, screen space, 0 = top)
|
||||||
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; // 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 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 signed distance from the focus region for LINEAR mode
|
// Calculate distance from the focus region for LINEAR mode
|
||||||
float linearFocusDistance(vec2 uv) {
|
// Works in screen space: X right (0-1), Y down (0-1)
|
||||||
// Center point of the focus region
|
// Distances are normalized to the Y axis (height) to match the overlay,
|
||||||
// Transform from screen coordinates to texture coordinates
|
// which defines focus size as a fraction of screen height.
|
||||||
// Back camera: Screen (x,y) -> Texture (y, 1-x)
|
float linearFocusDistance(vec2 screenPos) {
|
||||||
// Front camera: Screen (x,y) -> Texture (1-y, 1-x) (additional X flip for mirror)
|
vec2 center = vec2(uPositionX, uPositionY);
|
||||||
vec2 center;
|
vec2 offset = screenPos - center;
|
||||||
if (uIsFrontCamera == 1) {
|
|
||||||
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
|
|
||||||
} else {
|
|
||||||
center = vec2(uPositionY, 1.0 - uPositionX);
|
|
||||||
}
|
|
||||||
vec2 offset = uv - 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;
|
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;
|
float rotatedY = -offset.x * uSinAngle + offset.y * uCosAngle;
|
||||||
|
|
||||||
return abs(rotatedY);
|
return abs(rotatedY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate signed distance from the focus region for RADIAL mode
|
// Calculate distance from the focus region for RADIAL mode
|
||||||
float radialFocusDistance(vec2 uv) {
|
float radialFocusDistance(vec2 screenPos) {
|
||||||
// Center point of the focus region
|
vec2 center = vec2(uPositionX, uPositionY);
|
||||||
vec2 center;
|
vec2 offset = screenPos - center;
|
||||||
if (uIsFrontCamera == 1) {
|
|
||||||
center = vec2(1.0 - uPositionY, 1.0 - uPositionX);
|
|
||||||
} else {
|
|
||||||
center = vec2(uPositionY, 1.0 - uPositionX);
|
|
||||||
}
|
|
||||||
vec2 offset = uv - center;
|
|
||||||
|
|
||||||
// Correct for screen aspect ratio
|
// Scale X into the same physical units as Y (height-normalized)
|
||||||
float screenAspect = uResolution.x / uResolution.y;
|
float screenAspect = uResolution.x / uResolution.y;
|
||||||
offset.y *= screenAspect;
|
offset.x *= screenAspect;
|
||||||
|
|
||||||
// Use precomputed cos/sin for rotation
|
// Rotate offset
|
||||||
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
|
||||||
|
|
@ -74,83 +61,59 @@ float radialFocusDistance(vec2 uv) {
|
||||||
// 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;
|
||||||
// Falloff range scales with the falloff parameter
|
float transitionSize = halfSize * uFalloff * 3.0;
|
||||||
float transitionSize = halfSize * uFalloff;
|
|
||||||
|
|
||||||
if (dist < halfSize) {
|
if (dist < halfSize) {
|
||||||
return 0.0; // In focus region
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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() {
|
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;
|
float dist;
|
||||||
if (uMode == 1) {
|
if (uMode == 1) {
|
||||||
dist = radialFocusDistance(vTexCoord);
|
dist = radialFocusDistance(screenPos);
|
||||||
} else {
|
} else {
|
||||||
dist = linearFocusDistance(vTexCoord);
|
dist = linearFocusDistance(screenPos);
|
||||||
}
|
}
|
||||||
float blur = blurFactor(dist);
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
app/src/main/res/raw/tiltshift_passthrough_fragment.glsl
Normal file
15
app/src/main/res/raw/tiltshift_passthrough_fragment.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
}
|
||||||
151
app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt
Normal file
151
app/src/test/java/no/naiv/tiltshift/effect/BlurParametersTest.kt
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
// 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
[versions]
|
[versions]
|
||||||
agp = "8.7.3"
|
agp = "9.1.0"
|
||||||
kotlin = "2.0.21"
|
kotlin = "2.3.20"
|
||||||
coreKtx = "1.15.0"
|
coreKtx = "1.18.0"
|
||||||
lifecycleRuntimeKtx = "2.8.7"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.9.3"
|
activityCompose = "1.13.0"
|
||||||
composeBom = "2024.12.01"
|
composeBom = "2026.03.00"
|
||||||
camerax = "1.4.1"
|
camerax = "1.5.1"
|
||||||
accompanist = "0.36.0"
|
exifinterface = "1.4.2"
|
||||||
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,10 +36,9 @@ 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" }
|
||||||
|
|
||||||
# Accompanist for permissions
|
# Test
|
||||||
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
|
||||||
[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" }
|
||||||
|
|
|
||||||
4
gradle/wrapper/gradle-wrapper.properties
vendored
4
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
|
||||||
distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
|
distributionSha256Sum=0f6ba231b986276d8221d7a870b4d98e0df76e6daf1f42e7c0baec5032fb7d17
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
versionMajor=1
|
versionMajor=1
|
||||||
versionMinor=1
|
versionMinor=1
|
||||||
versionPatch=1
|
versionPatch=5
|
||||||
versionCode=3
|
versionCode=7
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue