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.
@@ -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
- guard let self else { return }
1782
- DispatchQueue.main.async {
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 = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
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 = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
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 = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
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 = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
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 = Int64(CMTimeGetSeconds(session.player.currentTime()) * 1000)
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?.dismiss(animated: true) {
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
- DispatchQueue.main.async { presenter.present(controller, animated: true) }
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 collectionView] _, image in
299
- guard collectionView?.indexPath(for: cell) == indexPath else { return }
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: Int64(durationSeconds * 1000))]
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 = Int64(CMTimeGetSeconds(player.currentTime()) * 1000)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-photo-video-editor",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "Native photo and video editor for React Native, powered by Kotlin and Swift.",
5
5
  "main": "./lib/module/index.js",
6
6
  "module": "./lib/module/index.js",