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.
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTING.md +101 -0
- package/LICENSE +20 -0
- package/PhotoVideoEditor.podspec +23 -0
- package/README.md +53 -0
- package/android/build.gradle +61 -0
- package/android/src/main/AndroidManifest.xml +12 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
- package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
- package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
- package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
- package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
- package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
- package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
- package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
- package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
- package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
- package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
- package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
- package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
- package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
- package/android/src/main/res/drawable/ic_add.xml +9 -0
- package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
- package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
- package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
- package/android/src/main/res/drawable/ic_check.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
- package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
- package/android/src/main/res/drawable/ic_close.xml +9 -0
- package/android/src/main/res/drawable/ic_compare.xml +9 -0
- package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
- package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
- package/android/src/main/res/drawable/ic_crop.xml +9 -0
- package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
- package/android/src/main/res/drawable/ic_delete.xml +9 -0
- package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
- package/android/src/main/res/drawable/ic_face.xml +9 -0
- package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
- package/android/src/main/res/drawable/ic_flare.xml +9 -0
- package/android/src/main/res/drawable/ic_format_size.xml +9 -0
- package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
- package/android/src/main/res/drawable/ic_layers.xml +9 -0
- package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
- package/android/src/main/res/drawable/ic_lock.xml +9 -0
- package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
- package/android/src/main/res/drawable/ic_movie.xml +9 -0
- package/android/src/main/res/drawable/ic_opacity.xml +9 -0
- package/android/src/main/res/drawable/ic_palette.xml +9 -0
- package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
- package/android/src/main/res/drawable/ic_redo.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
- package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
- package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
- package/android/src/main/res/drawable/ic_share.xml +9 -0
- package/android/src/main/res/drawable/ic_speed.xml +9 -0
- package/android/src/main/res/drawable/ic_straighten.xml +9 -0
- package/android/src/main/res/drawable/ic_title.xml +9 -0
- package/android/src/main/res/drawable/ic_tune.xml +9 -0
- package/android/src/main/res/drawable/ic_undo.xml +9 -0
- package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
- package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
- package/android/src/main/res/values/styles.xml +10 -0
- package/docs/api.md +9 -0
- package/docs/architecture.md +9 -0
- package/docs/customization.md +18 -0
- package/docs/development.md +21 -0
- package/docs/formats.md +27 -0
- package/docs/performance.md +3 -0
- package/docs/permissions.md +3 -0
- package/docs/photo-editor.md +41 -0
- package/docs/roadmap.md +12 -0
- package/docs/technology-decisions.md +17 -0
- package/docs/video-editor.md +76 -0
- package/ios/Editor/DesignTokens.swift +75 -0
- package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
- package/ios/Editor/README.md +5 -0
- package/ios/Editor/UI/EditorComponents.swift +138 -0
- package/ios/Files/SourceResolver.swift +13 -0
- package/ios/Models/EditorRequest.swift +3 -0
- package/ios/Photo/Export/PhotoExporter.swift +144 -0
- package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
- package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
- package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
- package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
- package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
- package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
- package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
- package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
- package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
- package/ios/Photo/UI/CropOverlayView.swift +161 -0
- package/ios/Photo/UI/LayerOverlayView.swift +176 -0
- package/ios/Photo/UI/ZoomableImageView.swift +79 -0
- package/ios/PhotoVideoEditor.mm +20 -0
- package/ios/PhotoVideoEditor.swift +70 -0
- package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
- package/ios/Stickers/OpenMojiCatalog.swift +188 -0
- package/ios/Stickers/RemoteImageLoader.swift +127 -0
- package/ios/Validation/EditorRequestValidator.swift +9 -0
- package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
- package/ios/Video/Export/VideoExporter.swift +275 -0
- package/ios/Video/Render/VideoClip.swift +21 -0
- package/ios/Video/Render/VideoEditSession.swift +99 -0
- package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
- package/ios/Video/Render/VideoTransformState.swift +23 -0
- package/ios/Video/UI/TrimRangeView.swift +91 -0
- package/lib/module/NativePhotoVideoEditor.js +5 -0
- package/lib/module/NativePhotoVideoEditor.js.map +1 -0
- package/lib/module/errors.js +29 -0
- package/lib/module/errors.js.map +1 -0
- package/lib/module/events.js +27 -0
- package/lib/module/events.js.map +1 -0
- package/lib/module/index.js +91 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/project.js +4 -0
- package/lib/module/project.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
- package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
- package/lib/typescript/src/errors.d.ts +11 -0
- package/lib/typescript/src/errors.d.ts.map +1 -0
- package/lib/typescript/src/events.d.ts +5 -0
- package/lib/typescript/src/events.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +15 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/project.d.ts +24 -0
- package/lib/typescript/src/project.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +108 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +154 -0
- package/src/NativePhotoVideoEditor.ts +12 -0
- package/src/errors.ts +78 -0
- package/src/events.ts +43 -0
- package/src/index.ts +123 -0
- package/src/project.ts +17 -0
- 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,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
|
+
}
|