Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a82629461 | |||
| b057613bac | |||
| e4892c4b12 | |||
| 5b553c7196 | |||
| a2dfa7db3d | |||
| b0691adfa3 | |||
| dd4471c7d2 | |||
| 1cd2b0a57c | |||
| c0bab85d63 | |||
| 4f8661f648 |
10 changed files with 82 additions and 173 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -86,6 +86,21 @@ Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after
|
||||||
- Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions
|
- Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions
|
||||||
- `writeExifToUri()` returns boolean and logs at ERROR level on failure
|
- `writeExifToUri()` returns boolean and logs at ERROR level on failure
|
||||||
|
|
||||||
|
### Orientation policy (do not change without asking)
|
||||||
|
|
||||||
|
- Activity is `screenOrientation="portrait"` in the manifest. The GL surface and Compose layout therefore never rotate.
|
||||||
|
- The camera passthrough uses a fixed 90° texcoord rotation (`texCoordsBack`/`texCoordsFront`). There is no `setTargetRotation`/`setDisplayRotation`/`getTransformMatrix`-based rotation tracking — past attempts (v1.1.6 through v1.1.13) all introduced subtle bugs and were reverted.
|
||||||
|
- The camera image lives in the device's portrait frame and visually follows the phone as it tilts. Per-orientation correctness comes from the EXIF orientation tag written at capture (`OrientationDetector.degreesToExifOrientation(rotationToDegrees(deviceRotation), isFrontCamera)`), not from rotating the bitmap.
|
||||||
|
- `OrientationEventListener` (`viewModel.currentRotation`) is for EXIF only. It is **not** the same as `Display.rotation`: it fires at the 45° tilt threshold while the activity rotates later, so it must not drive anything that has to stay in sync with the GL surface.
|
||||||
|
- `SurfaceTexture.getTransformMatrix()` with a custom `SurfaceProvider` does not change on `Preview.targetRotation` updates; rebinding doesn't reliably help either. Don't go down that road again.
|
||||||
|
|
||||||
|
### Local Android testing
|
||||||
|
|
||||||
|
- AVD: `tilfluktsrom` (Pixel 6, API 35, Google APIs) at `~/.android/avd/`. Boot with `emulator -avd tilfluktsrom -no-snapshot-save -gpu swiftshader_indirect -no-audio`.
|
||||||
|
- `adb -s emulator-5554 emu rotate` cycles `ROTATION_0 → 3 → 2 → 1 → 0` (decrements by 90°), not the other way.
|
||||||
|
- The default virtual scene is too rotationally symmetric to verify which way is "up". For orientation testing, pass `-virtualscene-poster wall=/path/to/marker.png` and ensure the scene camera faces the wall, or just don't trust emulator screenshots as proof of correct orientation.
|
||||||
|
- Camera bitmaps captured at startup tend to look black for a few seconds while CameraX rebinds. Wait ≥10 s after `am start` before screenshotting.
|
||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
| Permission | Purpose | Notes |
|
| Permission | Purpose | Notes |
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="fullSensor"
|
android:screenOrientation="portrait"
|
||||||
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
|
||||||
android:windowSoftInputMode="adjustNothing"
|
android:windowSoftInputMode="adjustNothing"
|
||||||
android:theme="@style/Theme.TiltShiftCamera">
|
android:theme="@style/Theme.TiltShiftCamera">
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,8 @@ package no.naiv.tiltshift.camera
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.SurfaceTexture
|
import android.graphics.SurfaceTexture
|
||||||
import android.hardware.display.DisplayManager
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.util.Size
|
import android.util.Size
|
||||||
import android.view.Display
|
|
||||||
import android.view.Surface
|
import android.view.Surface
|
||||||
import androidx.camera.core.Camera
|
import androidx.camera.core.Camera
|
||||||
import androidx.camera.core.CameraSelector
|
import androidx.camera.core.CameraSelector
|
||||||
|
|
@ -72,14 +70,6 @@ class CameraManager(private val context: Context) {
|
||||||
/** Weak reference to avoid preventing Activity GC across config changes. */
|
/** Weak reference to avoid preventing Activity GC across config changes. */
|
||||||
private var lifecycleOwnerRef: WeakReference<LifecycleOwner>? = null
|
private var lifecycleOwnerRef: WeakReference<LifecycleOwner>? = null
|
||||||
|
|
||||||
/**
|
|
||||||
* Target rotation passed to CameraX use cases. Drives the SurfaceTexture
|
|
||||||
* transform matrix and the rotation metadata on captured images.
|
|
||||||
* Initialized to the display rotation when the camera binds; updated by
|
|
||||||
* [setTargetRotation] when the device orientation changes.
|
|
||||||
*/
|
|
||||||
private var targetRotation: Int = Surface.ROTATION_0
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the camera with the given lifecycle owner.
|
* Starts the camera with the given lifecycle owner.
|
||||||
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
|
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
|
||||||
|
|
@ -90,13 +80,6 @@ class CameraManager(private val context: Context) {
|
||||||
) {
|
) {
|
||||||
this.surfaceTextureProvider = surfaceTextureProvider
|
this.surfaceTextureProvider = surfaceTextureProvider
|
||||||
this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
|
this.lifecycleOwnerRef = WeakReference(lifecycleOwner)
|
||||||
// Capture initial display rotation so the very first frame is oriented correctly,
|
|
||||||
// before the OrientationEventListener has had a chance to fire.
|
|
||||||
// Note: Context.getDisplay() throws on Application contexts; DisplayManager works
|
|
||||||
// for any context type and returns the default display.
|
|
||||||
targetRotation = (context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager)
|
|
||||||
?.getDisplay(Display.DEFAULT_DISPLAY)?.rotation
|
|
||||||
?: Surface.ROTATION_0
|
|
||||||
|
|
||||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
cameraProviderFuture.addListener({
|
cameraProviderFuture.addListener({
|
||||||
|
|
@ -127,13 +110,11 @@ class CameraManager(private val context: Context) {
|
||||||
|
|
||||||
preview = Preview.Builder()
|
preview = Preview.Builder()
|
||||||
.setResolutionSelector(resolutionSelector)
|
.setResolutionSelector(resolutionSelector)
|
||||||
.setTargetRotation(targetRotation)
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// Image capture use case
|
// Image capture use case
|
||||||
val captureBuilder = ImageCapture.Builder()
|
val captureBuilder = ImageCapture.Builder()
|
||||||
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||||
.setTargetRotation(targetRotation)
|
|
||||||
|
|
||||||
imageCapture = captureBuilder.build()
|
imageCapture = captureBuilder.build()
|
||||||
|
|
||||||
|
|
@ -192,23 +173,6 @@ class CameraManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the target rotation for Preview and ImageCapture use cases.
|
|
||||||
*
|
|
||||||
* Rebinds the use cases so CameraX issues a fresh SurfaceRequest with a
|
|
||||||
* resolution matching the new rotation and a corresponding texture transform
|
|
||||||
* matrix. Calling `preview.targetRotation = rotation` alone is insufficient
|
|
||||||
* for a custom SurfaceProvider — the new rotation only takes effect on
|
|
||||||
* subsequently bound streams, leaving the live SurfaceTexture matrix and
|
|
||||||
* buffer size stale (which made the preview appear locked to the original
|
|
||||||
* portrait orientation when the device was rotated to landscape).
|
|
||||||
*/
|
|
||||||
fun setTargetRotation(rotation: Int) {
|
|
||||||
if (targetRotation == rotation) return
|
|
||||||
targetRotation = rotation
|
|
||||||
lifecycleOwnerRef?.get()?.let { bindCameraUseCases(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the zoom ratio. Updates UI state immediately so that rapid pinch-to-zoom
|
* 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).
|
* gestures accumulate correctly (each frame uses the latest ratio as its base).
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import kotlin.coroutines.resume
|
||||||
import kotlin.math.cos
|
import kotlin.math.cos
|
||||||
import kotlin.math.sin
|
import kotlin.math.sin
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
import no.naiv.tiltshift.util.OrientationDetector
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles capturing photos with the tilt-shift effect applied.
|
* Handles capturing photos with the tilt-shift effect applied.
|
||||||
|
|
@ -121,10 +122,19 @@ class ImageCaptureHandler(
|
||||||
var thumbnail: Bitmap? = null
|
var thumbnail: Bitmap? = null
|
||||||
try {
|
try {
|
||||||
thumbnail = createThumbnail(captureResult.processed)
|
thumbnail = createThumbnail(captureResult.processed)
|
||||||
|
// Camera bitmap is in the device's portrait frame (CameraX
|
||||||
|
// rotated the sensor 90° because the activity is locked to
|
||||||
|
// portrait). Tag the EXIF with the user's physical tilt
|
||||||
|
// when they pressed the shutter so viewers display the
|
||||||
|
// photo right-side-up regardless of how the phone was held.
|
||||||
|
val exifOrientation = OrientationDetector.degreesToExifOrientation(
|
||||||
|
OrientationDetector.rotationToDegrees(deviceRotation),
|
||||||
|
isFrontCamera
|
||||||
|
)
|
||||||
val result = photoSaver.saveBitmapPair(
|
val result = photoSaver.saveBitmapPair(
|
||||||
original = captureResult.original,
|
original = captureResult.original,
|
||||||
processed = captureResult.processed,
|
processed = captureResult.processed,
|
||||||
orientation = ExifInterface.ORIENTATION_NORMAL,
|
orientation = exifOrientation,
|
||||||
location = location
|
location = location
|
||||||
)
|
)
|
||||||
if (result is SaveResult.Success) {
|
if (result is SaveResult.Success) {
|
||||||
|
|
|
||||||
|
|
@ -5,9 +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.opengl.Matrix
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Surface
|
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
import java.nio.ByteOrder
|
import java.nio.ByteOrder
|
||||||
import java.nio.FloatBuffer
|
import java.nio.FloatBuffer
|
||||||
|
|
@ -40,8 +38,9 @@ class TiltShiftRenderer(
|
||||||
private var surfaceTexture: SurfaceTexture? = null
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
private var cameraTextureId: Int = 0
|
private var cameraTextureId: Int = 0
|
||||||
|
|
||||||
// Camera quad: crop-to-fill vertices, standard texcoords (rotation comes from texMatrix)
|
// Camera quad: crop-to-fill vertices + rotated texcoords (pass 1 only)
|
||||||
private lateinit var cameraVertexBuffer: FloatBuffer
|
private lateinit var cameraVertexBuffer: FloatBuffer
|
||||||
|
private lateinit var cameraTexCoordBuffer: FloatBuffer
|
||||||
|
|
||||||
// Fullscreen quad for blur passes (no crop, standard texcoords)
|
// Fullscreen quad for blur passes (no crop, standard texcoords)
|
||||||
private lateinit var fullscreenVertexBuffer: FloatBuffer
|
private lateinit var fullscreenVertexBuffer: FloatBuffer
|
||||||
|
|
@ -55,11 +54,6 @@ class TiltShiftRenderer(
|
||||||
private var fboTexA: Int = 0
|
private var fboTexA: Int = 0
|
||||||
private var fboTexB: Int = 0
|
private var fboTexB: Int = 0
|
||||||
|
|
||||||
// SurfaceTexture transform matrix, refreshed each frame on the GL thread.
|
|
||||||
private val texMatrix = FloatArray(16)
|
|
||||||
// Sensor-to-buffer matrix from SurfaceTexture before display-rotation correction.
|
|
||||||
private val sensorMatrix = FloatArray(16)
|
|
||||||
|
|
||||||
// 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
|
||||||
|
|
@ -72,14 +66,33 @@ class TiltShiftRenderer(
|
||||||
private var cameraWidth: Int = 0
|
private var cameraWidth: Int = 0
|
||||||
@Volatile
|
@Volatile
|
||||||
private var cameraHeight: Int = 0
|
private var cameraHeight: Int = 0
|
||||||
|
|
||||||
/** Display rotation as a Surface.ROTATION_* constant; affects effective aspect. */
|
|
||||||
@Volatile
|
|
||||||
private var displayRotation: Int = Surface.ROTATION_0
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var vertexBufferDirty: Boolean = false
|
private var vertexBufferDirty: Boolean = false
|
||||||
|
|
||||||
|
// Texture coordinates rotated 90° for portrait mode (back camera)
|
||||||
|
// (Camera sensors are landscape-oriented, we rotate to portrait)
|
||||||
|
private val texCoordsBack = floatArrayOf(
|
||||||
|
1f, 1f, // Bottom left of screen -> bottom right of texture
|
||||||
|
1f, 0f, // Bottom right of screen -> top right of texture
|
||||||
|
0f, 1f, // Top left of screen -> bottom left of texture
|
||||||
|
0f, 0f // Top right of screen -> top left of texture
|
||||||
|
)
|
||||||
|
|
||||||
|
// Texture coordinates for front camera (mirrored + rotated)
|
||||||
|
// Front camera needs horizontal mirror for natural selfie view
|
||||||
|
private val texCoordsFront = floatArrayOf(
|
||||||
|
0f, 1f, // Bottom left of screen
|
||||||
|
0f, 0f, // Bottom right of screen
|
||||||
|
1f, 1f, // Top left of screen
|
||||||
|
1f, 0f // Top right of screen
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
@ -91,8 +104,12 @@ class TiltShiftRenderer(
|
||||||
cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
|
cameraVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
|
||||||
cameraVertexBuffer.position(0)
|
cameraVertexBuffer.position(0)
|
||||||
|
|
||||||
// Fullscreen quad for blur passes (standard coords). The same buffer is reused
|
// Camera texcoord buffer (rotated for portrait)
|
||||||
// for the camera passthrough texcoords — rotation is applied via uTexMatrix.
|
cameraTexCoordBuffer = allocateFloatBuffer(8)
|
||||||
|
cameraTexCoordBuffer.put(currentTexCoords)
|
||||||
|
cameraTexCoordBuffer.position(0)
|
||||||
|
|
||||||
|
// Fullscreen quad for blur passes (standard coords)
|
||||||
fullscreenVertexBuffer = allocateFloatBuffer(8)
|
fullscreenVertexBuffer = allocateFloatBuffer(8)
|
||||||
fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
|
fullscreenVertexBuffer.put(floatArrayOf(-1f, -1f, 1f, -1f, -1f, 1f, 1f, 1f))
|
||||||
fullscreenVertexBuffer.position(0)
|
fullscreenVertexBuffer.position(0)
|
||||||
|
|
@ -128,19 +145,20 @@ class TiltShiftRenderer(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDrawFrame(gl: GL10?) {
|
override fun onDrawFrame(gl: GL10?) {
|
||||||
val st = surfaceTexture
|
surfaceTexture?.updateTexImage()
|
||||||
st?.updateTexImage()
|
|
||||||
// SurfaceTexture's transform matrix only handles the sensor-to-buffer
|
|
||||||
// orientation; for a custom SurfaceProvider it does NOT vary with
|
|
||||||
// Preview.targetRotation. Apply the display-rotation correction here.
|
|
||||||
st?.getTransformMatrix(sensorMatrix)
|
|
||||||
composeTexMatrix()
|
|
||||||
|
|
||||||
if (vertexBufferDirty) {
|
if (vertexBufferDirty) {
|
||||||
recomputeVertices()
|
recomputeVertices()
|
||||||
vertexBufferDirty = false
|
vertexBufferDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (updateTexCoordBuffer) {
|
||||||
|
cameraTexCoordBuffer.clear()
|
||||||
|
cameraTexCoordBuffer.put(currentTexCoords)
|
||||||
|
cameraTexCoordBuffer.position(0)
|
||||||
|
updateTexCoordBuffer = false
|
||||||
|
}
|
||||||
|
|
||||||
val params = blurParameters
|
val params = blurParameters
|
||||||
|
|
||||||
// --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) ---
|
// --- Pass 1: Camera → FBO-A (passthrough with crop-to-fill) ---
|
||||||
|
|
@ -151,10 +169,10 @@ class TiltShiftRenderer(
|
||||||
)
|
)
|
||||||
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
|
GLES20.glViewport(0, 0, surfaceWidth, surfaceHeight)
|
||||||
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||||
shader.usePassthrough(cameraTextureId, texMatrix, isFrontCamera)
|
shader.usePassthrough(cameraTextureId)
|
||||||
drawQuad(
|
drawQuad(
|
||||||
shader.passthroughPositionLoc, shader.passthroughTexCoordLoc,
|
shader.passthroughPositionLoc, shader.passthroughTexCoordLoc,
|
||||||
cameraVertexBuffer, fullscreenTexCoordBuffer
|
cameraVertexBuffer, cameraTexCoordBuffer
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Pass 2: FBO-A → FBO-B (horizontal blur) ---
|
// --- Pass 2: FBO-A → FBO-B (horizontal blur) ---
|
||||||
|
|
@ -185,7 +203,11 @@ class TiltShiftRenderer(
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setFrontCamera(front: Boolean) {
|
fun setFrontCamera(front: Boolean) {
|
||||||
isFrontCamera = front
|
if (isFrontCamera != front) {
|
||||||
|
isFrontCamera = front
|
||||||
|
currentTexCoords = if (front) texCoordsFront else texCoordsBack
|
||||||
|
updateTexCoordBuffer = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setCameraResolution(width: Int, height: Int) {
|
fun setCameraResolution(width: Int, height: Int) {
|
||||||
|
|
@ -196,49 +218,6 @@ class TiltShiftRenderer(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Updates the display rotation so crop-to-fill picks the right effective aspect. */
|
|
||||||
fun setDisplayRotation(rotation: Int) {
|
|
||||||
if (displayRotation != rotation) {
|
|
||||||
displayRotation = rotation
|
|
||||||
vertexBufferDirty = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combines the sensor-to-buffer matrix with a rotation that compensates for
|
|
||||||
* the activity's current rotation. The activity rotates with the device
|
|
||||||
* (screenOrientation="fullSensor"), so the GL clip-space "up" direction
|
|
||||||
* tracks the device rather than the world. To keep world-up at screen-up
|
|
||||||
* regardless of orientation, rotate the texcoord sampling pattern by the
|
|
||||||
* inverse of the activity rotation. Without this correction, the two
|
|
||||||
* landscape orientations would render the same matrix and one would appear
|
|
||||||
* upside-down on a real device.
|
|
||||||
*/
|
|
||||||
private fun composeTexMatrix() {
|
|
||||||
// Inverse of the activity rotation: rotating the sampling pattern by
|
|
||||||
// -activityAngle puts the world-aligned point originally at screen P
|
|
||||||
// at the same screen P after the activity has rotated.
|
|
||||||
val angle = when (displayRotation) {
|
|
||||||
Surface.ROTATION_90 -> -90f
|
|
||||||
Surface.ROTATION_180 -> 180f
|
|
||||||
Surface.ROTATION_270 -> 90f
|
|
||||||
else -> 0f
|
|
||||||
}
|
|
||||||
if (angle == 0f) {
|
|
||||||
System.arraycopy(sensorMatrix, 0, texMatrix, 0, 16)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Build rotation around the (0.5, 0.5) texcoord center.
|
|
||||||
Matrix.setIdentityM(texMatrix, 0)
|
|
||||||
Matrix.translateM(texMatrix, 0, 0.5f, 0.5f, 0f)
|
|
||||||
Matrix.rotateM(texMatrix, 0, angle, 0f, 0f, 1f)
|
|
||||||
Matrix.translateM(texMatrix, 0, -0.5f, -0.5f, 0f)
|
|
||||||
// texMatrix = sensorMatrix * rotation (rotation is applied to the
|
|
||||||
// texcoord first, then the sensor-to-buffer transform).
|
|
||||||
val rot = texMatrix.copyOf()
|
|
||||||
Matrix.multiplyMM(texMatrix, 0, sensorMatrix, 0, rot, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun release() {
|
fun release() {
|
||||||
shader.release()
|
shader.release()
|
||||||
surfaceTexture?.release()
|
surfaceTexture?.release()
|
||||||
|
|
@ -275,24 +254,16 @@ class TiltShiftRenderer(
|
||||||
/**
|
/**
|
||||||
* Recomputes camera vertex positions to achieve crop-to-fill.
|
* Recomputes camera vertex positions to achieve crop-to-fill.
|
||||||
*
|
*
|
||||||
* The camera buffer is the sensor's native landscape resolution. The texMatrix
|
* The camera sensor is landscape; after the 90° rotation applied via texture coordinates,
|
||||||
* rotates it to match the display, so the *effective* displayed dimensions
|
* the effective portrait dimensions are (cameraHeight × cameraWidth). We scale the vertex
|
||||||
* depend on the display rotation: in portrait the buffer is rotated 90°
|
* quad so the camera frame fills the surface without stretching — the GPU clips the overflow.
|
||||||
* (effective width = cameraHeight), in landscape it is unrotated.
|
|
||||||
* We scale the vertex quad so the frame fills the surface without stretching —
|
|
||||||
* the GPU clips the overflow.
|
|
||||||
*/
|
*/
|
||||||
private fun recomputeVertices() {
|
private fun recomputeVertices() {
|
||||||
var scaleX = 1f
|
var scaleX = 1f
|
||||||
var scaleY = 1f
|
var scaleY = 1f
|
||||||
|
|
||||||
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
|
if (cameraWidth > 0 && cameraHeight > 0 && surfaceWidth > 0 && surfaceHeight > 0) {
|
||||||
val isPortrait = displayRotation == Surface.ROTATION_0 ||
|
val cameraRatio = cameraHeight.toFloat() / cameraWidth
|
||||||
displayRotation == Surface.ROTATION_180
|
|
||||||
val effectiveW = if (isPortrait) cameraHeight else cameraWidth
|
|
||||||
val effectiveH = if (isPortrait) cameraWidth else cameraHeight
|
|
||||||
|
|
||||||
val cameraRatio = effectiveW.toFloat() / effectiveH
|
|
||||||
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
|
val screenRatio = surfaceWidth.toFloat() / surfaceHeight
|
||||||
|
|
||||||
if (cameraRatio > screenRatio) {
|
if (cameraRatio > screenRatio) {
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,6 @@ import kotlin.math.sin
|
||||||
*/
|
*/
|
||||||
class TiltShiftShader(private val context: Context) {
|
class TiltShiftShader(private val context: Context) {
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val IDENTITY_MATRIX = floatArrayOf(
|
|
||||||
1f, 0f, 0f, 0f,
|
|
||||||
0f, 1f, 0f, 0f,
|
|
||||||
0f, 0f, 1f, 0f,
|
|
||||||
0f, 0f, 0f, 1f
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Passthrough program (camera → FBO) ---
|
// --- Passthrough program (camera → FBO) ---
|
||||||
|
|
||||||
private var passthroughProgramId: Int = 0
|
private var passthroughProgramId: Int = 0
|
||||||
|
|
@ -38,8 +29,6 @@ class TiltShiftShader(private val context: Context) {
|
||||||
var passthroughTexCoordLoc: Int = 0
|
var passthroughTexCoordLoc: Int = 0
|
||||||
private set
|
private set
|
||||||
private var passthroughTextureLoc: Int = 0
|
private var passthroughTextureLoc: Int = 0
|
||||||
private var passthroughTexMatrixLoc: Int = 0
|
|
||||||
private var passthroughMirrorLoc: Int = 0
|
|
||||||
|
|
||||||
// --- Blur program (FBO → FBO/screen) ---
|
// --- Blur program (FBO → FBO/screen) ---
|
||||||
|
|
||||||
|
|
@ -50,8 +39,6 @@ class TiltShiftShader(private val context: Context) {
|
||||||
var blurTexCoordLoc: Int = 0
|
var blurTexCoordLoc: Int = 0
|
||||||
private set
|
private set
|
||||||
private var blurTextureLoc: Int = 0
|
private var blurTextureLoc: Int = 0
|
||||||
private var blurTexMatrixLoc: Int = 0
|
|
||||||
private var blurMirrorLoc: Int = 0
|
|
||||||
private var blurModeLoc: Int = 0
|
private var blurModeLoc: Int = 0
|
||||||
private var blurPositionXLoc: Int = 0
|
private var blurPositionXLoc: Int = 0
|
||||||
private var blurPositionYLoc: Int = 0
|
private var blurPositionYLoc: Int = 0
|
||||||
|
|
@ -81,8 +68,6 @@ class TiltShiftShader(private val context: Context) {
|
||||||
passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition")
|
passthroughPositionLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aPosition")
|
||||||
passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord")
|
passthroughTexCoordLoc = GLES20.glGetAttribLocation(passthroughProgramId, "aTexCoord")
|
||||||
passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture")
|
passthroughTextureLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexture")
|
||||||
passthroughTexMatrixLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uTexMatrix")
|
|
||||||
passthroughMirrorLoc = GLES20.glGetUniformLocation(passthroughProgramId, "uMirrorX")
|
|
||||||
|
|
||||||
// Blur program
|
// Blur program
|
||||||
val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment)
|
val blurFragSource = loadShaderSource(R.raw.tiltshift_fragment)
|
||||||
|
|
@ -93,8 +78,6 @@ class TiltShiftShader(private val context: Context) {
|
||||||
blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition")
|
blurPositionLoc = GLES20.glGetAttribLocation(blurProgramId, "aPosition")
|
||||||
blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord")
|
blurTexCoordLoc = GLES20.glGetAttribLocation(blurProgramId, "aTexCoord")
|
||||||
blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture")
|
blurTextureLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexture")
|
||||||
blurTexMatrixLoc = GLES20.glGetUniformLocation(blurProgramId, "uTexMatrix")
|
|
||||||
blurMirrorLoc = GLES20.glGetUniformLocation(blurProgramId, "uMirrorX")
|
|
||||||
blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode")
|
blurModeLoc = GLES20.glGetUniformLocation(blurProgramId, "uMode")
|
||||||
blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX")
|
blurPositionXLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionX")
|
||||||
blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY")
|
blurPositionYLoc = GLES20.glGetUniformLocation(blurProgramId, "uPositionY")
|
||||||
|
|
@ -113,19 +96,12 @@ class TiltShiftShader(private val context: Context) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Activates the passthrough program and binds the camera texture.
|
* Activates the passthrough program and binds the camera texture.
|
||||||
*
|
|
||||||
* @param cameraTextureId The OES texture receiving camera frames.
|
|
||||||
* @param texMatrix 4x4 transform from SurfaceTexture.getTransformMatrix() —
|
|
||||||
* encodes sensor-to-display rotation and Y-flip.
|
|
||||||
* @param mirrorX true to horizontally mirror (front camera selfie view).
|
|
||||||
*/
|
*/
|
||||||
fun usePassthrough(cameraTextureId: Int, texMatrix: FloatArray, mirrorX: Boolean) {
|
fun usePassthrough(cameraTextureId: Int) {
|
||||||
GLES20.glUseProgram(passthroughProgramId)
|
GLES20.glUseProgram(passthroughProgramId)
|
||||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||||
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
|
||||||
GLES20.glUniform1i(passthroughTextureLoc, 0)
|
GLES20.glUniform1i(passthroughTextureLoc, 0)
|
||||||
GLES20.glUniformMatrix4fv(passthroughTexMatrixLoc, 1, false, texMatrix, 0)
|
|
||||||
GLES20.glUniform1f(passthroughMirrorLoc, if (mirrorX) 1f else 0f)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -152,10 +128,6 @@ class TiltShiftShader(private val context: Context) {
|
||||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId)
|
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, fboTextureId)
|
||||||
GLES20.glUniform1i(blurTextureLoc, 0)
|
GLES20.glUniform1i(blurTextureLoc, 0)
|
||||||
|
|
||||||
// FBO content is already in display orientation — pass identity matrix and no mirror.
|
|
||||||
GLES20.glUniformMatrix4fv(blurTexMatrixLoc, 1, false, IDENTITY_MATRIX, 0)
|
|
||||||
GLES20.glUniform1f(blurMirrorLoc, 0f)
|
|
||||||
|
|
||||||
GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0)
|
GLES20.glUniform1i(blurModeLoc, if (params.mode == BlurMode.RADIAL) 1 else 0)
|
||||||
GLES20.glUniform1f(blurPositionXLoc, params.positionX)
|
GLES20.glUniform1f(blurPositionXLoc, params.positionX)
|
||||||
GLES20.glUniform1f(blurPositionYLoc, params.positionY)
|
GLES20.glUniform1f(blurPositionYLoc, params.positionY)
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,6 @@ fun CameraScreen(
|
||||||
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
|
val isFrontCamera by viewModel.cameraManager.isFrontCamera.collectAsState()
|
||||||
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
|
val previewResolution by viewModel.cameraManager.previewResolution.collectAsState()
|
||||||
val cameraError by viewModel.cameraManager.error.collectAsState()
|
val cameraError by viewModel.cameraManager.error.collectAsState()
|
||||||
val currentRotation by viewModel.currentRotation.collectAsState()
|
|
||||||
|
|
||||||
// Gallery picker
|
// Gallery picker
|
||||||
val galleryLauncher = rememberLauncherForActivityResult(
|
val galleryLauncher = rememberLauncherForActivityResult(
|
||||||
|
|
@ -165,13 +164,6 @@ fun CameraScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward device rotation to renderer (aspect math) and CameraX (target rotation)
|
|
||||||
LaunchedEffect(currentRotation, renderer) {
|
|
||||||
renderer?.setDisplayRotation(currentRotation)
|
|
||||||
viewModel.cameraManager.setTargetRotation(currentRotation)
|
|
||||||
glSurfaceView?.requestRender()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start camera when surface texture is available
|
// Start camera when surface texture is available
|
||||||
LaunchedEffect(surfaceTexture) {
|
LaunchedEffect(surfaceTexture) {
|
||||||
surfaceTexture?.let {
|
surfaceTexture?.let {
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,7 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.RestartAlt
|
import androidx.compose.material.icons.filled.RestartAlt
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
|
@ -113,10 +110,8 @@ fun ControlPanel(
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.width(200.dp)
|
.width(200.dp)
|
||||||
.wrapContentHeight()
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(AppColors.OverlayDarker)
|
.background(AppColors.OverlayDarker)
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,12 @@
|
||||||
// Vertex shader for tilt-shift effect.
|
// Vertex shader for tilt-shift effect
|
||||||
//
|
// Passes through position and calculates texture coordinates
|
||||||
// uTexMatrix: applied to texcoords. For the passthrough pass it carries the
|
|
||||||
// SurfaceTexture transform (sensor → display rotation, plus Y-flip). For the
|
|
||||||
// blur passes it is identity.
|
|
||||||
// uMirrorX: 1.0 to horizontally mirror texcoords (front-camera selfie view),
|
|
||||||
// 0.0 otherwise. Applied AFTER uTexMatrix.
|
|
||||||
|
|
||||||
attribute vec4 aPosition;
|
attribute vec4 aPosition;
|
||||||
attribute vec2 aTexCoord;
|
attribute vec2 aTexCoord;
|
||||||
|
|
||||||
uniform mat4 uTexMatrix;
|
|
||||||
uniform float uMirrorX;
|
|
||||||
|
|
||||||
varying vec2 vTexCoord;
|
varying vec2 vTexCoord;
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
gl_Position = aPosition;
|
gl_Position = aPosition;
|
||||||
vec2 tc = (uTexMatrix * vec4(aTexCoord, 0.0, 1.0)).xy;
|
vTexCoord = aTexCoord;
|
||||||
if (uMirrorX > 0.5) tc.x = 1.0 - tc.x;
|
|
||||||
vTexCoord = tc;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
versionMajor=1
|
versionMajor=1
|
||||||
versionMinor=1
|
versionMinor=1
|
||||||
versionPatch=9
|
versionPatch=15
|
||||||
versionCode=11
|
versionCode=17
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue