react-native-photo-video-editor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,54 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ import android.graphics.Color
4
+ import java.util.UUID
5
+
6
+ enum class LayerType { TEXT, STICKER, OVERLAY }
7
+
8
+ /**
9
+ * A single non-destructive overlay layer (text, sticker, or uploaded image
10
+ * overlay). One flat model covers every type for simplicity — a documented
11
+ * simplification versus a sealed-class-per-type hierarchy.
12
+ *
13
+ * `x`/`y` are the layer's center, normalized 0..1 against the image.
14
+ */
15
+ data class PhotoLayer(
16
+ val id: String = UUID.randomUUID().toString(),
17
+ val type: LayerType,
18
+ var x: Float = 0.5f,
19
+ var y: Float = 0.5f,
20
+ var scale: Float = 1f,
21
+ var rotationDegrees: Float = 0f,
22
+ var opacity: Float = 1f,
23
+ var locked: Boolean = false,
24
+ var visible: Boolean = true,
25
+ // Text
26
+ var text: String = "",
27
+ var textColor: Int = Color.WHITE,
28
+ var fontSize: Float = 48f,
29
+ var fontFamily: String? = null,
30
+ // Sticker
31
+ var stickerId: String? = null,
32
+ var stickerUri: String? = null,
33
+ // Overlay (uploaded image)
34
+ var overlayUri: String? = null,
35
+ var overlayAspectRatio: Float? = null,
36
+ // Timing (video overlays only; ignored for photo layers). endMs <= 0 means "to end of video".
37
+ var startMs: Long = 0L,
38
+ var endMs: Long = 0L
39
+ ) {
40
+ fun effectiveEndMs(durationMs: Long): Long = if (endMs in 1..durationMs) endMs else durationMs
41
+
42
+ /** True if this layer should be visible at [positionMs] against a video of [durationMs]. Always true for photo layers. */
43
+ fun isActiveAt(positionMs: Long, durationMs: Long): Boolean {
44
+ if (!visible || durationMs < 0) return false
45
+ val clamped = positionMs.coerceAtLeast(0L)
46
+ if (clamped < startMs.coerceAtLeast(0L)) return false
47
+ // No explicit end (or one at/past the video's end) means "visible through the real last
48
+ // frame" — don't compare against `durationMs`, which is a separately-computed estimate
49
+ // (summed clip-trim durations) that can be a few ms shy of the true final frame's
50
+ // timestamp due to frame-rate rounding, which would otherwise hide the overlay early.
51
+ if (endMs <= 0 || endMs >= durationMs) return true
52
+ return clamped <= endMs
53
+ }
54
+ }
@@ -0,0 +1,108 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.Paint
6
+ import android.graphics.RectF
7
+ import android.text.TextPaint
8
+
9
+ /**
10
+ * Burns the layer stack onto a bitmap. Used identically by the live preview
11
+ * and the exporter (different bitmap resolutions, same code and normalized
12
+ * coordinates) so what you see matches what gets exported.
13
+ */
14
+ object PhotoLayerRenderer {
15
+ private val builtinStickers = mapOf(
16
+ "smile" to "😀",
17
+ "heart" to "❤️",
18
+ "star" to "⭐",
19
+ "fire" to "🔥",
20
+ "thumbsUp" to "👍",
21
+ "sun" to "☀️",
22
+ "clap" to "👏",
23
+ "sparkles" to "✨"
24
+ )
25
+
26
+ fun builtinStickerIds(): List<String> = builtinStickers.keys.toList()
27
+ fun glyphFor(stickerId: String): String = builtinStickers[stickerId] ?: "★"
28
+
29
+ fun render(base: Bitmap, layers: List<PhotoLayer>, bitmapResolver: (String) -> Bitmap?): Bitmap {
30
+ if (layers.isEmpty()) return base
31
+ val result = base.copy(Bitmap.Config.ARGB_8888, true)
32
+ val canvas = Canvas(result)
33
+ layers.filter { it.visible }.forEach { layer ->
34
+ val geometry = OverlayGeometry.map(layer, result.width, result.height)
35
+ canvas.save()
36
+ canvas.translate(geometry.centerX, geometry.centerY)
37
+ canvas.rotate(geometry.rotationDegrees)
38
+ canvas.scale(geometry.scale, geometry.scale)
39
+ val alpha = (geometry.opacity * 255).toInt()
40
+ when (layer.type) {
41
+ LayerType.TEXT -> drawText(canvas, layer, geometry.shortSide, alpha)
42
+ LayerType.STICKER -> drawSticker(canvas, layer, geometry.shortSide, alpha, bitmapResolver)
43
+ LayerType.OVERLAY -> drawOverlay(canvas, layer, geometry.shortSide, alpha, bitmapResolver)
44
+ }
45
+ canvas.restore()
46
+ }
47
+ return result
48
+ }
49
+
50
+ private fun drawText(canvas: Canvas, layer: PhotoLayer, shortSide: Float, alpha: Int) {
51
+ val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
52
+ color = layer.textColor
53
+ this.alpha = alpha
54
+ textSize = layer.fontSize / 48f * (shortSide * 0.06f)
55
+ textAlign = Paint.Align.CENTER
56
+ layer.fontFamily?.let { family ->
57
+ try {
58
+ typeface = android.graphics.Typeface.create(family, android.graphics.Typeface.NORMAL)
59
+ } catch (_: Exception) {
60
+ // Fall back to the default typeface if the family cannot be resolved.
61
+ }
62
+ }
63
+ }
64
+ val lines = layer.text.ifBlank { " " }.split("\n")
65
+ val lineHeight = paint.fontSpacing
66
+ val totalHeight = lineHeight * lines.size
67
+ lines.forEachIndexed { index, line ->
68
+ canvas.drawText(line, 0f, -totalHeight / 2f + lineHeight * (index + 1) - paint.descent(), paint)
69
+ }
70
+ }
71
+
72
+ private fun drawSticker(canvas: Canvas, layer: PhotoLayer, shortSide: Float, alpha: Int, resolver: (String) -> Bitmap?) {
73
+ val size = shortSide * 0.18f
74
+ val uri = layer.stickerUri
75
+ if (uri != null) {
76
+ resolver(uri)?.let { bitmap ->
77
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.alpha = alpha }
78
+ canvas.drawBitmap(bitmap, null, RectF(-size / 2, -size / 2, size / 2, size / 2), paint)
79
+ return
80
+ }
81
+ }
82
+ val glyph = layer.stickerId?.let { glyphFor(it) } ?: "★"
83
+ val paint = TextPaint(Paint.ANTI_ALIAS_FLAG).apply {
84
+ this.alpha = alpha
85
+ textSize = size
86
+ textAlign = Paint.Align.CENTER
87
+ }
88
+ canvas.drawText(glyph, 0f, size / 2f - paint.descent(), paint)
89
+ }
90
+
91
+ private fun drawOverlay(canvas: Canvas, layer: PhotoLayer, shortSide: Float, alpha: Int, resolver: (String) -> Bitmap?) {
92
+ val uri = layer.overlayUri ?: return
93
+ val bitmap = resolver(uri) ?: return
94
+ val aspect = layer.overlayAspectRatio ?: (bitmap.width.toFloat() / bitmap.height.toFloat())
95
+ val baseSize = shortSide * 0.6f
96
+ val width: Float
97
+ val height: Float
98
+ if (aspect >= 1f) {
99
+ width = baseSize
100
+ height = baseSize / aspect
101
+ } else {
102
+ height = baseSize
103
+ width = baseSize * aspect
104
+ }
105
+ val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { this.alpha = alpha }
106
+ canvas.drawBitmap(bitmap, null, RectF(-width / 2, -height / 2, width / 2, height / 2), paint)
107
+ }
108
+ }
@@ -0,0 +1,60 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ /**
4
+ * Ordered layer stack with snapshot-based undo/redo. One entry is pushed per
5
+ * *committed* operation (add/delete/duplicate/reorder, or a drag/slider
6
+ * gesture's final value) — never per intermediate touch-move frame — so a
7
+ * continuous drag does not flood the history.
8
+ */
9
+ class PhotoLayerStack(private val historyLimit: Int = 50) {
10
+ var layers: List<PhotoLayer> = emptyList()
11
+ private set
12
+ var revision: Long = 0
13
+ private set
14
+
15
+ private val undoStack = ArrayDeque<List<PhotoLayer>>()
16
+ private val redoStack = ArrayDeque<List<PhotoLayer>>()
17
+
18
+ val canUndo: Boolean get() = undoStack.isNotEmpty()
19
+ val canRedo: Boolean get() = redoStack.isNotEmpty()
20
+
21
+ /** Applies [mutation] as a single committed, undoable operation. */
22
+ fun commit(mutation: (List<PhotoLayer>) -> List<PhotoLayer>) {
23
+ pushUndo(layers)
24
+ redoStack.clear()
25
+ layers = mutation(layers)
26
+ revision++
27
+ }
28
+
29
+ /** Updates the live layer list without creating a history entry — for in-progress drags. */
30
+ fun updateLive(mutation: (List<PhotoLayer>) -> List<PhotoLayer>) {
31
+ layers = mutation(layers)
32
+ revision++
33
+ }
34
+
35
+ /** Records [previousLayers] (captured before a live-updated gesture began) as one undo step. */
36
+ fun commitSnapshot(previousLayers: List<PhotoLayer>) {
37
+ pushUndo(previousLayers)
38
+ redoStack.clear()
39
+ revision++
40
+ }
41
+
42
+ fun undo() {
43
+ val previous = undoStack.removeLastOrNull() ?: return
44
+ redoStack.addLast(layers)
45
+ layers = previous
46
+ revision++
47
+ }
48
+
49
+ fun redo() {
50
+ val next = redoStack.removeLastOrNull() ?: return
51
+ undoStack.addLast(layers)
52
+ layers = next
53
+ revision++
54
+ }
55
+
56
+ private fun pushUndo(snapshot: List<PhotoLayer>) {
57
+ undoStack.addLast(snapshot)
58
+ if (undoStack.size > historyLimit) undoStack.removeFirst()
59
+ }
60
+ }
@@ -0,0 +1,52 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ import android.graphics.Matrix
4
+
5
+ /**
6
+ * Non-destructive photo edit state. The crop rectangle is normalized (0..1)
7
+ * against the *transformed* (rotated/straightened) image, matching
8
+ * what the user sees in the crop overlay — the original source file is never
9
+ * modified, only read again at export time.
10
+ */
11
+ data class PhotoTransformState(
12
+ val cropLeft: Float = 0f,
13
+ val cropTop: Float = 0f,
14
+ val cropRight: Float = 1f,
15
+ val cropBottom: Float = 1f,
16
+ val rotationDegrees: Int = 0,
17
+ val straightenDegrees: Float = 0f,
18
+ val aspectRatio: Float? = null
19
+ ) {
20
+ val isIdentity: Boolean
21
+ get() = cropLeft == 0f && cropTop == 0f && cropRight == 1f && cropBottom == 1f &&
22
+ rotationDegrees == 0 && straightenDegrees == 0f
23
+
24
+ /** Rotating changes which pixels a normalized crop rect refers to, so the crop resets to full-frame. */
25
+ fun rotatedRight(): PhotoTransformState = copy(
26
+ rotationDegrees = (rotationDegrees + 90) % 360,
27
+ cropLeft = 0f,
28
+ cropTop = 0f,
29
+ cropRight = 1f,
30
+ cropBottom = 1f
31
+ )
32
+
33
+ fun withStraighten(degrees: Float): PhotoTransformState = copy(straightenDegrees = degrees.coerceIn(-45f, 45f))
34
+
35
+ fun withCrop(left: Float, top: Float, right: Float, bottom: Float): PhotoTransformState = copy(
36
+ cropLeft = left.coerceIn(0f, 1f),
37
+ cropTop = top.coerceIn(0f, 1f),
38
+ cropRight = right.coerceIn(0f, 1f),
39
+ cropBottom = bottom.coerceIn(0f, 1f)
40
+ )
41
+
42
+ fun withAspectRatio(ratio: Float?): PhotoTransformState = copy(aspectRatio = ratio)
43
+
44
+ fun reset(): PhotoTransformState = PhotoTransformState()
45
+
46
+ /** Combined rotate + straighten matrix, applied around the bitmap's own origin/size by the caller. */
47
+ fun toMatrix(): Matrix {
48
+ val matrix = Matrix()
49
+ matrix.postRotate(rotationDegrees.toFloat() + straightenDegrees)
50
+ return matrix
51
+ }
52
+ }
@@ -0,0 +1,168 @@
1
+ package com.photovideoeditor.photo.ui
2
+
3
+ import android.content.Context
4
+ import android.graphics.Canvas
5
+ import android.graphics.Color
6
+ import android.graphics.Paint
7
+ import android.graphics.RectF
8
+ import android.view.MotionEvent
9
+ import android.view.View
10
+ import kotlin.math.abs
11
+
12
+ /**
13
+ * Draws a draggable/resizable crop rectangle over [imageBounds] (the on-screen
14
+ * rect of the preview image, in this view's own coordinate space) and reports
15
+ * the crop edges back normalized to that rect via [onCropChanged].
16
+ */
17
+ class CropOverlayView(context: Context) : View(context) {
18
+ private var imageBounds = RectF()
19
+ private var cropRect = RectF()
20
+ private var aspectRatio: Float? = null
21
+ var onCropChanged: ((left: Float, top: Float, right: Float, bottom: Float) -> Unit)? = null
22
+
23
+ private val dimPaint = Paint().apply { color = Color.argb(153, 0, 0, 0) }
24
+ private val borderPaint = Paint().apply { color = Color.WHITE; style = Paint.Style.STROKE; strokeWidth = 3f }
25
+ private val gridPaint = Paint().apply { color = Color.argb(120, 255, 255, 255); style = Paint.Style.STROKE; strokeWidth = 1f }
26
+ private val handlePaint = Paint().apply { color = Color.WHITE; style = Paint.Style.FILL }
27
+
28
+ private val handleTouchRadius = 56f
29
+ private val minSizePx = 96f
30
+ private var activeHandle = Handle.NONE
31
+ private var lastX = 0f
32
+ private var lastY = 0f
33
+
34
+ private enum class Handle { NONE, MOVE, TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, BOTTOM_RIGHT }
35
+
36
+ /** Sets the on-screen image rect this overlay crops against. Resets the crop to full-frame when [resetCrop] is true. */
37
+ fun setImageBounds(bounds: RectF, resetCrop: Boolean) {
38
+ imageBounds = RectF(bounds)
39
+ if (resetCrop || cropRect.width() <= 0f || cropRect.height() <= 0f) {
40
+ cropRect = RectF(imageBounds)
41
+ reportCrop()
42
+ }
43
+ invalidate()
44
+ }
45
+
46
+ fun setAspectRatio(ratio: Float?) {
47
+ aspectRatio = ratio
48
+ if (ratio != null && imageBounds.width() > 0 && imageBounds.height() > 0) {
49
+ val centerX = cropRect.centerX()
50
+ val centerY = cropRect.centerY()
51
+ var width = cropRect.width()
52
+ var height = width / ratio
53
+ if (height > imageBounds.height()) {
54
+ height = cropRect.height()
55
+ width = height * ratio
56
+ }
57
+ cropRect = RectF(centerX - width / 2, centerY - height / 2, centerX + width / 2, centerY + height / 2)
58
+ clampToImageBounds()
59
+ invalidate()
60
+ }
61
+ reportCrop()
62
+ }
63
+
64
+ override fun onDraw(canvas: Canvas) {
65
+ super.onDraw(canvas)
66
+ if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return
67
+ canvas.drawRect(imageBounds.left, imageBounds.top, imageBounds.right, cropRect.top, dimPaint)
68
+ canvas.drawRect(imageBounds.left, cropRect.bottom, imageBounds.right, imageBounds.bottom, dimPaint)
69
+ canvas.drawRect(imageBounds.left, cropRect.top, cropRect.left, cropRect.bottom, dimPaint)
70
+ canvas.drawRect(cropRect.right, cropRect.top, imageBounds.right, cropRect.bottom, dimPaint)
71
+ canvas.drawRect(cropRect, borderPaint)
72
+ val thirdWidth = cropRect.width() / 3
73
+ val thirdHeight = cropRect.height() / 3
74
+ for (i in 1..2) {
75
+ canvas.drawLine(cropRect.left + thirdWidth * i, cropRect.top, cropRect.left + thirdWidth * i, cropRect.bottom, gridPaint)
76
+ canvas.drawLine(cropRect.left, cropRect.top + thirdHeight * i, cropRect.right, cropRect.top + thirdHeight * i, gridPaint)
77
+ }
78
+ listOf(
79
+ cropRect.left to cropRect.top,
80
+ cropRect.right to cropRect.top,
81
+ cropRect.left to cropRect.bottom,
82
+ cropRect.right to cropRect.bottom
83
+ ).forEach { (x, y) -> canvas.drawCircle(x, y, 8f, handlePaint) }
84
+ }
85
+
86
+ override fun onTouchEvent(event: MotionEvent): Boolean {
87
+ when (event.actionMasked) {
88
+ MotionEvent.ACTION_DOWN -> {
89
+ activeHandle = hitTest(event.x, event.y)
90
+ lastX = event.x
91
+ lastY = event.y
92
+ return activeHandle != Handle.NONE
93
+ }
94
+ MotionEvent.ACTION_MOVE -> {
95
+ if (activeHandle == Handle.NONE) return false
96
+ val dx = event.x - lastX
97
+ val dy = event.y - lastY
98
+ lastX = event.x
99
+ lastY = event.y
100
+ applyDrag(activeHandle, dx, dy)
101
+ invalidate()
102
+ reportCrop()
103
+ return true
104
+ }
105
+ MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
106
+ val handled = activeHandle != Handle.NONE
107
+ activeHandle = Handle.NONE
108
+ return handled
109
+ }
110
+ }
111
+ return super.onTouchEvent(event)
112
+ }
113
+
114
+ private fun hitTest(x: Float, y: Float): Handle {
115
+ fun near(px: Float, py: Float) = abs(x - px) < handleTouchRadius && abs(y - py) < handleTouchRadius
116
+ return when {
117
+ near(cropRect.left, cropRect.top) -> Handle.TOP_LEFT
118
+ near(cropRect.right, cropRect.top) -> Handle.TOP_RIGHT
119
+ near(cropRect.left, cropRect.bottom) -> Handle.BOTTOM_LEFT
120
+ near(cropRect.right, cropRect.bottom) -> Handle.BOTTOM_RIGHT
121
+ cropRect.contains(x, y) -> Handle.MOVE
122
+ else -> Handle.NONE
123
+ }
124
+ }
125
+
126
+ private fun applyDrag(handle: Handle, dx: Float, dy: Float) {
127
+ val proposed = RectF(cropRect)
128
+ when (handle) {
129
+ Handle.MOVE -> proposed.offset(dx, dy)
130
+ Handle.TOP_LEFT -> { proposed.left += dx; proposed.top += dy }
131
+ Handle.TOP_RIGHT -> { proposed.right += dx; proposed.top += dy }
132
+ Handle.BOTTOM_LEFT -> { proposed.left += dx; proposed.bottom += dy }
133
+ Handle.BOTTOM_RIGHT -> { proposed.right += dx; proposed.bottom += dy }
134
+ Handle.NONE -> return
135
+ }
136
+ if (proposed.width() < minSizePx || proposed.height() < minSizePx) return
137
+ aspectRatio?.let { ratio ->
138
+ when (handle) {
139
+ Handle.TOP_LEFT, Handle.BOTTOM_RIGHT -> proposed.bottom = proposed.top + proposed.width() / ratio
140
+ Handle.TOP_RIGHT -> proposed.top = proposed.bottom - proposed.width() / ratio
141
+ Handle.BOTTOM_LEFT -> proposed.bottom = proposed.top + proposed.width() / ratio
142
+ else -> {}
143
+ }
144
+ }
145
+ cropRect = proposed
146
+ clampToImageBounds()
147
+ }
148
+
149
+ private fun clampToImageBounds() {
150
+ if (cropRect.left < imageBounds.left) cropRect.offset(imageBounds.left - cropRect.left, 0f)
151
+ if (cropRect.top < imageBounds.top) cropRect.offset(0f, imageBounds.top - cropRect.top)
152
+ if (cropRect.right > imageBounds.right) cropRect.offset(imageBounds.right - cropRect.right, 0f)
153
+ if (cropRect.bottom > imageBounds.bottom) cropRect.offset(0f, imageBounds.bottom - cropRect.bottom)
154
+ cropRect.left = cropRect.left.coerceAtLeast(imageBounds.left)
155
+ cropRect.top = cropRect.top.coerceAtLeast(imageBounds.top)
156
+ cropRect.right = cropRect.right.coerceAtMost(imageBounds.right)
157
+ cropRect.bottom = cropRect.bottom.coerceAtMost(imageBounds.bottom)
158
+ }
159
+
160
+ private fun reportCrop() {
161
+ if (imageBounds.width() <= 0 || imageBounds.height() <= 0) return
162
+ val left = (cropRect.left - imageBounds.left) / imageBounds.width()
163
+ val top = (cropRect.top - imageBounds.top) / imageBounds.height()
164
+ val right = (cropRect.right - imageBounds.left) / imageBounds.width()
165
+ val bottom = (cropRect.bottom - imageBounds.top) / imageBounds.height()
166
+ onCropChanged?.invoke(left, top, right, bottom)
167
+ }
168
+ }
@@ -0,0 +1,57 @@
1
+ package com.photovideoeditor.photo.ui
2
+
3
+ import android.graphics.Bitmap
4
+ import android.os.Handler
5
+ import android.os.Looper
6
+ import com.photovideoeditor.photo.render.PhotoAdjustmentRenderer
7
+ import com.photovideoeditor.photo.render.PhotoAdjustments
8
+ import java.util.concurrent.Executors
9
+ import java.util.concurrent.atomic.AtomicBoolean
10
+
11
+ /** Loads filter-card previews serially without blocking layout or touch input. */
12
+ class FilterThumbnailLoader {
13
+ private val executor = Executors.newSingleThreadExecutor { task ->
14
+ Thread(task, "filter-thumbnail-loader").apply { priority = Thread.NORM_PRIORITY - 1 }
15
+ }
16
+ private val mainHandler = Handler(Looper.getMainLooper())
17
+ private val closed = AtomicBoolean(false)
18
+ private val cache = mutableMapOf<String, Bitmap>()
19
+
20
+ fun load(source: Bitmap?, presetId: String, sizePx: Int, deliver: (Bitmap?) -> Unit) {
21
+ if (source == null || closed.get()) {
22
+ deliver(null)
23
+ return
24
+ }
25
+ synchronized(cache) { cache[presetId] }?.let {
26
+ deliver(it)
27
+ return
28
+ }
29
+ // Capture a tiny independent input while the session bitmap is guaranteed alive.
30
+ val square = Bitmap.createScaledBitmap(source, sizePx, sizePx, true)
31
+ executor.execute {
32
+ if (closed.get()) {
33
+ if (square !== source && !square.isRecycled) square.recycle()
34
+ return@execute
35
+ }
36
+ val rendered = PhotoAdjustmentRenderer.apply(
37
+ square,
38
+ PhotoAdjustments(filterPreset = presetId, filterStrength = 100f),
39
+ )
40
+ if (rendered !== square && !square.isRecycled) square.recycle()
41
+ synchronized(cache) { cache[presetId] = rendered }
42
+ mainHandler.post { if (!closed.get()) deliver(rendered) }
43
+ }
44
+ }
45
+
46
+ fun close() {
47
+ if (!closed.compareAndSet(false, true)) return
48
+ mainHandler.removeCallbacksAndMessages(null)
49
+ executor.execute {
50
+ synchronized(cache) {
51
+ cache.values.distinct().forEach { if (!it.isRecycled) it.recycle() }
52
+ cache.clear()
53
+ }
54
+ }
55
+ executor.shutdown()
56
+ }
57
+ }