Initial implementation of Tilt-Shift Camera Android app
A dedicated camera app for tilt-shift photography with: - Real-time OpenGL ES 2.0 shader-based blur preview - Touch gesture controls (drag, rotate, pinch) for adjusting effect - CameraX integration for camera preview and high-res capture - EXIF metadata with GPS location support - MediaStore integration for saving to gallery - Jetpack Compose UI with haptic feedback Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
07e10ac9c3
38 changed files with 3489 additions and 0 deletions
173
app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Normal file
173
app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package no.naiv.tiltshift.camera
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.SurfaceTexture
|
||||
import android.util.Size
|
||||
import android.view.Surface
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.SurfaceRequest
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Manages CameraX camera setup and controls.
|
||||
*/
|
||||
class CameraManager(private val context: Context) {
|
||||
|
||||
private var cameraProvider: ProcessCameraProvider? = null
|
||||
private var camera: Camera? = null
|
||||
private var preview: Preview? = null
|
||||
var imageCapture: ImageCapture? = null
|
||||
private set
|
||||
|
||||
val lensController = LensController()
|
||||
|
||||
private val _zoomRatio = MutableStateFlow(1.0f)
|
||||
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
|
||||
|
||||
private val _minZoomRatio = MutableStateFlow(1.0f)
|
||||
val minZoomRatio: StateFlow<Float> = _minZoomRatio.asStateFlow()
|
||||
|
||||
private val _maxZoomRatio = MutableStateFlow(1.0f)
|
||||
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
|
||||
|
||||
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
||||
private var surfaceSize: Size = Size(1920, 1080)
|
||||
|
||||
/**
|
||||
* Starts the camera with the given lifecycle owner.
|
||||
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
|
||||
*/
|
||||
fun startCamera(
|
||||
lifecycleOwner: LifecycleOwner,
|
||||
surfaceTextureProvider: () -> SurfaceTexture?
|
||||
) {
|
||||
this.surfaceTextureProvider = surfaceTextureProvider
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener({
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
|
||||
bindCameraUseCases(lifecycleOwner)
|
||||
}, ContextCompat.getMainExecutor(context))
|
||||
}
|
||||
|
||||
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
|
||||
val provider = cameraProvider ?: return
|
||||
|
||||
// Unbind all use cases before rebinding
|
||||
provider.unbindAll()
|
||||
|
||||
// Preview use case with resolution selector
|
||||
val resolutionSelector = ResolutionSelector.Builder()
|
||||
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
|
||||
.build()
|
||||
|
||||
preview = Preview.Builder()
|
||||
.setResolutionSelector(resolutionSelector)
|
||||
.build()
|
||||
|
||||
// Image capture use case for high-res photos
|
||||
imageCapture = ImageCapture.Builder()
|
||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||
.build()
|
||||
|
||||
// Get camera selector from lens controller
|
||||
val cameraSelector = lensController.getCurrentLens()?.selector
|
||||
?: CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
try {
|
||||
// Bind use cases to camera
|
||||
camera = provider.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
cameraSelector,
|
||||
preview,
|
||||
imageCapture
|
||||
)
|
||||
|
||||
// Update zoom info
|
||||
camera?.cameraInfo?.let { info ->
|
||||
_minZoomRatio.value = info.zoomState.value?.minZoomRatio ?: 1.0f
|
||||
_maxZoomRatio.value = info.zoomState.value?.maxZoomRatio ?: 1.0f
|
||||
_zoomRatio.value = info.zoomState.value?.zoomRatio ?: 1.0f
|
||||
}
|
||||
|
||||
// Set up surface provider for preview
|
||||
preview?.setSurfaceProvider { request ->
|
||||
provideSurface(request)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
// Camera binding failed
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
private fun provideSurface(request: SurfaceRequest) {
|
||||
val surfaceTexture = surfaceTextureProvider?.invoke()
|
||||
if (surfaceTexture == null) {
|
||||
request.willNotProvideSurface()
|
||||
return
|
||||
}
|
||||
|
||||
surfaceSize = request.resolution
|
||||
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
|
||||
|
||||
val surface = Surface(surfaceTexture)
|
||||
request.provideSurface(surface, ContextCompat.getMainExecutor(context)) { result ->
|
||||
surface.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the zoom ratio.
|
||||
*/
|
||||
fun setZoom(ratio: Float) {
|
||||
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
||||
camera?.cameraControl?.setZoomRatio(clamped)
|
||||
_zoomRatio.value = clamped
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets zoom by linear percentage (0.0 to 1.0).
|
||||
*/
|
||||
fun setZoomLinear(percentage: Float) {
|
||||
camera?.cameraControl?.setLinearZoom(percentage.coerceIn(0f, 1f))
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a different lens.
|
||||
*/
|
||||
fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
|
||||
if (lensController.selectLens(lensId)) {
|
||||
bindCameraUseCases(lifecycleOwner)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the executor for image capture callbacks.
|
||||
*/
|
||||
fun getExecutor(): Executor {
|
||||
return ContextCompat.getMainExecutor(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases camera resources.
|
||||
*/
|
||||
fun release() {
|
||||
cameraProvider?.unbindAll()
|
||||
camera = null
|
||||
preview = null
|
||||
imageCapture = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
package no.naiv.tiltshift.camera
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.location.Location
|
||||
import android.os.Build
|
||||
import android.view.Surface
|
||||
import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.core.ImageProxy
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import no.naiv.tiltshift.effect.BlurParameters
|
||||
import no.naiv.tiltshift.storage.PhotoSaver
|
||||
import no.naiv.tiltshift.storage.SaveResult
|
||||
import no.naiv.tiltshift.util.OrientationDetector
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executor
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* Handles capturing photos with the tilt-shift effect applied.
|
||||
*/
|
||||
class ImageCaptureHandler(
|
||||
private val context: Context,
|
||||
private val photoSaver: PhotoSaver
|
||||
) {
|
||||
|
||||
/**
|
||||
* Captures a photo and applies the tilt-shift effect.
|
||||
*/
|
||||
suspend fun capturePhoto(
|
||||
imageCapture: ImageCapture,
|
||||
executor: Executor,
|
||||
blurParams: BlurParameters,
|
||||
deviceRotation: Int,
|
||||
location: Location?,
|
||||
isFrontCamera: Boolean
|
||||
): SaveResult = suspendCancellableCoroutine { continuation ->
|
||||
|
||||
imageCapture.takePicture(
|
||||
executor,
|
||||
object : ImageCapture.OnImageCapturedCallback() {
|
||||
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
||||
try {
|
||||
// Convert ImageProxy to Bitmap
|
||||
val bitmap = imageProxyToBitmap(imageProxy)
|
||||
imageProxy.close()
|
||||
|
||||
if (bitmap == null) {
|
||||
continuation.resume(SaveResult.Error("Failed to convert image"))
|
||||
return
|
||||
}
|
||||
|
||||
// Apply tilt-shift effect to captured image
|
||||
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
|
||||
bitmap.recycle()
|
||||
|
||||
// Determine EXIF orientation
|
||||
val rotationDegrees = OrientationDetector.rotationToDegrees(deviceRotation)
|
||||
val exifOrientation = OrientationDetector.degreesToExifOrientation(
|
||||
rotationDegrees, isFrontCamera
|
||||
)
|
||||
|
||||
// Save with EXIF data
|
||||
kotlinx.coroutines.runBlocking {
|
||||
val result = photoSaver.saveBitmap(
|
||||
processedBitmap,
|
||||
exifOrientation,
|
||||
location
|
||||
)
|
||||
processedBitmap.recycle()
|
||||
continuation.resume(result)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
continuation.resume(SaveResult.Error("Capture failed: ${e.message}", e))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(exception: ImageCaptureException) {
|
||||
continuation.resume(
|
||||
SaveResult.Error("Capture failed: ${exception.message}", exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap? {
|
||||
val buffer = imageProxy.planes[0].buffer
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies tilt-shift blur effect to a bitmap.
|
||||
* This is a software fallback - on newer devices we could use RenderScript/RenderEffect.
|
||||
*/
|
||||
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
|
||||
val width = source.width
|
||||
val height = source.height
|
||||
|
||||
// Create output bitmap
|
||||
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(result)
|
||||
|
||||
// For performance, we use a scaled-down version for blur and composite
|
||||
val scaleFactor = 4 // Blur a 1/4 size image for speed
|
||||
val blurredWidth = width / scaleFactor
|
||||
val blurredHeight = height / scaleFactor
|
||||
|
||||
// Create scaled bitmap for blur
|
||||
val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
||||
|
||||
// Apply stack blur (fast approximation)
|
||||
val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
||||
scaled.recycle()
|
||||
|
||||
// Scale blurred back up
|
||||
val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
|
||||
blurred.recycle()
|
||||
|
||||
// Create gradient mask based on tilt-shift parameters
|
||||
val mask = createGradientMask(width, height, params)
|
||||
|
||||
// Composite: blend original with blurred based on mask
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
val pixels = IntArray(width * height)
|
||||
val blurredPixels = IntArray(width * height)
|
||||
val maskPixels = IntArray(width * height)
|
||||
|
||||
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
|
||||
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
|
||||
|
||||
blurredFullSize.recycle()
|
||||
mask.recycle()
|
||||
|
||||
for (i in pixels.indices) {
|
||||
val maskAlpha = (maskPixels[i] and 0xFF) / 255f
|
||||
val origR = (pixels[i] shr 16) and 0xFF
|
||||
val origG = (pixels[i] shr 8) and 0xFF
|
||||
val origB = pixels[i] and 0xFF
|
||||
val blurR = (blurredPixels[i] shr 16) and 0xFF
|
||||
val blurG = (blurredPixels[i] shr 8) and 0xFF
|
||||
val blurB = blurredPixels[i] and 0xFF
|
||||
|
||||
val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
|
||||
val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
|
||||
val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
|
||||
|
||||
pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
result.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a gradient mask for the tilt-shift effect.
|
||||
*/
|
||||
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
|
||||
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val pixels = IntArray(width * height)
|
||||
|
||||
val centerY = height * params.position
|
||||
val focusHalfHeight = height * params.size * 0.5f
|
||||
val transitionHeight = focusHalfHeight * 0.5f
|
||||
|
||||
val cosAngle = cos(params.angle)
|
||||
val sinAngle = sin(params.angle)
|
||||
|
||||
for (y in 0 until height) {
|
||||
for (x in 0 until width) {
|
||||
// Rotate point around center
|
||||
val dx = x - width / 2f
|
||||
val dy = y - centerY
|
||||
val rotatedY = -dx * sinAngle + dy * cosAngle
|
||||
|
||||
// Calculate blur amount based on distance from focus line
|
||||
val dist = kotlin.math.abs(rotatedY)
|
||||
val blurAmount = when {
|
||||
dist < focusHalfHeight -> 0f
|
||||
dist < focusHalfHeight + transitionHeight -> {
|
||||
(dist - focusHalfHeight) / transitionHeight
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
val gray = (blurAmount * 255).toInt().coerceIn(0, 255)
|
||||
pixels[y * width + x] = (0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
|
||||
}
|
||||
}
|
||||
|
||||
mask.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||
return mask
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
98
app/src/main/java/no/naiv/tiltshift/camera/LensController.kt
Normal file
98
app/src/main/java/no/naiv/tiltshift/camera/LensController.kt
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package no.naiv.tiltshift.camera
|
||||
|
||||
import androidx.camera.core.CameraInfo
|
||||
import androidx.camera.core.CameraSelector
|
||||
|
||||
/**
|
||||
* Represents available camera lenses on the device.
|
||||
*/
|
||||
data class CameraLens(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val zoomFactor: Float,
|
||||
val selector: CameraSelector
|
||||
)
|
||||
|
||||
/**
|
||||
* Controls lens selection and provides information about available lenses.
|
||||
*/
|
||||
class LensController {
|
||||
|
||||
private val availableLenses = mutableListOf<CameraLens>()
|
||||
private var currentLensIndex = 0
|
||||
|
||||
/**
|
||||
* Initializes available lenses based on device capabilities.
|
||||
* Should be called after CameraProvider is available.
|
||||
*/
|
||||
fun initialize(cameraInfos: List<CameraInfo>) {
|
||||
availableLenses.clear()
|
||||
|
||||
// Check for back cameras (main, ultrawide, telephoto)
|
||||
val hasBackCamera = cameraInfos.any {
|
||||
it.lensFacing == CameraSelector.LENS_FACING_BACK
|
||||
}
|
||||
|
||||
if (hasBackCamera) {
|
||||
// Standard back camera
|
||||
availableLenses.add(
|
||||
CameraLens(
|
||||
id = "back_main",
|
||||
displayName = "1x",
|
||||
zoomFactor = 1.0f,
|
||||
selector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Set default to main back camera
|
||||
currentLensIndex = availableLenses.indexOfFirst { it.id == "back_main" }
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all available lenses.
|
||||
*/
|
||||
fun getAvailableLenses(): List<CameraLens> = availableLenses.toList()
|
||||
|
||||
/**
|
||||
* Returns the currently selected lens.
|
||||
*/
|
||||
fun getCurrentLens(): CameraLens? {
|
||||
return if (availableLenses.isNotEmpty()) {
|
||||
availableLenses[currentLensIndex]
|
||||
} else null
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a specific lens by ID.
|
||||
* Returns true if the lens was found and selected.
|
||||
*/
|
||||
fun selectLens(lensId: String): Boolean {
|
||||
val index = availableLenses.indexOfFirst { it.id == lensId }
|
||||
if (index >= 0) {
|
||||
currentLensIndex = index
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cycles to the next available lens.
|
||||
* Returns the newly selected lens, or null if no lenses available.
|
||||
*/
|
||||
fun cycleToNextLens(): CameraLens? {
|
||||
if (availableLenses.isEmpty()) return null
|
||||
|
||||
currentLensIndex = (currentLensIndex + 1) % availableLenses.size
|
||||
return availableLenses[currentLensIndex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Common zoom levels that can be achieved through digital zoom.
|
||||
* These are presented as quick-select buttons.
|
||||
*/
|
||||
fun getZoomPresets(): List<Float> {
|
||||
return listOf(0.5f, 1.0f, 2.0f, 5.0f)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue