diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d9a4edf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,102 @@ +# Tilt-Shift Camera + +Android camera app that applies a real-time tilt-shift (miniature/diorama) blur effect to the camera preview and to imported gallery images. Built with Kotlin, Jetpack Compose, CameraX, and OpenGL ES 2.0. + +## What it does + +- Live camera preview with GPU-accelerated tilt-shift blur via GLSL fragment shader +- Gallery import with CPU-based preview that updates in real time as you adjust parameters +- Supports both **linear** (band) and **radial** (elliptical) blur modes +- Gesture controls: drag to position, pinch to resize, two-finger rotate +- Slider panel for precise control of blur, falloff, size, angle, aspect ratio +- Multi-lens support on devices with multiple back cameras +- EXIF GPS tagging from device location +- Saves processed images to MediaStore (scoped storage) + +## Architecture + +``` +no.naiv.tiltshift/ + MainActivity.kt # Entry point, permissions, edge-to-edge setup + ui/ + CameraScreen.kt # Main Compose UI, GL surface, controls + CameraViewModel.kt # State management, gallery preview loop, bitmap lifecycle + TiltShiftOverlay.kt # Gesture handling and visual guides + ZoomControl.kt # Zoom presets and indicator + LensSwitcher.kt # Multi-lens picker + theme/AppColors.kt # Color constants + camera/ + CameraManager.kt # CameraX lifecycle, zoom, lens binding + ImageCaptureHandler.kt # Capture pipeline, CPU blur/mask, gallery processing + LensController.kt # Enumerates physical camera lenses + effect/ + TiltShiftRenderer.kt # GLSurfaceView.Renderer for live camera preview + TiltShiftShader.kt # Compiles GLSL, sets uniforms (incl. precomputed trig) + BlurParameters.kt # Data class for all effect parameters + storage/ + PhotoSaver.kt # MediaStore writes, EXIF metadata, IS_PENDING pattern + SaveResult.kt # Sealed class for save outcomes + util/ + LocationProvider.kt # FusedLocationProvider flow (accepts coarse or fine) + OrientationDetector.kt # Device rotation for EXIF + HapticFeedback.kt # Null-safe vibration wrapper +``` + +### Rendering pipeline + +- **Camera preview**: OpenGL ES 2.0 via `GLSurfaceView` + `TiltShiftRenderer`. Camera frames arrive as `GL_TEXTURE_EXTERNAL_OES` from a `SurfaceTexture`. The fragment shader (`tiltshift_fragment.glsl`) applies blur per-fragment using precomputed `uCosAngle`/`uSinAngle` uniforms and an unrolled 9-tap Gaussian kernel. +- **Gallery preview**: CPU-based. A 1024px-max downscaled source is kept in `galleryPreviewSource`. `CameraViewModel.startPreviewLoop()` uses `collectLatest` on blur params (with 80ms debounce) to reactively recompute the preview via `ImageCaptureHandler.applyTiltShiftPreview()`. +- **Final save**: Full-resolution CPU pipeline — stack blur at 1/4 scale, gradient mask at 1/4 scale with bilinear upscale, per-pixel compositing. Camera captures save both original + processed; gallery imports save only the processed version (original already on device). + +## Build & run + +```bash +./gradlew assembleRelease # Build release APK +./gradlew compileDebugKotlin # Quick compile check +adb install -r app/build/outputs/apk/release/naiv-tilt-shift-release.apk +``` + +Signing config is loaded from `local.properties` (not committed). + +## Key design decisions and patterns + +### Bitmap lifecycle (important!) + +Bitmaps emitted to `StateFlow`s are **never eagerly recycled** immediately after replacement. Compose may still be drawing the old bitmap in the current frame. Instead: + +- A `pendingRecyclePreview` / `pendingRecycleThumbnail` field holds the bitmap from the *previous* update +- On the *next* update, the pending bitmap is recycled (Compose has had a full frame to finish) +- Final cleanup happens in `cancelGalleryPreview()` (which `join()`s the preview job first) and `onCleared()` + +### Thread safety + +- `galleryPreviewSource` is `@Volatile` (accessed from Main thread, IO dispatcher, and cancel path) +- `TiltShiftRenderer.currentTexCoords` is `@Volatile` (written by UI thread, read by GL thread) +- `cancelGalleryPreview()` cancels + `join()`s the preview job before recycling the source bitmap, because `applyTiltShiftEffect` is a long CPU loop with no suspension points +- GL resources are released via `glSurfaceView.queueEvent {}` (must run on GL thread) +- `CameraManager.captureExecutor` is shut down in `release()` to prevent thread leaks + +### Error handling + +- `bitmap.compress()` return value is checked; failure reported to user +- `loadBitmapFromUri()` logs all null-return paths (stream open, dimensions, decode) +- Error/success dismiss indicators use cancellable `Job` tracking to prevent race conditions +- `writeExifToUri()` returns boolean and logs at ERROR level on failure + +## Permissions + +| Permission | Purpose | Notes | +|-----------|---------|-------| +| `CAMERA` | Camera preview and capture | Required | +| `ACCESS_FINE_LOCATION` | GPS EXIF tagging | Optional; coarse-only grant also works | +| `ACCESS_COARSE_LOCATION` | GPS EXIF tagging | Fallback if fine denied | +| `ACCESS_MEDIA_LOCATION` | Read GPS from gallery images | Required on Android 10+ | +| `VIBRATE` | Haptic feedback | Always granted | + +## Known limitations / future work + +- `minSdk = 35` (Android 15) — intentional for personal use. Lower to 26-29 if distributing. +- Accompanist Permissions (`0.36.0`) is deprecated; should migrate to first-party `activity-compose` API. +- No user-facing toggle to disable GPS tagging — location is embedded whenever permission is granted. +- 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.