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,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
|
+
}
|