react-native-photo-video-editor 0.3.4 → 0.3.6
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/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +13 -0
- package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +13 -0
- package/ios/Editor/PhotoVideoEditorViewController.swift +9 -8
- package/ios/PhotoVideoEditor.swift +15 -4
- package/ios/Stickers/OnlineStickerSheetViewController.swift +8 -5
- package/ios/Video/Render/VideoEditSession.swift +18 -3
- package/package.json +1 -1
|
@@ -126,6 +126,19 @@ object PhotoExporter {
|
|
|
126
126
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
|
127
127
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
|
128
128
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
|
129
|
+
// Mirrored-and-rotated: what a front-facing camera writes for a portrait
|
|
130
|
+
// shot (CameraX marks the capture as reversed-horizontal, which combines
|
|
131
|
+
// with the 90/270 sensor rotation into TRANSPOSE/TRANSVERSE rather than
|
|
132
|
+
// a plain ROTATE_90/270). Falling through to `else` here left those
|
|
133
|
+
// photos unrotated in the editor.
|
|
134
|
+
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
135
|
+
matrix.postRotate(90f)
|
|
136
|
+
matrix.postScale(-1f, 1f)
|
|
137
|
+
}
|
|
138
|
+
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
139
|
+
matrix.postRotate(270f)
|
|
140
|
+
matrix.postScale(-1f, 1f)
|
|
141
|
+
}
|
|
129
142
|
else -> return bitmap
|
|
130
143
|
}
|
|
131
144
|
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
|
|
@@ -112,6 +112,19 @@ class PhotoEditSession(private val context: Context, sourceUri: String, maxPrevi
|
|
|
112
112
|
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
|
113
113
|
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
|
114
114
|
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
|
115
|
+
// Mirrored-and-rotated: what a front-facing camera writes for a portrait
|
|
116
|
+
// shot (CameraX marks the capture as reversed-horizontal, which combines
|
|
117
|
+
// with the 90/270 sensor rotation into TRANSPOSE/TRANSVERSE rather than
|
|
118
|
+
// a plain ROTATE_90/270). Falling through to `else` here left those
|
|
119
|
+
// photos unrotated in the editor.
|
|
120
|
+
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
|
121
|
+
matrix.postRotate(90f)
|
|
122
|
+
matrix.postScale(-1f, 1f)
|
|
123
|
+
}
|
|
124
|
+
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
|
125
|
+
matrix.postRotate(270f)
|
|
126
|
+
matrix.postScale(-1f, 1f)
|
|
127
|
+
}
|
|
115
128
|
else -> return decoded
|
|
116
129
|
}
|
|
117
130
|
return Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true)
|
|
@@ -1777,9 +1777,9 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1777
1777
|
])
|
|
1778
1778
|
clipStripScroll = stripScroll
|
|
1779
1779
|
|
|
1780
|
-
session.onClipsReady = { [weak self] in
|
|
1781
|
-
|
|
1782
|
-
|
|
1780
|
+
session.onClipsReady = { [weak self, weak session] in
|
|
1781
|
+
DispatchQueue.main.async { [weak self, weak session] in
|
|
1782
|
+
guard let self, let session else { return }
|
|
1783
1783
|
self.selectedClipIndex = 0
|
|
1784
1784
|
self.updateTimeLabel()
|
|
1785
1785
|
self.bindTrimViewToSelectedClip(session: session)
|
|
@@ -1907,7 +1907,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1907
1907
|
positionPollTimer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { [weak self] _ in
|
|
1908
1908
|
guard let self, let session = self.videoSession else { return }
|
|
1909
1909
|
if !self.seekingPosition, session.durationMs > 0 {
|
|
1910
|
-
let currentMs =
|
|
1910
|
+
let currentMs = session.currentPositionMs
|
|
1911
1911
|
self.positionSlider?.value = Float(min(max(Double(currentMs) / Double(session.durationMs), 0), 1)) * 1000
|
|
1912
1912
|
self.updateTimeLabel()
|
|
1913
1913
|
}
|
|
@@ -1951,7 +1951,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1951
1951
|
|
|
1952
1952
|
private func updateTimeLabel() {
|
|
1953
1953
|
guard let session = videoSession else { return }
|
|
1954
|
-
let currentMs =
|
|
1954
|
+
let currentMs = session.currentPositionMs
|
|
1955
1955
|
timeLabel?.text = "\(formatMs(currentMs)) / \(formatMs(session.durationMs))"
|
|
1956
1956
|
}
|
|
1957
1957
|
|
|
@@ -1964,7 +1964,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
1964
1964
|
/// transparent image sized to the video's own natural size. Reuses
|
|
1965
1965
|
/// `PhotoLayerRenderer` unchanged — overlays are just `PhotoLayer`s with a time range.
|
|
1966
1966
|
private func refreshVideoOverlayPreview(session: VideoEditSession) {
|
|
1967
|
-
let positionMs =
|
|
1967
|
+
let positionMs = session.currentPositionMs
|
|
1968
1968
|
let activeLayers = session.layerStack.layers.filter { $0.isActive(atMs: positionMs, durationMs: session.durationMs) }
|
|
1969
1969
|
if let overlayView = videoOverlayImageView {
|
|
1970
1970
|
videoLayerOverlay?.layers = activeLayers
|
|
@@ -2305,7 +2305,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2305
2305
|
case "crop":
|
|
2306
2306
|
setVideoCropMode(true, session: session)
|
|
2307
2307
|
case "cover":
|
|
2308
|
-
let atMs =
|
|
2308
|
+
let atMs = session.currentPositionMs
|
|
2309
2309
|
session.update { $0.coverFrameMs = atMs }
|
|
2310
2310
|
showToast("Cover frame set at \(formatMs(atMs))")
|
|
2311
2311
|
case "rotate":
|
|
@@ -2496,7 +2496,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2496
2496
|
guard let session = videoSession, let clip = session.clips[safe: selectedClipIndex] else { return }
|
|
2497
2497
|
let index = selectedClipIndex
|
|
2498
2498
|
let precedingMs = session.clips.prefix(index).reduce(Int64(0)) { $0 + $1.trimmedDurationMs() }
|
|
2499
|
-
let globalMs =
|
|
2499
|
+
let globalMs = session.currentPositionMs
|
|
2500
2500
|
let splitWithinClipMs = min(max(globalMs - precedingMs, 0), clip.trimmedDurationMs())
|
|
2501
2501
|
let splitSourceMs = clip.trimStartMs + splitWithinClipMs
|
|
2502
2502
|
guard splitSourceMs > clip.trimStartMs + 50, splitSourceMs < clip.effectiveTrimEndMs() - 50 else {
|
|
@@ -2789,6 +2789,7 @@ final class PhotoVideoEditorViewController: UIViewController {
|
|
|
2789
2789
|
|
|
2790
2790
|
deinit {
|
|
2791
2791
|
positionPollTimer?.invalidate()
|
|
2792
|
+
hideVideoControlsTimer?.invalidate()
|
|
2792
2793
|
timelineThumbnailRequest?.cancel()
|
|
2793
2794
|
timelineThumbnailRepository?.close()
|
|
2794
2795
|
photoSession?.release()
|
|
@@ -10,6 +10,16 @@ public class PhotoVideoEditorSwift: NSObject {
|
|
|
10
10
|
_ request: String,
|
|
11
11
|
resolve: @escaping RCTPromiseResolveBlock,
|
|
12
12
|
reject: @escaping RCTPromiseRejectBlock
|
|
13
|
+
) {
|
|
14
|
+
DispatchQueue.main.async { [self] in
|
|
15
|
+
presentEditor(request, resolve: resolve, reject: reject)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private func presentEditor(
|
|
20
|
+
_ request: String,
|
|
21
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
22
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
13
23
|
) {
|
|
14
24
|
guard let data = request.data(using: .utf8),
|
|
15
25
|
let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
@@ -36,10 +46,11 @@ public class PhotoVideoEditorSwift: NSObject {
|
|
|
36
46
|
doneButtonText: doneButtonText
|
|
37
47
|
)
|
|
38
48
|
controller.completion = { [weak self, weak controller] outcome in
|
|
39
|
-
guard let self else { return }
|
|
40
|
-
self.editorOpen = false
|
|
41
49
|
DispatchQueue.main.async {
|
|
42
|
-
controller
|
|
50
|
+
guard let self, let controller, controller.completion != nil else { return }
|
|
51
|
+
controller.completion = nil
|
|
52
|
+
controller.dismiss(animated: true) {
|
|
53
|
+
self.editorOpen = false
|
|
43
54
|
switch outcome {
|
|
44
55
|
case .cancelled:
|
|
45
56
|
var result: [String: Any] = ["uri": uri, "type": type, "cancelled": true]
|
|
@@ -60,7 +71,7 @@ public class PhotoVideoEditorSwift: NSObject {
|
|
|
60
71
|
}
|
|
61
72
|
}
|
|
62
73
|
}
|
|
63
|
-
|
|
74
|
+
presenter.present(controller, animated: true)
|
|
64
75
|
}
|
|
65
76
|
|
|
66
77
|
@objc public func cancelExport(_ jobId: String?, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) { resolve(false) }
|
|
@@ -52,6 +52,8 @@ final class OnlineStickerSheetViewController: UIViewController {
|
|
|
52
52
|
view.backgroundColor = DesignTokens.elevatedPanel
|
|
53
53
|
buildUI()
|
|
54
54
|
loadLocalStickers()
|
|
55
|
+
rebuildCategoryChips()
|
|
56
|
+
applyFilter()
|
|
55
57
|
}
|
|
56
58
|
|
|
57
59
|
override func viewDidDisappear(_ animated: Bool) {
|
|
@@ -187,9 +189,6 @@ final class OnlineStickerSheetViewController: UIViewController {
|
|
|
187
189
|
?? hostBundle.url(forResource: "PhotoVideoEditorStickers", withExtension: "bundle")
|
|
188
190
|
guard let bundleURL, let bundle = Bundle(url: bundleURL) else { return }
|
|
189
191
|
localStickerNames = (bundle.urls(forResourcesWithExtension: "png", subdirectory: nil) ?? []).map(\.lastPathComponent).sorted()
|
|
190
|
-
displayedLocalStickerNames = localStickerNames
|
|
191
|
-
rebuildCategoryChips()
|
|
192
|
-
applyFilter()
|
|
193
192
|
}
|
|
194
193
|
|
|
195
194
|
private func localStickerURL(named name: String) -> URL? {
|
|
@@ -294,12 +293,14 @@ extension OnlineStickerSheetViewController: UICollectionViewDataSource, UICollec
|
|
|
294
293
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: Self.cellReuseID, for: indexPath) as! BundledStickerCell
|
|
295
294
|
if indexPath.item < displayedRuntimeStickers.count {
|
|
296
295
|
let sticker = displayedRuntimeStickers[indexPath.item]
|
|
296
|
+
cell.representedStickerURI = sticker.uri
|
|
297
297
|
cell.configure(image: nil)
|
|
298
|
-
loadRuntimeSticker(sticker) { [weak
|
|
299
|
-
guard
|
|
298
|
+
loadRuntimeSticker(sticker) { [weak cell] _, image in
|
|
299
|
+
guard let cell, cell.representedStickerURI == sticker.uri else { return }
|
|
300
300
|
cell.configure(image: image)
|
|
301
301
|
}
|
|
302
302
|
} else {
|
|
303
|
+
cell.representedStickerURI = nil
|
|
303
304
|
let name = displayedLocalStickerNames[indexPath.item - displayedRuntimeStickers.count]
|
|
304
305
|
let image = localStickerURL(named: name).flatMap { UIImage(contentsOfFile: $0.path) }
|
|
305
306
|
cell.configure(image: image)
|
|
@@ -330,6 +331,7 @@ extension OnlineStickerSheetViewController: UICollectionViewDataSource, UICollec
|
|
|
330
331
|
|
|
331
332
|
/// One grid cell for a bundled sticker image.
|
|
332
333
|
private final class BundledStickerCell: UICollectionViewCell {
|
|
334
|
+
var representedStickerURI: String?
|
|
333
335
|
private let imageView = UIImageView()
|
|
334
336
|
|
|
335
337
|
override init(frame: CGRect) {
|
|
@@ -356,6 +358,7 @@ private final class BundledStickerCell: UICollectionViewCell {
|
|
|
356
358
|
|
|
357
359
|
override func prepareForReuse() {
|
|
358
360
|
super.prepareForReuse()
|
|
361
|
+
representedStickerURI = nil
|
|
359
362
|
imageView.image = nil
|
|
360
363
|
}
|
|
361
364
|
}
|
|
@@ -28,6 +28,7 @@ final class VideoEditSession {
|
|
|
28
28
|
private var cachedAsset: AVURLAsset?
|
|
29
29
|
private var statusObservation: NSKeyValueObservation?
|
|
30
30
|
private let sourceUri: String
|
|
31
|
+
private var isReleased = false
|
|
31
32
|
|
|
32
33
|
init(sourceUri: String) {
|
|
33
34
|
self.sourceUri = sourceUri
|
|
@@ -40,14 +41,14 @@ final class VideoEditSession {
|
|
|
40
41
|
statusObservation = probeItem.observe(\.status, options: [.initial, .new]) { [weak self] observedItem, _ in
|
|
41
42
|
guard let self, observedItem.status == .readyToPlay else { return }
|
|
42
43
|
DispatchQueue.main.async { [weak self] in
|
|
43
|
-
guard let self, self.clips.isEmpty else { return }
|
|
44
|
+
guard let self, !self.isReleased, self.clips.isEmpty else { return }
|
|
44
45
|
if let track = asset.tracks(withMediaType: .video).first {
|
|
45
46
|
let size = track.naturalSize.applying(track.preferredTransform)
|
|
46
47
|
self.naturalSize = CGSize(width: max(1, abs(size.width)), height: max(1, abs(size.height)))
|
|
47
48
|
}
|
|
48
49
|
let durationSeconds = CMTimeGetSeconds(asset.duration)
|
|
49
50
|
guard durationSeconds.isFinite, durationSeconds > 0 else { return }
|
|
50
|
-
self.clips = [VideoClip(sourceUri: sourceUri, originalDurationMs:
|
|
51
|
+
self.clips = [VideoClip(sourceUri: sourceUri, originalDurationMs: Self.milliseconds(for: asset.duration))]
|
|
51
52
|
self.rebuildPlayerComposition()
|
|
52
53
|
self.onClipsReady?()
|
|
53
54
|
}
|
|
@@ -58,6 +59,15 @@ final class VideoEditSession {
|
|
|
58
59
|
/// Sum of every clip's trimmed duration — the length of the composed timeline.
|
|
59
60
|
var durationMs: Int64 { clips.reduce(0) { $0 + $1.trimmedDurationMs() } }
|
|
60
61
|
|
|
62
|
+
var currentPositionMs: Int64 { Self.milliseconds(for: player.currentTime()) }
|
|
63
|
+
|
|
64
|
+
static func milliseconds(for time: CMTime) -> Int64 {
|
|
65
|
+
guard time.isNumeric else { return 0 }
|
|
66
|
+
let converted = CMTimeConvertScale(time, timescale: 1000, method: .roundTowardZero)
|
|
67
|
+
guard converted.isNumeric else { return 0 }
|
|
68
|
+
return max(0, converted.value)
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
func update(_ transform: (inout VideoTransformState) -> Void) {
|
|
62
72
|
transform(&state)
|
|
63
73
|
}
|
|
@@ -65,7 +75,7 @@ final class VideoEditSession {
|
|
|
65
75
|
/// Applies `transform` to the clip list and rebuilds the preview composition to match,
|
|
66
76
|
/// preserving the playhead's position on the composed timeline as closely as possible.
|
|
67
77
|
func updateClips(_ transform: ([VideoClip]) -> [VideoClip]) {
|
|
68
|
-
let previousPositionMs =
|
|
78
|
+
let previousPositionMs = currentPositionMs
|
|
69
79
|
clips = transform(clips)
|
|
70
80
|
rebuildPlayerComposition()
|
|
71
81
|
player.seek(to: CMTime(value: previousPositionMs.clamped(to: 0...max(0, durationMs)), timescale: 1000))
|
|
@@ -108,8 +118,13 @@ final class VideoEditSession {
|
|
|
108
118
|
}
|
|
109
119
|
|
|
110
120
|
func release() {
|
|
121
|
+
isReleased = true
|
|
122
|
+
onClipsReady = nil
|
|
111
123
|
statusObservation?.invalidate()
|
|
124
|
+
statusObservation = nil
|
|
112
125
|
player.pause()
|
|
126
|
+
player.replaceCurrentItem(with: nil)
|
|
127
|
+
cachedAsset = nil
|
|
113
128
|
}
|
|
114
129
|
}
|
|
115
130
|
|
package/package.json
CHANGED