react-native-photo-video-editor 0.2.1 → 0.3.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 +20 -0
- package/README.md +27 -0
- package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +81 -0
- package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +7 -0
- package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +26 -0
- package/android/src/main/res/drawable/ic_swap_sticker.xml +3 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +455 -151
- package/ios/Editor/UI/CropPanel.swift +10 -3
- package/ios/Editor/UI/EditorComponents.swift +53 -10
- package/ios/Editor/UI/EditorPanel.swift +228 -155
- package/ios/Editor/UI/EditorToolSlider.swift +4 -2
- package/ios/Files/SourceResolver.swift +131 -3
- package/ios/Photo/Export/PhotoExporter.swift +2 -2
- package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +57 -63
- package/ios/Photo/Rendering/PixelBuffer.swift +24 -18
- package/ios/Photo/UI/FilterThumbnailLoader.swift +3 -3
- package/ios/Photo/UI/LayerOverlayView.swift +18 -0
- package/ios/PhotoVideoEditor.mm +33 -2
- package/ios/PhotoVideoEditor.swift +22 -18
- package/ios/Stickers/OnlineStickerSheetViewController.swift +1 -1
- package/ios/Video/Render/VideoEditSession.swift +30 -11
- package/lib/module/index.js +8 -0
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/types.d.ts +2 -1
- package/lib/typescript/src/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +24 -0
- package/src/types.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0
|
|
6
|
+
|
|
7
|
+
- Breaking API change: migrate `initialStickerId: 'brand'` to `initialStickerIds: ['brand']`.
|
|
8
|
+
- Replace `initialStickerId` with `initialStickerIds` and add a top-right control on the selected default sticker to cycle through default stickers on Android and iOS. Swaps preserve transforms and support undo/redo; the button is enabled only for multiple IDs.
|
|
9
|
+
- Place the swap control on the sticker’s top-right corner, above the scale control, so it follows dragging and rotation.
|
|
10
|
+
- Remove dummy sticker data from the example; default stickers are supplied by the caller.
|
|
11
|
+
|
|
12
|
+
## 0.2.2
|
|
13
|
+
|
|
14
|
+
- Add `initialStickerId` to place a matching `stickerAssets` image at the center when opening the photo or video editor on Android and iOS.
|
|
15
|
+
- Validate initial sticker IDs before opening the editor and report unreadable initial images.
|
|
16
|
+
- Support base64 PNG data URIs for locally embedded sticker images.
|
|
17
|
+
- Keep initial stickers optional: the editor opens without a sticker unless one is supplied.
|
|
18
|
+
|
|
5
19
|
## 0.2.1
|
|
6
20
|
|
|
7
21
|
- Fix an iOS build failure in `TextEditorSheet`: its private stored property `editing` collided with `UIViewController.isEditing`, which is exported to Objective-C as `editing`, so the compiler treated it as an invalid override ("cannot override with a stored property" / "overriding property must be as accessible as its enclosing type"). The property is now `isEditingExisting`; the `editing:` initialiser label is unchanged, so call sites are unaffected.
|
|
22
|
+
- Fix three more iOS build failures in `PhotoVideoEditorViewController`:
|
|
23
|
+
- `ZoomableImageView()` had no matching initialiser. The class overrides `init(frame:)`, so it does not inherit `UIView`'s no-argument initialiser; the call is now `ZoomableImageView(frame: .zero)`. This also cleared the cascading "cannot infer type of closure parameter 'bounds'" error on the `onBoundsChanged` assignment.
|
|
24
|
+
- Two layer-transform handlers used an immediately-applied closure inside a ternary (`{ var layer = $0; ... }()`). The `$0` inside bound to the inner closure's own parameter rather than the enclosing `map`'s element, making it a one-argument closure invoked with none. Both are rewritten as an explicit `map { current in ... }` with a `guard`.
|
|
25
|
+
- Fix an iOS build failure in `PhotoExporter`: `(exportOptions?["maxWidth"] as? NSNumber)?.doubleValue.map { ... }` applied `map` to the non-optional `Double` inside the optional chain rather than to the optional itself. `map` now runs on the optional `NSNumber`, keeping the `CGFloat?` type that `resize(_:maxWidth:maxHeight:)` expects.
|
|
26
|
+
- Fix an iOS build failure in `VideoEditSession`: inside `private extension Int64`, the bare `min`/`max` in `clamped(to:)` resolved to the static properties `Int64.min`/`Int64.max` instead of the global functions. They are now qualified as `Swift.min`/`Swift.max`.
|
|
27
|
+
- Fix an iOS build failure in `PhotoVideoEditor.mm`: the TurboModule class subclassed the Swift `PhotoVideoEditorSwift`, which Objective-C cannot do — Swift emits every class into the generated header with `objc_subclassing_restricted`. The TurboModule now holds a `PhotoVideoEditorSwift` instance and forwards `openEditor`, `cancelExport` and `isAvailable` to it.
|
|
8
28
|
|
|
9
29
|
## 0.2.0
|
|
10
30
|
|
package/README.md
CHANGED
|
@@ -55,3 +55,30 @@ try {
|
|
|
55
55
|
See [API](docs/api.md), [architecture](docs/architecture.md), [photo editor](docs/photo-editor.md), [video editor](docs/video-editor.md), and [roadmap](docs/roadmap.md). Known limitations are non-functional tool placeholders, no transformed export, local-URI-only input, and no verified legacy-architecture support.
|
|
56
56
|
|
|
57
57
|
For local work, run `yarn`, then `yarn typecheck`, `yarn lint`, `yarn test`, and `yarn prepare`. See [CONTRIBUTING.md](CONTRIBUTING.md). MIT licensed; see [LICENSE](LICENSE).
|
|
58
|
+
|
|
59
|
+
To place a sticker at the center automatically when opening either editor, pass
|
|
60
|
+
`initialStickerIds` containing unique IDs from `stickerAssets`:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
await openPhotoEditor({
|
|
64
|
+
source: { uri: photoUri, type: 'photo' },
|
|
65
|
+
stickerAssets: [
|
|
66
|
+
{ id: 'brand', uri: 'https://example.com/brand-sticker.png' },
|
|
67
|
+
{ id: 'alternate', uri: 'https://example.com/alternate.png' },
|
|
68
|
+
],
|
|
69
|
+
initialStickerIds: ['brand', 'alternate'],
|
|
70
|
+
});
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
The first sticker starts at the center of the media and can be moved, resized, or
|
|
74
|
+
removed. The “Switch default sticker” control at the selected default sticker’s top-right
|
|
75
|
+
corner, above its bottom-right scale control, cycles through the IDs in
|
|
76
|
+
order and wraps around, replacing the current default sticker while preserving
|
|
77
|
+
its transforms. Switching is undoable. After deletion, use Undo to restore the sticker and its
|
|
78
|
+
controls. The swap control is disabled for one ID and appears only on the selected
|
|
79
|
+
default sticker. Failed swaps leave the existing sticker in place. The same option works with `openVideoEditor` and `openEditor`, even if
|
|
80
|
+
`features.stickers` hides the sticker picker. Omit it to start without a sticker.
|
|
81
|
+
An unknown or duplicate ID rejects with `E_INVALID_OPTIONS`; an unreadable image
|
|
82
|
+
rejects with `E_SOURCE_UNREADABLE`. HTTPS and local sticker URIs are supported.
|
|
83
|
+
|
|
84
|
+
Migration: replace `initialStickerId: 'brand'` with `initialStickerIds: ['brand']`.
|
|
@@ -185,6 +185,81 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
185
185
|
}
|
|
186
186
|
applyStatusBarStyle()
|
|
187
187
|
setContentView(createEditorView())
|
|
188
|
+
if (!isFinishing) insertInitialSticker()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private val initialStickerIds: List<String> by lazy {
|
|
192
|
+
val ids = request.optJSONArray("initialStickerIds")
|
|
193
|
+
if (ids == null) emptyList() else (0 until ids.length()).map { ids.optString(it) }.filter { it.isNotBlank() }.distinct()
|
|
194
|
+
}
|
|
195
|
+
private val initialStickerLayerId = java.util.UUID.randomUUID().toString()
|
|
196
|
+
private val initialStickerPaths = mutableMapOf<String, String>()
|
|
197
|
+
private var initialStickerLoading = false
|
|
198
|
+
|
|
199
|
+
private fun currentInitialSticker(): PhotoLayer? =
|
|
200
|
+
(photoSession?.layerStack?.layers ?: videoSession?.layerStack?.layers)?.firstOrNull { it.id == initialStickerLayerId }
|
|
201
|
+
|
|
202
|
+
private fun switchInitialSticker() {
|
|
203
|
+
if (initialStickerLoading || initialStickerIds.size < 2) return
|
|
204
|
+
val currentIndex = initialStickerIds.indexOf(currentInitialSticker()?.stickerId)
|
|
205
|
+
insertInitialSticker((currentIndex + 1) % initialStickerIds.size)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private fun insertInitialSticker(index: Int = 0) {
|
|
209
|
+
if (initialStickerLoading) return
|
|
210
|
+
val id = initialStickerIds.getOrNull(index) ?: return
|
|
211
|
+
val asset = runtimeStickerAssets().firstOrNull { it.id == id } ?: return
|
|
212
|
+
initialStickerLoading = true
|
|
213
|
+
window.setFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
|
|
214
|
+
Thread {
|
|
215
|
+
val path = try {
|
|
216
|
+
val localPath = initialStickerPaths[id] ?: if (asset.uri.startsWith("https://", ignoreCase = true)) {
|
|
217
|
+
val output = File.createTempFile("pve_initial_sticker_", ".img", cacheDir)
|
|
218
|
+
val connection = java.net.URL(asset.uri).openConnection().apply {
|
|
219
|
+
connectTimeout = 10_000
|
|
220
|
+
readTimeout = 15_000
|
|
221
|
+
}
|
|
222
|
+
connection.getInputStream().use { input -> output.outputStream().use { input.copyTo(it) } }
|
|
223
|
+
output.absolutePath
|
|
224
|
+
} else SourceResolver.resolvePath(this, asset.uri, "pve_initial_sticker")
|
|
225
|
+
localPath?.takeIf {
|
|
226
|
+
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
227
|
+
BitmapFactory.decodeFile(it, options)
|
|
228
|
+
options.outWidth > 0 && options.outHeight > 0
|
|
229
|
+
}
|
|
230
|
+
} catch (_: Exception) { null }
|
|
231
|
+
runOnUiThread {
|
|
232
|
+
initialStickerLoading = false
|
|
233
|
+
window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
|
|
234
|
+
if (isFinishing || isDestroyed) return@runOnUiThread
|
|
235
|
+
if (path == null) {
|
|
236
|
+
if (initialStickerPaths.isEmpty()) finishWithError("The initial sticker could not be loaded.", "E_SOURCE_UNREADABLE")
|
|
237
|
+
else Toast.makeText(this, "Could not load this sticker. Try again.", Toast.LENGTH_SHORT).show()
|
|
238
|
+
return@runOnUiThread
|
|
239
|
+
}
|
|
240
|
+
initialStickerPaths[id] = path
|
|
241
|
+
window.decorView.post {
|
|
242
|
+
if (isFinishing || isDestroyed) return@post
|
|
243
|
+
val uri = Uri.fromFile(File(path)).toString()
|
|
244
|
+
val fresh = newSticker(stickerId = id, stickerUri = uri)
|
|
245
|
+
val layer = currentInitialSticker()?.copy(stickerId = id, stickerUri = uri, overlayAspectRatio = fresh.overlayAspectRatio)
|
|
246
|
+
?: fresh.copy(id = initialStickerLayerId)
|
|
247
|
+
val replace: (List<PhotoLayer>) -> List<PhotoLayer> = { layers ->
|
|
248
|
+
if (layers.any { it.id == initialStickerLayerId }) layers.map { if (it.id == initialStickerLayerId) layer else it }
|
|
249
|
+
else layers + layer
|
|
250
|
+
}
|
|
251
|
+
if (mediaType == "photo") {
|
|
252
|
+
photoSession?.layerStack?.commit(replace)
|
|
253
|
+
onLayerStackChanged()
|
|
254
|
+
selectLayer(layer.id)
|
|
255
|
+
} else videoSession?.let { session ->
|
|
256
|
+
session.layerStack.commit(replace)
|
|
257
|
+
onVideoLayerStackChanged()
|
|
258
|
+
selectVideoLayer(layer.id, session)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}.start()
|
|
188
263
|
}
|
|
189
264
|
|
|
190
265
|
private fun applyStatusBarStyle() {
|
|
@@ -275,6 +350,9 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
275
350
|
previewContainer.addView(overlay, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
276
351
|
|
|
277
352
|
val layers = LayerOverlayView(this)
|
|
353
|
+
layers.swappableLayerId = initialStickerLayerId
|
|
354
|
+
layers.stickerSwapEnabled = initialStickerIds.size > 1
|
|
355
|
+
layers.onStickerSwap = { switchInitialSticker() }
|
|
278
356
|
layerOverlay = layers
|
|
279
357
|
previewContainer.addView(layers, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
280
358
|
|
|
@@ -1456,6 +1534,9 @@ class PhotoVideoEditorActivity : Activity() {
|
|
|
1456
1534
|
if (videoCropMode) videoCropOverlay.restore(videoMediaBounds(session, playerView, false), session.state.crop)
|
|
1457
1535
|
}
|
|
1458
1536
|
val layerOverlay = LayerOverlayView(this)
|
|
1537
|
+
layerOverlay.swappableLayerId = initialStickerLayerId
|
|
1538
|
+
layerOverlay.stickerSwapEnabled = initialStickerIds.size > 1
|
|
1539
|
+
layerOverlay.onStickerSwap = { switchInitialSticker() }
|
|
1459
1540
|
videoLayerOverlay = layerOverlay
|
|
1460
1541
|
preview.addView(layerOverlay, FrameLayout.LayoutParams(MATCH, MATCH))
|
|
1461
1542
|
layerOverlay.onLayerDelete = { id ->
|
|
@@ -15,6 +15,13 @@ object SourceResolver {
|
|
|
15
15
|
val uri = Uri.parse(sourceUri)
|
|
16
16
|
return when (uri.scheme) {
|
|
17
17
|
"file", null -> uri.path ?: sourceUri
|
|
18
|
+
"data" -> {
|
|
19
|
+
if (!sourceUri.startsWith("data:image/png;base64,")) return null
|
|
20
|
+
val bytes = try {
|
|
21
|
+
android.util.Base64.decode(sourceUri.substringAfter(','), android.util.Base64.DEFAULT)
|
|
22
|
+
} catch (_: IllegalArgumentException) { return null }
|
|
23
|
+
File.createTempFile(tempPrefix, ".png", context.cacheDir).apply { writeBytes(bytes) }.absolutePath
|
|
24
|
+
}
|
|
18
25
|
"content" -> {
|
|
19
26
|
val tempFile = File.createTempFile(tempPrefix, ".tmp", context.cacheDir)
|
|
20
27
|
val copied = context.contentResolver.openInputStream(uri)?.use { input ->
|
|
@@ -28,6 +28,11 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
28
28
|
set(value) { field = value; invalidate() }
|
|
29
29
|
var selectedLayerId: String? = null
|
|
30
30
|
set(value) { field = value; invalidate() }
|
|
31
|
+
var swappableLayerId: String? = null
|
|
32
|
+
set(value) { field = value; invalidate() }
|
|
33
|
+
var stickerSwapEnabled = false
|
|
34
|
+
set(value) { field = value; invalidate() }
|
|
35
|
+
var onStickerSwap: (() -> Unit)? = null
|
|
31
36
|
var onLayerDelete: ((String) -> Unit)? = null
|
|
32
37
|
var onLayerTapped: ((String?) -> Unit)? = null
|
|
33
38
|
var onLayerDoubleTapped: ((String) -> Unit)? = null
|
|
@@ -71,6 +76,10 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
71
76
|
}
|
|
72
77
|
drawControl(canvas, -half.first, -half.second, R.drawable.ic_close, Color.rgb(255, 100, 115))
|
|
73
78
|
drawControl(canvas, half.first, half.second, R.drawable.ic_fullscreen, Color.rgb(167, 139, 250))
|
|
79
|
+
if (selected.type == LayerType.STICKER && selected.id == swappableLayerId) {
|
|
80
|
+
drawControl(canvas, half.first, -half.second, R.drawable.ic_swap_sticker,
|
|
81
|
+
if (stickerSwapEnabled && !selected.locked) Color.rgb(167, 139, 250) else Color.GRAY)
|
|
82
|
+
}
|
|
74
83
|
canvas.restore()
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -92,6 +101,17 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
92
101
|
if (selected != null && !selected.locked) {
|
|
93
102
|
val handle = handlePoint(selected)
|
|
94
103
|
val center = centerOf(selected)
|
|
104
|
+
if (selected.type == LayerType.STICKER && selected.id == swappableLayerId) {
|
|
105
|
+
val swap = swapPoint(selected)
|
|
106
|
+
val swapDistance = hypot(event.x - swap.first, event.y - swap.second)
|
|
107
|
+
val deleteDistance = hypot(event.x - (2 * center.first - handle.first), event.y - (2 * center.second - handle.second))
|
|
108
|
+
val scaleDistance = hypot(event.x - handle.first, event.y - handle.second)
|
|
109
|
+
if (swapDistance <= handleTouchRadius && swapDistance <= deleteDistance && swapDistance <= scaleDistance) {
|
|
110
|
+
resetGesture()
|
|
111
|
+
if (stickerSwapEnabled) onStickerSwap?.invoke()
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
}
|
|
95
115
|
if (hypot(event.x - (2 * center.first - handle.first), event.y - (2 * center.second - handle.second)) <= handleTouchRadius) {
|
|
96
116
|
onLayerDelete?.invoke(selected.id); resetGesture(); return true
|
|
97
117
|
}
|
|
@@ -225,6 +245,12 @@ class LayerOverlayView(context: Context) : View(context) {
|
|
|
225
245
|
return Pair(center.first + (half.first * cos(radians) - half.second * sin(radians)).toFloat(), center.second + (half.first * sin(radians) + half.second * cos(radians)).toFloat())
|
|
226
246
|
}
|
|
227
247
|
|
|
248
|
+
private fun swapPoint(layer: PhotoLayer): Pair<Float, Float> {
|
|
249
|
+
val center = centerOf(layer); val half = selectionHalfExtents(layer)
|
|
250
|
+
val radians = Math.toRadians(layer.rotationDegrees.toDouble())
|
|
251
|
+
return Pair(center.first + (half.first * cos(radians) + half.second * sin(radians)).toFloat(), center.second + (half.first * sin(radians) - half.second * cos(radians)).toFloat())
|
|
252
|
+
}
|
|
253
|
+
|
|
228
254
|
private fun isHandleHit(layer: PhotoLayer, x: Float, y: Float): Boolean {
|
|
229
255
|
val handle = handlePoint(layer)
|
|
230
256
|
return hypot(x - handle.first, y - handle.second) <= handleTouchRadius
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
|
|
2
|
+
<path android:fillColor="#FFFFFFFF" android:pathData="M7,7h11l-3,-3 1.4,-1.4L22,8l-5.6,5.4L15,12l3,-3H7zM17,17H6l3,3 -1.4,1.4L2,16l5.6,-5.4L9,12l-3,3h11z" />
|
|
3
|
+
</vector>
|