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,5 @@
1
+ # Planned Android editor
2
+
3
+ Milestone one contains no editing engine. A future full-screen editor will use Media3 Player for preview, Media3 Transformer for supported video transformations and export, Canvas/Bitmap for basic photo composition, and coroutines for asynchronous work. OpenGL ES or a deliberate GPU abstraction will be introduced only if measured real-time filter requirements justify it.
4
+
5
+ `content://` inputs will be read through `ContentResolver`. Cache and output files will be streamed and lifecycle-managed without loading entire videos into memory. Gallery permissions will only be requested by a consuming app when gallery saving is explicitly enabled.
@@ -0,0 +1,29 @@
1
+ package com.photovideoeditor.files
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import java.io.File
6
+
7
+ /**
8
+ * Resolves an editor source uri (file://, content://, or a bare path) to a
9
+ * local filesystem path that platform decoders (BitmapFactory, ExifInterface)
10
+ * can read directly. content:// sources are copied to a cache-dir temp file
11
+ * since those decoders require a real path.
12
+ */
13
+ object SourceResolver {
14
+ fun resolvePath(context: Context, sourceUri: String, tempPrefix: String): String? {
15
+ val uri = Uri.parse(sourceUri)
16
+ return when (uri.scheme) {
17
+ "file", null -> uri.path ?: sourceUri
18
+ "content" -> {
19
+ val tempFile = File.createTempFile(tempPrefix, ".tmp", context.cacheDir)
20
+ val copied = context.contentResolver.openInputStream(uri)?.use { input ->
21
+ tempFile.outputStream().use { output -> input.copyTo(output) }
22
+ true
23
+ } ?: false
24
+ if (copied) tempFile.absolutePath else null
25
+ }
26
+ else -> null
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,11 @@
1
+ package com.photovideoeditor.media
2
+
3
+ /** Pure sizing policy shared by previews and overlay decoding. */
4
+ object BitmapSampling {
5
+ fun inSampleSize(width: Int, height: Int, maxEdge: Int): Int {
6
+ if (width <= 0 || height <= 0 || maxEdge <= 0) return 1
7
+ var sample = 1
8
+ while (maxOf(width, height) / (sample * 2) >= maxEdge) sample *= 2
9
+ return sample
10
+ }
11
+ }
@@ -0,0 +1,3 @@
1
+ package com.photovideoeditor.model
2
+
3
+ internal data class EditorRequest(val uri: String, val mediaType: String, val mimeType: String?)
@@ -0,0 +1,201 @@
1
+ package com.photovideoeditor.photo.export
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.Matrix
7
+ import android.net.Uri
8
+ import android.os.Build
9
+ import androidx.exifinterface.media.ExifInterface
10
+ import com.photovideoeditor.files.SourceResolver
11
+ import com.photovideoeditor.photo.render.PhotoAdjustmentRenderer
12
+ import com.photovideoeditor.photo.render.PhotoAdjustments
13
+ import com.photovideoeditor.photo.render.PhotoLayer
14
+ import com.photovideoeditor.photo.render.PhotoLayerRenderer
15
+ import com.photovideoeditor.photo.render.PhotoTransformState
16
+ import org.json.JSONObject
17
+ import java.io.File
18
+ import java.io.FileOutputStream
19
+ import java.util.UUID
20
+ import kotlin.math.roundToInt
21
+
22
+ data class PhotoExportResult(
23
+ val uri: String,
24
+ val width: Int,
25
+ val height: Int,
26
+ val fileSize: Long,
27
+ val mimeType: String
28
+ )
29
+
30
+ class PhotoExportException(val code: String, message: String) : Exception(message)
31
+
32
+ /**
33
+ * Applies a [PhotoTransformState] to the full-resolution source photo and
34
+ * writes the result to a new file in the app cache dir. The source file is
35
+ * never modified. Bitmaps are downsampled to the requested export bounds (or
36
+ * a safe default) to avoid decoding an unbounded full-resolution image twice.
37
+ */
38
+ object PhotoExporter {
39
+ private const val DEFAULT_MAX_DIMENSION = 4096
40
+
41
+ fun export(
42
+ context: Context,
43
+ sourceUri: String,
44
+ transform: PhotoTransformState,
45
+ adjustments: PhotoAdjustments,
46
+ layers: List<PhotoLayer>,
47
+ exportOptions: JSONObject?
48
+ ): PhotoExportResult {
49
+ val path = SourceResolver.resolvePath(context, sourceUri, "pve_export")
50
+ ?: throw PhotoExportException("E_SOURCE_NOT_FOUND", "The selected photo could not be found.")
51
+
52
+ val exif = try { ExifInterface(path) } catch (_: Exception) { null }
53
+ val exifOrientation = exif?.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
54
+ ?: ExifInterface.ORIENTATION_NORMAL
55
+
56
+ val maxWidth = exportOptions?.optInt("maxWidth", 0)?.takeIf { it > 0 }
57
+ val maxHeight = exportOptions?.optInt("maxHeight", 0)?.takeIf { it > 0 }
58
+
59
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
60
+ BitmapFactory.decodeFile(path, bounds)
61
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) {
62
+ throw PhotoExportException("E_SOURCE_UNREADABLE", "The selected photo could not be decoded.")
63
+ }
64
+
65
+ val decodeTarget = maxOf(maxWidth ?: DEFAULT_MAX_DIMENSION, maxHeight ?: DEFAULT_MAX_DIMENSION)
66
+ val sampleSize = computeSampleSize(bounds.outWidth, bounds.outHeight, decodeTarget)
67
+ val decoded = BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sampleSize })
68
+ ?: throw PhotoExportException("E_OUT_OF_MEMORY", "The photo is too large to process on this device.")
69
+
70
+ val upright = applyExifOrientation(decoded, exifOrientation)
71
+ val transformed = applyTransform(upright, transform)
72
+ val adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments)
73
+ val layered = PhotoLayerRenderer.render(adjusted, layers) { uri -> resolveImageUri(context, uri) }
74
+ val cropped = cropBitmap(layered, transform)
75
+ val resized = resizeToFit(cropped, maxWidth, maxHeight)
76
+
77
+ val format = exportOptions?.optString("imageFormat", "jpeg")?.lowercase() ?: "jpeg"
78
+ val quality = qualityFor(exportOptions?.optString("quality", "high") ?: "high")
79
+ val (compressFormat, mimeType, extension) = encodingFor(format)
80
+
81
+ val outputDir = File(context.cacheDir, "photovideoeditor").apply { mkdirs() }
82
+ val outputFile = File(outputDir, "${UUID.randomUUID()}.$extension")
83
+ FileOutputStream(outputFile).use { stream ->
84
+ if (!resized.compress(compressFormat, quality, stream)) {
85
+ throw PhotoExportException("E_EXPORT_FAILED", "Unable to encode the exported photo.")
86
+ }
87
+ }
88
+
89
+ if (format == "jpeg" && (exportOptions?.optBoolean("preserveMetadata", true) != false)) {
90
+ preserveMetadata(exif, outputFile.absolutePath)
91
+ }
92
+
93
+ if (decoded !== upright) decoded.recycle()
94
+ if (upright !== transformed) upright.recycle()
95
+ if (transformed !== adjusted) transformed.recycle()
96
+ if (adjusted !== layered) adjusted.recycle()
97
+ if (layered !== cropped) layered.recycle()
98
+ if (cropped !== resized) cropped.recycle()
99
+
100
+ return PhotoExportResult(
101
+ uri = Uri.fromFile(outputFile).toString(),
102
+ width = resized.width,
103
+ height = resized.height,
104
+ fileSize = outputFile.length(),
105
+ mimeType = mimeType
106
+ )
107
+ }
108
+
109
+ private fun resolveImageUri(context: Context, uri: String): Bitmap? {
110
+ val path = SourceResolver.resolvePath(context, uri, "pve_layer_image_export") ?: return null
111
+ return BitmapFactory.decodeFile(path)
112
+ }
113
+
114
+ private fun computeSampleSize(width: Int, height: Int, target: Int): Int {
115
+ var sample = 1
116
+ while (width / (sample * 2) >= target && height / (sample * 2) >= target) sample *= 2
117
+ return sample
118
+ }
119
+
120
+ private fun applyExifOrientation(bitmap: Bitmap, orientation: Int): Bitmap {
121
+ val matrix = Matrix()
122
+ when (orientation) {
123
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
124
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
125
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
126
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
127
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
128
+ else -> return bitmap
129
+ }
130
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
131
+ }
132
+
133
+ private fun applyTransform(bitmap: Bitmap, transform: PhotoTransformState): Bitmap {
134
+ if (transform.rotationDegrees == 0 && transform.straightenDegrees == 0f) return bitmap
135
+ return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, transform.toMatrix(), true)
136
+ }
137
+
138
+ private fun cropBitmap(bitmap: Bitmap, transform: PhotoTransformState): Bitmap {
139
+ if (transform.cropLeft <= 0f && transform.cropTop <= 0f && transform.cropRight >= 1f && transform.cropBottom >= 1f) {
140
+ return bitmap
141
+ }
142
+ val left = (transform.cropLeft * bitmap.width).roundToInt().coerceIn(0, bitmap.width - 1)
143
+ val top = (transform.cropTop * bitmap.height).roundToInt().coerceIn(0, bitmap.height - 1)
144
+ val right = (transform.cropRight * bitmap.width).roundToInt().coerceIn(left + 1, bitmap.width)
145
+ val bottom = (transform.cropBottom * bitmap.height).roundToInt().coerceIn(top + 1, bitmap.height)
146
+ return Bitmap.createBitmap(bitmap, left, top, right - left, bottom - top)
147
+ }
148
+
149
+ private fun resizeToFit(bitmap: Bitmap, maxWidth: Int?, maxHeight: Int?): Bitmap {
150
+ if (maxWidth == null && maxHeight == null) return bitmap
151
+ val widthLimit = maxWidth ?: bitmap.width
152
+ val heightLimit = maxHeight ?: bitmap.height
153
+ if (bitmap.width <= widthLimit && bitmap.height <= heightLimit) return bitmap
154
+ val scale = minOf(widthLimit.toFloat() / bitmap.width, heightLimit.toFloat() / bitmap.height)
155
+ val targetWidth = (bitmap.width * scale).roundToInt().coerceAtLeast(1)
156
+ val targetHeight = (bitmap.height * scale).roundToInt().coerceAtLeast(1)
157
+ return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
158
+ }
159
+
160
+ private fun qualityFor(preset: String): Int = when (preset) {
161
+ "low" -> 50
162
+ "medium" -> 75
163
+ "original" -> 100
164
+ else -> 90
165
+ }
166
+
167
+ @Suppress("DEPRECATION")
168
+ private fun legacyWebp() = Bitmap.CompressFormat.WEBP
169
+
170
+ private fun encodingFor(format: String): Triple<Bitmap.CompressFormat, String, String> = when (format) {
171
+ "png" -> Triple(Bitmap.CompressFormat.PNG, "image/png", "png")
172
+ "webp" -> Triple(
173
+ if (Build.VERSION.SDK_INT >= 30) Bitmap.CompressFormat.WEBP_LOSSY else legacyWebp(),
174
+ "image/webp",
175
+ "webp"
176
+ )
177
+ else -> Triple(Bitmap.CompressFormat.JPEG, "image/jpeg", "jpg")
178
+ }
179
+
180
+ private fun preserveMetadata(source: ExifInterface?, outputPath: String) {
181
+ if (source == null) return
182
+ try {
183
+ val output = ExifInterface(outputPath)
184
+ listOf(
185
+ ExifInterface.TAG_DATETIME,
186
+ ExifInterface.TAG_DATETIME_DIGITIZED,
187
+ ExifInterface.TAG_MAKE,
188
+ ExifInterface.TAG_MODEL,
189
+ ExifInterface.TAG_GPS_LATITUDE,
190
+ ExifInterface.TAG_GPS_LATITUDE_REF,
191
+ ExifInterface.TAG_GPS_LONGITUDE,
192
+ ExifInterface.TAG_GPS_LONGITUDE_REF
193
+ ).forEach { tag -> source.getAttribute(tag)?.let { output.setAttribute(tag, it) } }
194
+ // Pixels are already upright post-transform; the copied source orientation tag must not be reapplied.
195
+ output.setAttribute(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL.toString())
196
+ output.saveAttributes()
197
+ } catch (_: Exception) {
198
+ // Best-effort only; a metadata write failure must not fail the export.
199
+ }
200
+ }
201
+ }
@@ -0,0 +1,22 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ /** Pure coordinate mapping shared by preview and export renderers. */
4
+ object OverlayGeometry {
5
+ data class Transform(
6
+ val centerX: Float,
7
+ val centerY: Float,
8
+ val scale: Float,
9
+ val rotationDegrees: Float,
10
+ val opacity: Float,
11
+ val shortSide: Float,
12
+ )
13
+
14
+ fun map(layer: PhotoLayer, frameWidth: Int, frameHeight: Int): Transform = Transform(
15
+ centerX = layer.x * frameWidth,
16
+ centerY = layer.y * frameHeight,
17
+ scale = layer.scale,
18
+ rotationDegrees = layer.rotationDegrees,
19
+ opacity = layer.opacity.coerceIn(0f, 1f),
20
+ shortSide = minOf(frameWidth, frameHeight).toFloat(),
21
+ )
22
+ }
@@ -0,0 +1,160 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ import android.graphics.Bitmap
4
+ import android.graphics.Canvas
5
+ import android.graphics.ColorMatrix
6
+ import android.graphics.ColorMatrixColorFilter
7
+ import android.graphics.Matrix
8
+ import android.graphics.Paint
9
+ import kotlin.math.pow
10
+ import kotlin.math.roundToInt
11
+
12
+ /**
13
+ * Applies the non-destructive [PhotoAdjustments] stack to a bitmap. The live
14
+ * preview and the exporter both call this, in this exact order, so what the
15
+ * user sees always matches what gets exported:
16
+ *
17
+ * color matrix (brightness/contrast/saturation/exposure/temperature) ->
18
+ * blur -> mirror -> preset filter blend.
19
+ */
20
+ object PhotoAdjustmentRenderer {
21
+ fun apply(source: Bitmap, adjustments: PhotoAdjustments): Bitmap {
22
+ if (adjustments.isIdentity) return source
23
+ var bitmap = applyColorMatrix(source, adjustments)
24
+ if (adjustments.blurRadius >= 1f) bitmap = applyBoxBlur(bitmap, adjustments.blurRadius.roundToInt())
25
+ if (adjustments.mirror) bitmap = applyMirror(bitmap)
26
+ adjustments.filterPreset?.let { preset -> bitmap = applyPreset(bitmap, preset, adjustments.filterStrength / 100f) }
27
+ return bitmap
28
+ }
29
+
30
+ private fun applyColorMatrix(source: Bitmap, adjustments: PhotoAdjustments): Bitmap {
31
+ if (adjustments.brightness == 0f && adjustments.contrast == 0f && adjustments.saturation == 0f &&
32
+ adjustments.exposure == 0f && adjustments.temperature == 0f
33
+ ) return source
34
+
35
+ val matrix = ColorMatrix()
36
+ matrix.postConcat(scaleMatrix(2f.pow(adjustments.exposure / 100f)))
37
+ matrix.postConcat(translateMatrix(adjustments.brightness / 100f * 80f))
38
+ matrix.postConcat(contrastMatrix((adjustments.contrast + 100f) / 100f))
39
+ matrix.postConcat(ColorMatrix().apply { setSaturation((adjustments.saturation + 100f) / 100f) })
40
+ matrix.postConcat(temperatureMatrix(adjustments.temperature / 100f * 40f))
41
+
42
+ val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
43
+ Canvas(result).drawBitmap(source, 0f, 0f, Paint(Paint.ANTI_ALIAS_FLAG).apply { colorFilter = ColorMatrixColorFilter(matrix) })
44
+ return result
45
+ }
46
+
47
+ private fun scaleMatrix(scale: Float) = ColorMatrix(
48
+ floatArrayOf(
49
+ scale, 0f, 0f, 0f, 0f,
50
+ 0f, scale, 0f, 0f, 0f,
51
+ 0f, 0f, scale, 0f, 0f,
52
+ 0f, 0f, 0f, 1f, 0f
53
+ )
54
+ )
55
+
56
+ private fun translateMatrix(offset: Float) = ColorMatrix(
57
+ floatArrayOf(
58
+ 1f, 0f, 0f, 0f, offset,
59
+ 0f, 1f, 0f, 0f, offset,
60
+ 0f, 0f, 1f, 0f, offset,
61
+ 0f, 0f, 0f, 1f, 0f
62
+ )
63
+ )
64
+
65
+ private fun contrastMatrix(scale: Float): ColorMatrix {
66
+ val translate = 128f * (1f - scale)
67
+ return ColorMatrix(
68
+ floatArrayOf(
69
+ scale, 0f, 0f, 0f, translate,
70
+ 0f, scale, 0f, 0f, translate,
71
+ 0f, 0f, scale, 0f, translate,
72
+ 0f, 0f, 0f, 1f, 0f
73
+ )
74
+ )
75
+ }
76
+
77
+ private fun temperatureMatrix(tempShift: Float) = ColorMatrix(
78
+ floatArrayOf(
79
+ 1f, 0f, 0f, 0f, tempShift,
80
+ 0f, 1f, 0f, 0f, 0f,
81
+ 0f, 0f, 1f, 0f, -tempShift,
82
+ 0f, 0f, 0f, 1f, 0f
83
+ )
84
+ )
85
+
86
+ /** Same box-blur approximation used by the Blur adjustment slider. */
87
+ fun blur(source: Bitmap, radiusPx: Int): Bitmap = applyBoxBlur(source, radiusPx.coerceIn(1, 40))
88
+
89
+ /** Approximate Gaussian blur via three passes of separable box blur — a standard, cheap approximation. */
90
+ private fun applyBoxBlur(source: Bitmap, radius: Int): Bitmap {
91
+ val width = source.width
92
+ val height = source.height
93
+ var pixels = IntArray(width * height)
94
+ source.getPixels(pixels, 0, width, 0, 0, width, height)
95
+ repeat(3) {
96
+ pixels = boxBlurPass(pixels, width, height, radius, horizontal = true)
97
+ pixels = boxBlurPass(pixels, width, height, radius, horizontal = false)
98
+ }
99
+ val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
100
+ result.setPixels(pixels, 0, width, 0, 0, width, height)
101
+ return result
102
+ }
103
+
104
+ private fun boxBlurPass(pixels: IntArray, width: Int, height: Int, radius: Int, horizontal: Boolean): IntArray {
105
+ val output = IntArray(pixels.size)
106
+ val outerLimit = if (horizontal) height else width
107
+ val innerLimit = if (horizontal) width else height
108
+ for (outer in 0 until outerLimit) {
109
+ for (inner in 0 until innerLimit) {
110
+ var alpha = 0
111
+ var red = 0
112
+ var green = 0
113
+ var blue = 0
114
+ var count = 0
115
+ for (offset in -radius..radius) {
116
+ val sampleInner = (inner + offset).coerceIn(0, innerLimit - 1)
117
+ val index = if (horizontal) outer * width + sampleInner else sampleInner * width + outer
118
+ val pixel = pixels[index]
119
+ alpha += (pixel shr 24) and 0xFF
120
+ red += (pixel shr 16) and 0xFF
121
+ green += (pixel shr 8) and 0xFF
122
+ blue += pixel and 0xFF
123
+ count++
124
+ }
125
+ val outIndex = if (horizontal) outer * width + inner else inner * width + outer
126
+ output[outIndex] = ((alpha / count) shl 24) or ((red / count) shl 16) or ((green / count) shl 8) or (blue / count)
127
+ }
128
+ }
129
+ return output
130
+ }
131
+
132
+ /** Mirrors the left half of the image onto the right half. */
133
+ private fun applyMirror(source: Bitmap): Bitmap {
134
+ if (source.width < 2) return source
135
+ val halfWidth = source.width / 2
136
+ val leftHalf = Bitmap.createBitmap(source, 0, 0, halfWidth, source.height)
137
+ val result = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
138
+ val canvas = Canvas(result)
139
+ canvas.drawBitmap(leftHalf, 0f, 0f, null)
140
+ val flipMatrix = Matrix().apply {
141
+ postScale(-1f, 1f)
142
+ postTranslate(source.width.toFloat(), 0f)
143
+ }
144
+ canvas.drawBitmap(leftHalf, flipMatrix, null)
145
+ return result
146
+ }
147
+
148
+ private fun applyPreset(source: Bitmap, preset: String, strength: Float): Bitmap {
149
+ val matrix = PhotoFilterPresets.matrixFor(preset) ?: return source
150
+ val filtered = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
151
+ Canvas(filtered).drawBitmap(source, 0f, 0f, Paint(Paint.ANTI_ALIAS_FLAG).apply { colorFilter = ColorMatrixColorFilter(matrix) })
152
+ if (strength >= 1f) return filtered
153
+
154
+ val blended = Bitmap.createBitmap(source.width, source.height, Bitmap.Config.ARGB_8888)
155
+ val canvas = Canvas(blended)
156
+ canvas.drawBitmap(source, 0f, 0f, null)
157
+ canvas.drawBitmap(filtered, 0f, 0f, Paint(Paint.ANTI_ALIAS_FLAG).apply { alpha = (strength * 255f).roundToInt().coerceIn(0, 255) })
158
+ return blended
159
+ }
160
+ }
@@ -0,0 +1,55 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ /**
4
+ * Non-destructive photo adjustment stack (Milestone 3). Every numeric field
5
+ * has a documented range and a zero/no-op default so "reset" is always just
6
+ * `PhotoAdjustments()`.
7
+ */
8
+ data class PhotoAdjustments(
9
+ val brightness: Float = 0f, // -100..100
10
+ val contrast: Float = 0f, // -100..100
11
+ val saturation: Float = 0f, // -100..100
12
+ val exposure: Float = 0f, // -100..100
13
+ val temperature: Float = 0f, // -100..100
14
+ val blurRadius: Float = 0f, // 0..25 (px)
15
+ val mirror: Boolean = false,
16
+ val filterPreset: String? = null,
17
+ val filterStrength: Float = 100f // 0..100, only meaningful when filterPreset is set
18
+ ) {
19
+ val isIdentity: Boolean get() = this == PhotoAdjustments()
20
+
21
+ fun value(key: String): Float = when (key) {
22
+ "brightness" -> brightness
23
+ "contrast" -> contrast
24
+ "saturation" -> saturation
25
+ "exposure" -> exposure
26
+ "temperature" -> temperature
27
+ "blurRadius" -> blurRadius
28
+ "filterStrength" -> filterStrength
29
+ else -> 0f
30
+ }
31
+
32
+ fun with(key: String, value: Float): PhotoAdjustments = when (key) {
33
+ "brightness" -> copy(brightness = value)
34
+ "contrast" -> copy(contrast = value)
35
+ "saturation" -> copy(saturation = value)
36
+ "exposure" -> copy(exposure = value)
37
+ "temperature" -> copy(temperature = value)
38
+ "blurRadius" -> copy(blurRadius = value)
39
+ "filterStrength" -> copy(filterStrength = value)
40
+ else -> this
41
+ }
42
+
43
+ companion object {
44
+ /** Adjustment keys that map to a continuous slider (excludes mirror/filterPreset, which are selectors). */
45
+ val SLIDER_KEYS = listOf(
46
+ "brightness", "contrast", "saturation", "exposure", "temperature", "blurRadius"
47
+ )
48
+
49
+ fun rangeFor(key: String): Pair<Float, Float> = when (key) {
50
+ "blurRadius" -> 0f to 25f
51
+ "filterStrength" -> 0f to 100f
52
+ else -> -100f to 100f
53
+ }
54
+ }
55
+ }
@@ -0,0 +1,121 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ import android.content.Context
4
+ import android.graphics.Bitmap
5
+ import android.graphics.BitmapFactory
6
+ import android.graphics.Matrix
7
+ import androidx.exifinterface.media.ExifInterface
8
+ import com.photovideoeditor.files.SourceResolver
9
+ import com.photovideoeditor.media.BitmapSampling
10
+
11
+ /**
12
+ * Owns an in-memory, downsampled working copy of the source photo for fast,
13
+ * memory-safe live preview. The full-resolution source is decoded again, once,
14
+ * only at export time (see [com.photovideoeditor.photo.export.PhotoExporter]).
15
+ */
16
+ class PhotoEditSession(private val context: Context, sourceUri: String, maxPreviewDimension: Int = 1600) {
17
+ var state: PhotoTransformState = PhotoTransformState()
18
+ private set
19
+ var adjustments: PhotoAdjustments = PhotoAdjustments()
20
+ private set
21
+ val layerStack = PhotoLayerStack()
22
+
23
+ val baseBitmap: Bitmap? = decodeUpright(context, sourceUri, maxPreviewDimension)
24
+
25
+ private var cachedBaseBitmap: Bitmap? = null
26
+ private var cachedKey: Pair<PhotoTransformState, PhotoAdjustments>? = null
27
+ private var cachedPreviewBitmap: Bitmap? = null
28
+ private var cachedPreviewBase: Bitmap? = null
29
+ private var cachedPreviewLayerRevision = -1L
30
+ private val imageLayerCache = mutableMapOf<String, Bitmap?>()
31
+
32
+ fun update(transform: (PhotoTransformState) -> PhotoTransformState) {
33
+ state = transform(state)
34
+ }
35
+
36
+ fun updateAdjustments(transform: (PhotoAdjustments) -> PhotoAdjustments) {
37
+ adjustments = transform(adjustments)
38
+ }
39
+
40
+ /** Rotate/straighten + adjustments, cached so dragging a layer doesn't re-run the full pipeline every frame. */
41
+ private fun computeBase(): Bitmap? {
42
+ val base = baseBitmap ?: return null
43
+ val key = Pair(state, adjustments)
44
+ cachedBaseBitmap?.let { if (cachedKey == key) return it }
45
+ val transformed = if (state.rotationDegrees == 0 && state.straightenDegrees == 0f) {
46
+ base
47
+ } else {
48
+ Bitmap.createBitmap(base, 0, 0, base.width, base.height, state.toMatrix(), true)
49
+ }
50
+ val adjusted = PhotoAdjustmentRenderer.apply(transformed, adjustments)
51
+ cachedBaseBitmap = adjusted
52
+ cachedKey = key
53
+ return adjusted
54
+ }
55
+
56
+ /** Renders rotate/straighten + adjustments + layers — the crop overlay draws the crop live on top of this. */
57
+ fun renderPreview(): Bitmap? {
58
+ val base = computeBase() ?: return null
59
+ val layers = layerStack.layers
60
+ cachedPreviewBitmap?.let { cached ->
61
+ if (cachedPreviewBase === base && cachedPreviewLayerRevision == layerStack.revision) return cached
62
+ }
63
+ return PhotoLayerRenderer.render(base, layers, ::resolveImageUri).also {
64
+ cachedPreviewBitmap = it
65
+ cachedPreviewBase = base
66
+ cachedPreviewLayerRevision = layerStack.revision
67
+ }
68
+ }
69
+
70
+ /** Releases large preview and sticker/overlay bitmaps when the editor leaves the screen. */
71
+ fun release() {
72
+ val owned = buildSet {
73
+ baseBitmap?.let(::add)
74
+ cachedBaseBitmap?.let(::add)
75
+ cachedPreviewBitmap?.let(::add)
76
+ imageLayerCache.values.filterNotNull().forEach(::add)
77
+ }
78
+ owned.forEach { if (!it.isRecycled) it.recycle() }
79
+ cachedBaseBitmap = null
80
+ cachedPreviewBitmap = null
81
+ cachedPreviewBase = null
82
+ cachedPreviewLayerRevision = -1L
83
+ imageLayerCache.clear()
84
+ }
85
+
86
+ /** Resolves a sticker-uri or overlay-uri layer's image URI to a decoded bitmap, caching by URI. */
87
+ private fun resolveImageUri(uri: String): Bitmap? {
88
+ imageLayerCache[uri]?.let { return it }
89
+ if (imageLayerCache.containsKey(uri)) return null
90
+ val path = SourceResolver.resolvePath(context, uri, "pve_layer_image")
91
+ val bitmap = path?.let { BitmapFactory.decodeFile(it) }
92
+ imageLayerCache[uri] = bitmap
93
+ return bitmap
94
+ }
95
+
96
+ private fun decodeUpright(context: Context, sourceUri: String, maxDimension: Int): Bitmap? {
97
+ val path = SourceResolver.resolvePath(context, sourceUri, "pve_preview") ?: return null
98
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
99
+ BitmapFactory.decodeFile(path, bounds)
100
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
101
+
102
+ val sample = BitmapSampling.inSampleSize(bounds.outWidth, bounds.outHeight, maxDimension)
103
+ val decoded = BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
104
+
105
+ val orientation = try {
106
+ ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
107
+ } catch (_: Exception) {
108
+ ExifInterface.ORIENTATION_NORMAL
109
+ }
110
+ val matrix = Matrix()
111
+ when (orientation) {
112
+ ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
113
+ ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
114
+ ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
115
+ ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
116
+ ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
117
+ else -> return decoded
118
+ }
119
+ return Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
120
+ }
121
+ }
@@ -0,0 +1,64 @@
1
+ package com.photovideoeditor.photo.render
2
+
3
+ import android.graphics.ColorMatrix
4
+
5
+ /** A small set of original, neutrally named preset filters (no third-party looks or names copied). */
6
+ object PhotoFilterPresets {
7
+ val PRESET_IDS = listOf("mono", "noir", "fade", "chrome", "warmth")
8
+
9
+ fun labelFor(id: String): String = when (id) {
10
+ "mono" -> "Mono"
11
+ "noir" -> "Noir"
12
+ "fade" -> "Fade"
13
+ "chrome" -> "Chrome"
14
+ "warmth" -> "Warmth"
15
+ else -> id
16
+ }
17
+
18
+ fun matrixFor(id: String): ColorMatrix? = when (id) {
19
+ "mono" -> ColorMatrix().apply { setSaturation(0f) }
20
+ "noir" -> ColorMatrix().apply {
21
+ setSaturation(0f)
22
+ postConcat(
23
+ ColorMatrix(
24
+ floatArrayOf(
25
+ 1.3f, 0f, 0f, 0f, -40f,
26
+ 0f, 1.3f, 0f, 0f, -40f,
27
+ 0f, 0f, 1.3f, 0f, -40f,
28
+ 0f, 0f, 0f, 1f, 0f
29
+ )
30
+ )
31
+ )
32
+ }
33
+ "fade" -> ColorMatrix(
34
+ floatArrayOf(
35
+ 0.9f, 0f, 0f, 0f, 25f,
36
+ 0f, 0.9f, 0f, 0f, 25f,
37
+ 0f, 0f, 0.9f, 0f, 25f,
38
+ 0f, 0f, 0f, 1f, 0f
39
+ )
40
+ )
41
+ "chrome" -> ColorMatrix().apply {
42
+ setSaturation(1.4f)
43
+ postConcat(
44
+ ColorMatrix(
45
+ floatArrayOf(
46
+ 1.1f, 0f, 0f, 0f, 0f,
47
+ 0f, 1.1f, 0f, 0f, 0f,
48
+ 0f, 0f, 1.1f, 0f, 0f,
49
+ 0f, 0f, 0f, 1f, 0f
50
+ )
51
+ )
52
+ )
53
+ }
54
+ "warmth" -> ColorMatrix(
55
+ floatArrayOf(
56
+ 1f, 0f, 0f, 0f, 18f,
57
+ 0f, 1f, 0f, 0f, 6f,
58
+ 0f, 0f, 1f, 0f, -12f,
59
+ 0f, 0f, 0f, 1f, 0f
60
+ )
61
+ )
62
+ else -> null
63
+ }
64
+ }