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.
Files changed (162) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +101 -0
  3. package/LICENSE +20 -0
  4. package/PhotoVideoEditor.podspec +23 -0
  5. package/README.md +53 -0
  6. package/android/build.gradle +61 -0
  7. package/android/src/main/AndroidManifest.xml +12 -0
  8. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorModule.kt +126 -0
  9. package/android/src/main/java/com/photovideoeditor/PhotoVideoEditorPackage.kt +31 -0
  10. package/android/src/main/java/com/photovideoeditor/editor/PhotoVideoEditorActivity.kt +2521 -0
  11. package/android/src/main/java/com/photovideoeditor/editor/README.md +5 -0
  12. package/android/src/main/java/com/photovideoeditor/files/SourceResolver.kt +29 -0
  13. package/android/src/main/java/com/photovideoeditor/media/BitmapSampling.kt +11 -0
  14. package/android/src/main/java/com/photovideoeditor/model/EditorRequest.kt +3 -0
  15. package/android/src/main/java/com/photovideoeditor/photo/export/PhotoExporter.kt +201 -0
  16. package/android/src/main/java/com/photovideoeditor/photo/render/OverlayGeometry.kt +22 -0
  17. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustmentRenderer.kt +160 -0
  18. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoAdjustments.kt +55 -0
  19. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoEditSession.kt +121 -0
  20. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoFilterPresets.kt +64 -0
  21. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayer.kt +54 -0
  22. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerRenderer.kt +108 -0
  23. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoLayerStack.kt +60 -0
  24. package/android/src/main/java/com/photovideoeditor/photo/render/PhotoTransformState.kt +52 -0
  25. package/android/src/main/java/com/photovideoeditor/photo/ui/CropOverlayView.kt +168 -0
  26. package/android/src/main/java/com/photovideoeditor/photo/ui/FilterThumbnailLoader.kt +57 -0
  27. package/android/src/main/java/com/photovideoeditor/photo/ui/LayerOverlayView.kt +202 -0
  28. package/android/src/main/java/com/photovideoeditor/photo/ui/PhotoPreviewRenderCoordinator.kt +57 -0
  29. package/android/src/main/java/com/photovideoeditor/photo/ui/ZoomableImageView.kt +96 -0
  30. package/android/src/main/java/com/photovideoeditor/stickers/OnlineStickerSheet.kt +310 -0
  31. package/android/src/main/java/com/photovideoeditor/stickers/OpenMojiCatalog.kt +172 -0
  32. package/android/src/main/java/com/photovideoeditor/stickers/RemoteImageLoader.kt +110 -0
  33. package/android/src/main/java/com/photovideoeditor/ui/DesignTokens.kt +93 -0
  34. package/android/src/main/java/com/photovideoeditor/ui/components/EditorComponents.kt +119 -0
  35. package/android/src/main/java/com/photovideoeditor/validation/EditorRequestValidator.kt +11 -0
  36. package/android/src/main/java/com/photovideoeditor/video/cache/TimelineThumbnailRepository.kt +130 -0
  37. package/android/src/main/java/com/photovideoeditor/video/export/VideoExporter.kt +319 -0
  38. package/android/src/main/java/com/photovideoeditor/video/render/VideoClip.kt +20 -0
  39. package/android/src/main/java/com/photovideoeditor/video/render/VideoEditSession.kt +136 -0
  40. package/android/src/main/java/com/photovideoeditor/video/render/VideoThumbnailGenerator.kt +31 -0
  41. package/android/src/main/java/com/photovideoeditor/video/render/VideoTransformState.kt +48 -0
  42. package/android/src/main/java/com/photovideoeditor/video/ui/TrimRangeView.kt +104 -0
  43. package/android/src/main/res/drawable/ic_add.xml +9 -0
  44. package/android/src/main/res/drawable/ic_arrow_back.xml +9 -0
  45. package/android/src/main/res/drawable/ic_aspect_ratio.xml +9 -0
  46. package/android/src/main/res/drawable/ic_auto_fix_high.xml +9 -0
  47. package/android/src/main/res/drawable/ic_check.xml +9 -0
  48. package/android/src/main/res/drawable/ic_chevron_left.xml +9 -0
  49. package/android/src/main/res/drawable/ic_chevron_right.xml +9 -0
  50. package/android/src/main/res/drawable/ic_close.xml +9 -0
  51. package/android/src/main/res/drawable/ic_compare.xml +9 -0
  52. package/android/src/main/res/drawable/ic_content_copy.xml +9 -0
  53. package/android/src/main/res/drawable/ic_content_cut.xml +9 -0
  54. package/android/src/main/res/drawable/ic_crop.xml +9 -0
  55. package/android/src/main/res/drawable/ic_crop_square.xml +9 -0
  56. package/android/src/main/res/drawable/ic_delete.xml +9 -0
  57. package/android/src/main/res/drawable/ic_drag_indicator.xml +9 -0
  58. package/android/src/main/res/drawable/ic_face.xml +9 -0
  59. package/android/src/main/res/drawable/ic_filter_center_focus.xml +9 -0
  60. package/android/src/main/res/drawable/ic_flare.xml +9 -0
  61. package/android/src/main/res/drawable/ic_format_size.xml +9 -0
  62. package/android/src/main/res/drawable/ic_fullscreen.xml +9 -0
  63. package/android/src/main/res/drawable/ic_layers.xml +9 -0
  64. package/android/src/main/res/drawable/ic_light_mode.xml +9 -0
  65. package/android/src/main/res/drawable/ic_lock.xml +9 -0
  66. package/android/src/main/res/drawable/ic_magic_button.xml +9 -0
  67. package/android/src/main/res/drawable/ic_movie.xml +9 -0
  68. package/android/src/main/res/drawable/ic_opacity.xml +9 -0
  69. package/android/src/main/res/drawable/ic_palette.xml +9 -0
  70. package/android/src/main/res/drawable/ic_photo_filter.xml +9 -0
  71. package/android/src/main/res/drawable/ic_redo.xml +9 -0
  72. package/android/src/main/res/drawable/ic_rotate_90_degrees_ccw.xml +9 -0
  73. package/android/src/main/res/drawable/ic_rotate_left.xml +9 -0
  74. package/android/src/main/res/drawable/ic_rotate_right.xml +9 -0
  75. package/android/src/main/res/drawable/ic_sentiment_satisfied.xml +9 -0
  76. package/android/src/main/res/drawable/ic_share.xml +9 -0
  77. package/android/src/main/res/drawable/ic_speed.xml +9 -0
  78. package/android/src/main/res/drawable/ic_straighten.xml +9 -0
  79. package/android/src/main/res/drawable/ic_title.xml +9 -0
  80. package/android/src/main/res/drawable/ic_tune.xml +9 -0
  81. package/android/src/main/res/drawable/ic_undo.xml +9 -0
  82. package/android/src/main/res/drawable/ic_visibility_off.xml +9 -0
  83. package/android/src/main/res/drawable/ic_wallpaper.xml +9 -0
  84. package/android/src/main/res/values/styles.xml +10 -0
  85. package/docs/api.md +9 -0
  86. package/docs/architecture.md +9 -0
  87. package/docs/customization.md +18 -0
  88. package/docs/development.md +21 -0
  89. package/docs/formats.md +27 -0
  90. package/docs/performance.md +3 -0
  91. package/docs/permissions.md +3 -0
  92. package/docs/photo-editor.md +41 -0
  93. package/docs/roadmap.md +12 -0
  94. package/docs/technology-decisions.md +17 -0
  95. package/docs/video-editor.md +76 -0
  96. package/ios/Editor/DesignTokens.swift +75 -0
  97. package/ios/Editor/PhotoVideoEditorOutcome.swift +9 -0
  98. package/ios/Editor/PhotoVideoEditorViewController.swift +2064 -0
  99. package/ios/Editor/README.md +5 -0
  100. package/ios/Editor/UI/EditorComponents.swift +138 -0
  101. package/ios/Files/SourceResolver.swift +13 -0
  102. package/ios/Models/EditorRequest.swift +3 -0
  103. package/ios/Photo/Export/PhotoExporter.swift +144 -0
  104. package/ios/Photo/Rendering/OverlayGeometry.swift +22 -0
  105. package/ios/Photo/Rendering/PhotoAdjustmentRenderer.swift +141 -0
  106. package/ios/Photo/Rendering/PhotoAdjustments.swift +57 -0
  107. package/ios/Photo/Rendering/PhotoEditSession.swift +121 -0
  108. package/ios/Photo/Rendering/PhotoFilterPresets.swift +45 -0
  109. package/ios/Photo/Rendering/PhotoLayer.swift +59 -0
  110. package/ios/Photo/Rendering/PhotoLayerRenderer.swift +94 -0
  111. package/ios/Photo/Rendering/PhotoLayerStack.swift +60 -0
  112. package/ios/Photo/Rendering/PhotoTransformState.swift +49 -0
  113. package/ios/Photo/Rendering/PixelBuffer.swift +68 -0
  114. package/ios/Photo/UI/CropOverlayView.swift +161 -0
  115. package/ios/Photo/UI/LayerOverlayView.swift +176 -0
  116. package/ios/Photo/UI/ZoomableImageView.swift +79 -0
  117. package/ios/PhotoVideoEditor.mm +20 -0
  118. package/ios/PhotoVideoEditor.swift +70 -0
  119. package/ios/Stickers/OnlineStickerSheetViewController.swift +418 -0
  120. package/ios/Stickers/OpenMojiCatalog.swift +188 -0
  121. package/ios/Stickers/RemoteImageLoader.swift +127 -0
  122. package/ios/Validation/EditorRequestValidator.swift +9 -0
  123. package/ios/Video/Cache/TimelineThumbnailRepository.swift +148 -0
  124. package/ios/Video/Export/VideoExporter.swift +275 -0
  125. package/ios/Video/Render/VideoClip.swift +21 -0
  126. package/ios/Video/Render/VideoEditSession.swift +99 -0
  127. package/ios/Video/Render/VideoThumbnailGenerator.swift +25 -0
  128. package/ios/Video/Render/VideoTransformState.swift +23 -0
  129. package/ios/Video/UI/TrimRangeView.swift +91 -0
  130. package/lib/module/NativePhotoVideoEditor.js +5 -0
  131. package/lib/module/NativePhotoVideoEditor.js.map +1 -0
  132. package/lib/module/errors.js +29 -0
  133. package/lib/module/errors.js.map +1 -0
  134. package/lib/module/events.js +27 -0
  135. package/lib/module/events.js.map +1 -0
  136. package/lib/module/index.js +91 -0
  137. package/lib/module/index.js.map +1 -0
  138. package/lib/module/package.json +1 -0
  139. package/lib/module/project.js +4 -0
  140. package/lib/module/project.js.map +1 -0
  141. package/lib/module/types.js +2 -0
  142. package/lib/module/types.js.map +1 -0
  143. package/lib/typescript/package.json +1 -0
  144. package/lib/typescript/src/NativePhotoVideoEditor.d.ts +11 -0
  145. package/lib/typescript/src/NativePhotoVideoEditor.d.ts.map +1 -0
  146. package/lib/typescript/src/errors.d.ts +11 -0
  147. package/lib/typescript/src/errors.d.ts.map +1 -0
  148. package/lib/typescript/src/events.d.ts +5 -0
  149. package/lib/typescript/src/events.d.ts.map +1 -0
  150. package/lib/typescript/src/index.d.ts +15 -0
  151. package/lib/typescript/src/index.d.ts.map +1 -0
  152. package/lib/typescript/src/project.d.ts +24 -0
  153. package/lib/typescript/src/project.d.ts.map +1 -0
  154. package/lib/typescript/src/types.d.ts +108 -0
  155. package/lib/typescript/src/types.d.ts.map +1 -0
  156. package/package.json +154 -0
  157. package/src/NativePhotoVideoEditor.ts +12 -0
  158. package/src/errors.ts +78 -0
  159. package/src/events.ts +43 -0
  160. package/src/index.ts +123 -0
  161. package/src/project.ts +17 -0
  162. package/src/types.ts +121 -0
@@ -0,0 +1,127 @@
1
+ import UIKit
2
+
3
+ /// Small async image loader for the online-sticker sheet: in-memory + on-disk thumbnail cache,
4
+ /// plus a full-res "download and hand me a local file" path for the sticker that actually gets
5
+ /// inserted as a layer. Plain `URLSession`, no third-party dependency (matches the rest of the
6
+ /// codebase's zero-networking-dependency policy).
7
+ final class RemoteImageLoader {
8
+ static let shared = RemoteImageLoader()
9
+
10
+ private let memoryCache = NSCache<NSString, UIImage>()
11
+ private let ioQueue = DispatchQueue(label: "com.photovideoeditor.remoteimageloader.io", qos: .userInitiated)
12
+ private let session = URLSession.shared
13
+
14
+ private init() {
15
+ memoryCache.countLimit = 500
16
+ }
17
+
18
+ private var diskCacheDirectory: URL? {
19
+ guard let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
20
+ let dir = base.appendingPathComponent("openmoji", isDirectory: true)
21
+ if !FileManager.default.fileExists(atPath: dir.path) {
22
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
23
+ }
24
+ return dir
25
+ }
26
+
27
+ private func diskCacheFile(forKey key: String) -> URL? {
28
+ diskCacheDirectory?.appendingPathComponent(key)
29
+ }
30
+
31
+ // MARK: - Thumbnails (UICollectionViewCell-safe)
32
+
33
+ /// Loads a thumbnail into `imageView`, guarding against cell reuse: the URL being loaded is
34
+ /// stamped onto the image view via associated object, and the result is only applied if that
35
+ /// stamp still matches once the load completes (standard fix for the "wrong image after scroll"
36
+ /// bug with recycled `UICollectionViewCell`/`UITableViewCell` image views).
37
+ func loadThumbnail(url: URL, into imageView: UIImageView) {
38
+ Self.setLoadTag(url.absoluteString, on: imageView)
39
+ imageView.image = nil
40
+
41
+ let cacheKey = url.absoluteString as NSString
42
+ if let cached = memoryCache.object(forKey: cacheKey) {
43
+ imageView.image = cached
44
+ return
45
+ }
46
+
47
+ let diskKey = "\(url.lastPathComponent)_thumb.png"
48
+ ioQueue.async { [weak self] in
49
+ guard let self else { return }
50
+ if let diskURL = self.diskCacheFile(forKey: diskKey),
51
+ let data = try? Data(contentsOf: diskURL),
52
+ let image = UIImage(data: data) {
53
+ self.memoryCache.setObject(image, forKey: cacheKey)
54
+ DispatchQueue.main.async {
55
+ guard Self.loadTag(on: imageView) == url.absoluteString else { return }
56
+ imageView.image = image
57
+ }
58
+ return
59
+ }
60
+
61
+ self.session.dataTask(with: url) { data, response, _ in
62
+ guard
63
+ let data, let image = UIImage(data: data),
64
+ let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode)
65
+ else { return }
66
+ self.memoryCache.setObject(image, forKey: cacheKey)
67
+ if let diskURL = self.diskCacheFile(forKey: diskKey) {
68
+ try? data.write(to: diskURL, options: .atomic)
69
+ }
70
+ DispatchQueue.main.async {
71
+ guard Self.loadTag(on: imageView) == url.absoluteString else { return }
72
+ imageView.image = image
73
+ }
74
+ }.resume()
75
+ }
76
+ }
77
+
78
+ // MARK: - Full-res download
79
+
80
+ /// Downloads (or returns the already-cached file for) the full-res sticker image. Completion
81
+ /// always fires on the main thread.
82
+ func downloadFull(url: URL, completion: @escaping (URL?, Error?) -> Void) {
83
+ let diskKey = "\(url.lastPathComponent)_full.png"
84
+ ioQueue.async { [weak self] in
85
+ guard let self else { return }
86
+ if let diskURL = self.diskCacheFile(forKey: diskKey), FileManager.default.fileExists(atPath: diskURL.path) {
87
+ DispatchQueue.main.async { completion(diskURL, nil) }
88
+ return
89
+ }
90
+ self.session.dataTask(with: url) { data, response, error in
91
+ if let error {
92
+ DispatchQueue.main.async { completion(nil, error) }
93
+ return
94
+ }
95
+ guard
96
+ let data,
97
+ let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode)
98
+ else {
99
+ DispatchQueue.main.async { completion(nil, URLError(.badServerResponse)) }
100
+ return
101
+ }
102
+ guard let diskURL = self.diskCacheFile(forKey: diskKey) else {
103
+ DispatchQueue.main.async { completion(nil, URLError(.cannotCreateFile)) }
104
+ return
105
+ }
106
+ do {
107
+ try data.write(to: diskURL, options: .atomic)
108
+ DispatchQueue.main.async { completion(diskURL, nil) }
109
+ } catch {
110
+ DispatchQueue.main.async { completion(nil, error) }
111
+ }
112
+ }.resume()
113
+ }
114
+ }
115
+
116
+ // MARK: - Cell-reuse tagging
117
+
118
+ private static var loadTagKey: UInt8 = 0
119
+
120
+ private static func setLoadTag(_ tag: String, on imageView: UIImageView) {
121
+ objc_setAssociatedObject(imageView, &loadTagKey, tag, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
122
+ }
123
+
124
+ private static func loadTag(on imageView: UIImageView) -> String? {
125
+ objc_getAssociatedObject(imageView, &loadTagKey) as? String
126
+ }
127
+ }
@@ -0,0 +1,9 @@
1
+ import Foundation
2
+
3
+ enum EditorRequestValidator {
4
+ static func errorCode(for request: EditorRequest) -> String? {
5
+ if request.uri.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return "E_INVALID_URI" }
6
+ if request.mediaType != "photo" && request.mediaType != "video" { return "E_UNSUPPORTED_MEDIA_TYPE" }
7
+ return nil
8
+ }
9
+ }
@@ -0,0 +1,148 @@
1
+ import AVFoundation
2
+ import CryptoKit
3
+ import UIKit
4
+
5
+ /// Bounded memory -> disk -> decoder pipeline for video timeline thumbnails.
6
+ final class TimelineThumbnailRepository {
7
+ final class Request {
8
+ private let lock = NSLock()
9
+ private var cancelled = false
10
+
11
+ func cancel() {
12
+ lock.lock()
13
+ cancelled = true
14
+ lock.unlock()
15
+ }
16
+
17
+ fileprivate var isCancelled: Bool {
18
+ lock.lock()
19
+ defer { lock.unlock() }
20
+ return cancelled
21
+ }
22
+ }
23
+
24
+ private let memory = NSCache<NSString, UIImage>()
25
+ private let decodeQueue: OperationQueue = {
26
+ let queue = OperationQueue()
27
+ queue.name = "com.photovideoeditor.timeline-thumbnails"
28
+ queue.qualityOfService = .utility
29
+ queue.maxConcurrentOperationCount = 2
30
+ return queue
31
+ }()
32
+ private let diskQueue = DispatchQueue(label: "com.photovideoeditor.timeline-thumbnail-disk")
33
+ private let cacheDirectory: URL
34
+ private let maxDiskBytes: Int64
35
+
36
+ init(maxDiskBytes: Int64 = 96 * 1024 * 1024) {
37
+ self.maxDiskBytes = maxDiskBytes
38
+ let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
39
+ cacheDirectory = base.appendingPathComponent("pve_timeline_thumbnails", isDirectory: true)
40
+ try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
41
+ memory.totalCostLimit = min(64 * 1024 * 1024, max(16 * 1024 * 1024, Int(ProcessInfo.processInfo.physicalMemory / 16)))
42
+ memory.countLimit = 96
43
+ }
44
+
45
+ @discardableResult
46
+ func load(
47
+ mediaID: String,
48
+ asset: AVAsset,
49
+ durationMs: Int64,
50
+ count: Int = 12,
51
+ size: CGSize,
52
+ onThumbnail: @escaping (_ index: Int, _ image: UIImage) -> Void,
53
+ onComplete: @escaping () -> Void
54
+ ) -> Request {
55
+ let request = Request()
56
+ let safeCount = min(24, max(1, count))
57
+ let pixelSize = CGSize(width: max(1, size.width), height: max(1, size.height))
58
+ decodeQueue.addOperation { [weak self] in
59
+ guard let self else { return }
60
+ let generator = AVAssetImageGenerator(asset: asset)
61
+ generator.appliesPreferredTrackTransform = true
62
+ generator.maximumSize = pixelSize
63
+ generator.requestedTimeToleranceBefore = CMTime(value: 100, timescale: 1000)
64
+ generator.requestedTimeToleranceAfter = CMTime(value: 100, timescale: 1000)
65
+ let step = max(1, durationMs) / Int64(safeCount)
66
+
67
+ for index in 0..<safeCount {
68
+ if request.isCancelled { break }
69
+ autoreleasepool {
70
+ let timestampMs = Int64(index) * step + step / 2
71
+ let key = self.key(mediaID: mediaID, timestampMs: timestampMs, size: pixelSize)
72
+ let image = self.memory.object(forKey: key as NSString)
73
+ ?? self.readDisk(key: key)
74
+ ?? self.decode(generator: generator, timestampMs: timestampMs)
75
+ guard let image, !request.isCancelled else { return }
76
+ self.memory.setObject(image, forKey: key as NSString, cost: image.memoryCost)
77
+ self.writeDiskIfNeeded(key: key, image: image)
78
+ DispatchQueue.main.async {
79
+ if !request.isCancelled { onThumbnail(index, image) }
80
+ }
81
+ }
82
+ }
83
+ if !request.isCancelled { DispatchQueue.main.async { onComplete() } }
84
+ self.diskQueue.async { self.trimDisk() }
85
+ }
86
+ return request
87
+ }
88
+
89
+ func close() {
90
+ decodeQueue.cancelAllOperations()
91
+ memory.removeAllObjects()
92
+ }
93
+
94
+ func clearMemory() {
95
+ memory.removeAllObjects()
96
+ }
97
+
98
+ private func decode(generator: AVAssetImageGenerator, timestampMs: Int64) -> UIImage? {
99
+ guard let cgImage = try? generator.copyCGImage(
100
+ at: CMTime(value: timestampMs, timescale: 1000),
101
+ actualTime: nil
102
+ ) else { return nil }
103
+ return UIImage(cgImage: cgImage)
104
+ }
105
+
106
+ private func readDisk(key: String) -> UIImage? {
107
+ let url = cacheDirectory.appendingPathComponent(key).appendingPathExtension("jpg")
108
+ guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), let image = UIImage(data: data) else { return nil }
109
+ try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
110
+ return image
111
+ }
112
+
113
+ private func writeDiskIfNeeded(key: String, image: UIImage) {
114
+ let url = cacheDirectory.appendingPathComponent(key).appendingPathExtension("jpg")
115
+ guard !FileManager.default.fileExists(atPath: url.path), let data = image.jpegData(compressionQuality: 0.82) else { return }
116
+ try? data.write(to: url, options: .atomic)
117
+ }
118
+
119
+ private func trimDisk() {
120
+ let keys: Set<URLResourceKey> = [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey]
121
+ guard let files = try? FileManager.default.contentsOfDirectory(
122
+ at: cacheDirectory,
123
+ includingPropertiesForKeys: Array(keys),
124
+ options: [.skipsHiddenFiles]
125
+ ) else { return }
126
+ let entries = files.compactMap { url -> (URL, Date, Int64)? in
127
+ guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { return nil }
128
+ return (url, values.contentModificationDate ?? .distantPast, Int64(values.fileSize ?? 0))
129
+ }.sorted { $0.1 < $1.1 }
130
+ var bytes = entries.reduce(Int64(0)) { $0 + $1.2 }
131
+ for entry in entries where bytes > maxDiskBytes {
132
+ try? FileManager.default.removeItem(at: entry.0)
133
+ bytes -= entry.2
134
+ }
135
+ }
136
+
137
+ private func key(mediaID: String, timestampMs: Int64, size: CGSize) -> String {
138
+ let digest = SHA256.hash(data: Data(mediaID.utf8)).prefix(12).map { String(format: "%02x", $0) }.joined()
139
+ return "timeline_\(digest)_\(timestampMs)_\(Int(size.width))x\(Int(size.height))"
140
+ }
141
+ }
142
+
143
+ private extension UIImage {
144
+ var memoryCost: Int {
145
+ guard let image = cgImage else { return 1 }
146
+ return image.bytesPerRow * image.height
147
+ }
148
+ }
@@ -0,0 +1,275 @@
1
+ import AVFoundation
2
+ import QuartzCore
3
+ import UIKit
4
+
5
+ struct VideoExportResult {
6
+ let uri: String
7
+ let width: Int
8
+ let height: Int
9
+ let durationMs: Int64
10
+ let fileSize: Int
11
+ let mimeType: String
12
+ }
13
+
14
+ struct VideoExportError: Error {
15
+ let code: String
16
+ let message: String
17
+ }
18
+
19
+ /// Wraps AVFoundation to apply trim/mute/rotate/flip to the full-resolution
20
+ /// source video and export an H.264/AAC MP4. The source file is never
21
+ /// modified.
22
+ ///
23
+ /// Milestone 7 (multi-clip timeline): each `VideoClip`'s trimmed range is
24
+ /// inserted into the same export `AVMutableComposition` back-to-back via
25
+ /// `insertTimeRange(_:of:at:)`, mirroring exactly what `VideoEditSession`
26
+ /// already does for gapless preview — `AVMutableComposition` supports
27
+ /// multi-segment concatenation natively, so no Media3-style multi-item
28
+ /// `Composition`/`EditedMediaItemSequence` equivalent is needed here.
29
+ ///
30
+ final class VideoExporter {
31
+ private var exportSession: AVAssetExportSession?
32
+ private var progressTimer: Timer?
33
+ private var outputURL: URL?
34
+
35
+ func export(
36
+ clips: [VideoClip],
37
+ assetProvider: (VideoClip) -> AVURLAsset,
38
+ state: VideoTransformState,
39
+ layers: [PhotoLayer],
40
+ exportOptions: [String: Any]?,
41
+ onProgress: @escaping (Float) -> Void,
42
+ onComplete: @escaping (VideoExportResult) -> Void,
43
+ onError: @escaping (VideoExportError) -> Void
44
+ ) {
45
+ guard !clips.isEmpty else {
46
+ onError(VideoExportError(code: "E_SOURCE_NOT_FOUND", message: "There are no clips to export."))
47
+ return
48
+ }
49
+
50
+ let composition = AVMutableComposition()
51
+ guard let compositionVideoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
52
+ onError(VideoExportError(code: "E_INTERNAL", message: "Unable to build the export composition."))
53
+ return
54
+ }
55
+ let compositionAudioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
56
+
57
+ var sourceVideoTrack: AVAssetTrack?
58
+ var cursor = CMTime.zero
59
+ for clip in clips {
60
+ let clipAsset = assetProvider(clip)
61
+ guard let clipVideoTrack = clipAsset.tracks(withMediaType: .video).first else {
62
+ onError(VideoExportError(code: "E_SOURCE_UNREADABLE", message: "A clip's source video could not be decoded."))
63
+ return
64
+ }
65
+ if sourceVideoTrack == nil { sourceVideoTrack = clipVideoTrack }
66
+ let range = CMTimeRange(
67
+ start: CMTime(value: clip.trimStartMs, timescale: 1000),
68
+ end: CMTime(value: clip.effectiveTrimEndMs(), timescale: 1000)
69
+ )
70
+ do {
71
+ try compositionVideoTrack.insertTimeRange(range, of: clipVideoTrack, at: cursor)
72
+ } catch {
73
+ onError(VideoExportError(code: "E_EXPORT_FAILED", message: "Unable to trim the video."))
74
+ return
75
+ }
76
+ if let compositionAudioTrack, let clipAudioTrack = clipAsset.tracks(withMediaType: .audio).first {
77
+ try? compositionAudioTrack.insertTimeRange(range, of: clipAudioTrack, at: cursor)
78
+ }
79
+ cursor = CMTimeAdd(cursor, range.duration)
80
+ }
81
+ let timeRange = CMTimeRange(start: .zero, duration: cursor)
82
+ let durationMs = clips.reduce(Int64(0)) { $0 + $1.trimmedDurationMs() }
83
+
84
+ guard let sourceVideoTrack else {
85
+ onError(VideoExportError(code: "E_SOURCE_UNREADABLE", message: "The selected video could not be decoded."))
86
+ return
87
+ }
88
+
89
+ let naturalSize = sourceVideoTrack.naturalSize
90
+ let baseTransform = sourceVideoTrack.preferredTransform
91
+ let uprightSize = naturalSize.applying(baseTransform)
92
+ let uprightWidth = abs(uprightSize.width)
93
+ let uprightHeight = abs(uprightSize.height)
94
+
95
+ var transform = baseTransform
96
+
97
+ let normalizedRotation = ((state.rotationDegrees % 360) + 360) % 360
98
+ var renderWidth = uprightWidth
99
+ var renderHeight = uprightHeight
100
+ if normalizedRotation != 0 {
101
+ let radians = CGFloat(normalizedRotation) * .pi / 180
102
+ transform = transform.concatenating(CGAffineTransform(rotationAngle: radians))
103
+ switch normalizedRotation {
104
+ case 90:
105
+ transform = transform.concatenating(CGAffineTransform(translationX: uprightHeight, y: 0))
106
+ renderWidth = uprightHeight
107
+ renderHeight = uprightWidth
108
+ case 180:
109
+ transform = transform.concatenating(CGAffineTransform(translationX: uprightWidth, y: uprightHeight))
110
+ case 270:
111
+ transform = transform.concatenating(CGAffineTransform(translationX: 0, y: uprightWidth))
112
+ renderWidth = uprightHeight
113
+ renderHeight = uprightWidth
114
+ default:
115
+ break
116
+ }
117
+ }
118
+
119
+ // Crop around the transformed video's center. The same normalized layer
120
+ // coordinates are then evaluated against this final output rectangle.
121
+ if let targetRatio = state.aspectRatio, targetRatio > 0 {
122
+ let currentRatio = renderWidth / renderHeight
123
+ if currentRatio > targetRatio {
124
+ let croppedWidth = renderHeight * targetRatio
125
+ transform = transform.concatenating(CGAffineTransform(translationX: -(renderWidth - croppedWidth) / 2, y: 0))
126
+ renderWidth = croppedWidth
127
+ } else if currentRatio < targetRatio {
128
+ let croppedHeight = renderWidth / targetRatio
129
+ transform = transform.concatenating(CGAffineTransform(translationX: 0, y: -(renderHeight - croppedHeight) / 2))
130
+ renderHeight = croppedHeight
131
+ }
132
+ }
133
+
134
+ let videoComposition = AVMutableVideoComposition()
135
+ videoComposition.renderSize = CGSize(width: renderWidth, height: renderHeight)
136
+ videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
137
+
138
+ let instruction = AVMutableVideoCompositionInstruction()
139
+ instruction.timeRange = CMTimeRange(start: .zero, duration: timeRange.duration)
140
+ let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
141
+ layerInstruction.setTransform(transform, at: .zero)
142
+ instruction.layerInstructions = [layerInstruction]
143
+ videoComposition.instructions = [instruction]
144
+
145
+ if !layers.isEmpty {
146
+ let renderSize = CGSize(width: renderWidth, height: renderHeight)
147
+ let longestEdge = max(renderSize.width, renderSize.height)
148
+ let overlayScale = min(1, 1280 / max(longestEdge, 1))
149
+ let overlayRenderSize = CGSize(
150
+ width: max(2, floor(renderSize.width * overlayScale)),
151
+ height: max(2, floor(renderSize.height * overlayScale))
152
+ )
153
+ let renderer = PhotoEditSession.pixelRenderer(size: overlayRenderSize)
154
+ let transparentBase = renderer.image { _ in }
155
+ let videoLayer = CALayer()
156
+ videoLayer.frame = CGRect(origin: .zero, size: renderSize)
157
+ let parentLayer = CALayer()
158
+ parentLayer.frame = videoLayer.frame
159
+ parentLayer.addSublayer(videoLayer)
160
+ let durationSeconds = max(CMTimeGetSeconds(timeRange.duration), 0.001)
161
+ let visibleLayers = layers.filter(\.visible)
162
+ // Merge only adjacent layers with identical timing. This collapses the
163
+ // common full-duration stack to one bitmap without changing z-order.
164
+ var timingGroups: [(key: String, layers: [PhotoLayer])] = []
165
+ for layer in visibleLayers {
166
+ let key = "\(max(0, layer.startMs)):\(layer.effectiveEndMs(durationMs: durationMs))"
167
+ if timingGroups.last?.key == key {
168
+ timingGroups[timingGroups.count - 1].layers.append(layer)
169
+ } else {
170
+ timingGroups.append((key, [layer]))
171
+ }
172
+ }
173
+ for entry in timingGroups {
174
+ let group = entry.layers
175
+ guard let representative = group.first else { continue }
176
+ autoreleasepool {
177
+ let image = PhotoLayerRenderer.render(transparentBase, layers: group) { uri in
178
+ SourceResolver.resolvePath(sourceUri: uri, tempPrefix: "pve_video_layer_export").flatMap { UIImage(contentsOfFile: $0) }
179
+ }
180
+ let surface = CALayer()
181
+ surface.frame = parentLayer.bounds
182
+ surface.contents = image.cgImage
183
+ surface.contentsGravity = .resizeAspect
184
+ applyVisibilityTiming(to: surface, layer: representative, durationMs: durationMs, durationSeconds: durationSeconds)
185
+ parentLayer.addSublayer(surface)
186
+ }
187
+ }
188
+ videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(postProcessingAsVideoLayer: videoLayer, in: parentLayer)
189
+ }
190
+
191
+ let outputDir = FileManager.default.temporaryDirectory.appendingPathComponent("photovideoeditor", isDirectory: true)
192
+ try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
193
+ let output = outputDir.appendingPathComponent(UUID().uuidString + ".mp4")
194
+ outputURL = output
195
+
196
+ guard let session = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
197
+ onError(VideoExportError(code: "E_INTERNAL", message: "Unable to create the export session."))
198
+ return
199
+ }
200
+ session.outputURL = output
201
+ session.outputFileType = .mp4
202
+ session.videoComposition = videoComposition
203
+ exportSession = session
204
+
205
+ let timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak session] _ in
206
+ guard let session else { return }
207
+ onProgress(session.progress)
208
+ }
209
+ progressTimer = timer
210
+
211
+ session.exportAsynchronously { [weak self] in
212
+ guard let self else { return }
213
+ self.progressTimer?.invalidate()
214
+ switch session.status {
215
+ case .completed:
216
+ self.outputURL = nil
217
+ let attributes = try? FileManager.default.attributesOfItem(atPath: output.path)
218
+ let fileSize = (attributes?[.size] as? Int) ?? 0
219
+ onComplete(
220
+ VideoExportResult(
221
+ uri: output.absoluteString,
222
+ width: Int(renderWidth),
223
+ height: Int(renderHeight),
224
+ durationMs: durationMs,
225
+ fileSize: fileSize,
226
+ mimeType: "video/mp4"
227
+ )
228
+ )
229
+ case .cancelled:
230
+ try? FileManager.default.removeItem(at: output)
231
+ onError(VideoExportError(code: "E_EXPORT_CANCELLED", message: "Export was cancelled."))
232
+ default:
233
+ try? FileManager.default.removeItem(at: output)
234
+ onError(VideoExportError(code: "E_EXPORT_FAILED", message: session.error?.localizedDescription ?? "Unable to export the video."))
235
+ }
236
+ }
237
+ }
238
+
239
+ private func applyVisibilityTiming(to surface: CALayer, layer: PhotoLayer, durationMs: Int64, durationSeconds: Double) {
240
+ let start = min(max(layer.startMs, 0), durationMs)
241
+ let end = layer.effectiveEndMs(durationMs: durationMs)
242
+ if start == 0 && end >= durationMs {
243
+ surface.opacity = 1
244
+ return
245
+ }
246
+ let animation = CAKeyframeAnimation(keyPath: "opacity")
247
+ var values: [NSNumber] = []
248
+ var times: [NSNumber] = []
249
+ if start > 0 {
250
+ values.append(0); times.append(0)
251
+ values.append(1); times.append(NSNumber(value: Double(start) / Double(max(durationMs, 1))))
252
+ } else {
253
+ values.append(1); times.append(0)
254
+ }
255
+ if end < durationMs {
256
+ values.append(0); times.append(NSNumber(value: Double(end) / Double(max(durationMs, 1))))
257
+ } else {
258
+ values.append(1); times.append(1)
259
+ }
260
+ animation.values = values
261
+ animation.keyTimes = times
262
+ animation.calculationMode = .discrete
263
+ animation.beginTime = AVCoreAnimationBeginTimeAtZero
264
+ animation.duration = durationSeconds
265
+ animation.fillMode = .both
266
+ animation.isRemovedOnCompletion = false
267
+ surface.add(animation, forKey: "overlayVisibility")
268
+ }
269
+
270
+ func cancel() {
271
+ exportSession?.cancelExport()
272
+ progressTimer?.invalidate()
273
+ if let outputURL { try? FileManager.default.removeItem(at: outputURL) }
274
+ }
275
+ }
@@ -0,0 +1,21 @@
1
+ import Foundation
2
+
3
+ /// One segment in the multi-clip timeline (Milestone 7). Mirrors `VideoClip.kt`.
4
+ /// Every clip in this milestone shares the same source file (created via
5
+ /// Split/Duplicate of one input) — importing separate additional source files
6
+ /// from a system picker is a follow-up, not attempted here.
7
+ struct VideoClip: Equatable {
8
+ var id: String = UUID().uuidString
9
+ let sourceUri: String
10
+ let originalDurationMs: Int64
11
+ var trimStartMs: Int64 = 0
12
+ var trimEndMs: Int64 = 0
13
+
14
+ func effectiveTrimEndMs() -> Int64 {
15
+ (trimEndMs > 0 && trimEndMs <= originalDurationMs) ? trimEndMs : originalDurationMs
16
+ }
17
+
18
+ func trimmedDurationMs() -> Int64 {
19
+ max(0, effectiveTrimEndMs() - trimStartMs)
20
+ }
21
+ }
@@ -0,0 +1,99 @@
1
+ import AVFoundation
2
+ import UIKit
3
+
4
+ /// Owns the AVPlayer preview instance, the ordered `VideoClip` list (the
5
+ /// multi-clip timeline, Milestone 7), and the `VideoTransformState` that
6
+ /// applies globally across the whole composed sequence. The source is never
7
+ /// modified — `VideoExporter` reads the clip list again to produce the
8
+ /// exported file.
9
+ ///
10
+ /// Unlike Android (which plays a multi-item ExoPlayer playlist and has to
11
+ /// track a separate "global" position distinct from the player's own
12
+ /// per-item position), the preview here concatenates every clip's trimmed
13
+ /// range into a single `AVMutableComposition` and plays that one asset — so
14
+ /// `player.currentTime()`/`player.seek(to:)` already ARE the composed-timeline
15
+ /// position, with no extra bookkeeping needed. This is a deliberate,
16
+ /// architecture-appropriate difference from the Android session, not an
17
+ /// oversight.
18
+ final class VideoEditSession {
19
+ let player = AVPlayer()
20
+ /// Text/sticker/shape overlays. Reuses the photo layer model/stack — see `PhotoLayer.startMs`/`endMs` for timing.
21
+ let layerStack = PhotoLayerStack()
22
+ private(set) var clips: [VideoClip] = []
23
+ private(set) var state = VideoTransformState()
24
+ /// Fires once the source is probed and the initial single-clip timeline is ready.
25
+ var onClipsReady: (() -> Void)?
26
+
27
+ private var statusObservation: NSKeyValueObservation?
28
+
29
+ init(sourceUri: String) {
30
+ let url = URL(fileURLWithPath: SourceResolver.resolvePath(sourceUri: sourceUri, tempPrefix: "pve_video_preview") ?? sourceUri)
31
+ let asset = AVURLAsset(url: url)
32
+ let probeItem = AVPlayerItem(asset: asset)
33
+ statusObservation = probeItem.observe(\.status, options: [.new]) { [weak self] observedItem, _ in
34
+ guard let self, observedItem.status == .readyToPlay, self.clips.isEmpty else { return }
35
+ let durationSeconds = CMTimeGetSeconds(asset.duration)
36
+ guard durationSeconds.isFinite, durationSeconds > 0 else { return }
37
+ self.clips = [VideoClip(sourceUri: sourceUri, originalDurationMs: Int64(durationSeconds * 1000))]
38
+ self.rebuildPlayerComposition()
39
+ self.onClipsReady?()
40
+ }
41
+ player.replaceCurrentItem(with: probeItem)
42
+ }
43
+
44
+ /// Sum of every clip's trimmed duration — the length of the composed timeline.
45
+ var durationMs: Int64 { clips.reduce(0) { $0 + $1.trimmedDurationMs() } }
46
+
47
+ func update(_ transform: (inout VideoTransformState) -> Void) {
48
+ transform(&state)
49
+ }
50
+
51
+ /// Applies `transform` to the clip list and rebuilds the preview composition to match,
52
+ /// preserving the playhead's position on the composed timeline as closely as possible.
53
+ func updateClips(_ transform: ([VideoClip]) -> [VideoClip]) {
54
+ let previousPositionMs = Int64(CMTimeGetSeconds(player.currentTime()) * 1000)
55
+ clips = transform(clips)
56
+ rebuildPlayerComposition()
57
+ player.seek(to: CMTime(value: previousPositionMs.clamped(to: 0...max(0, durationMs)), timescale: 1000))
58
+ }
59
+
60
+ /// The `AVURLAsset` for one clip's own source file (all clips share one source this milestone).
61
+ func asset(for clip: VideoClip) -> AVURLAsset {
62
+ let url = URL(fileURLWithPath: SourceResolver.resolvePath(sourceUri: clip.sourceUri, tempPrefix: "pve_video_preview") ?? clip.sourceUri)
63
+ return AVURLAsset(url: url)
64
+ }
65
+
66
+ private func rebuildPlayerComposition() {
67
+ guard !clips.isEmpty else { return }
68
+ let composition = AVMutableComposition()
69
+ let videoTrack = composition.addMutableTrack(withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid)
70
+ let audioTrack = composition.addMutableTrack(withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid)
71
+ var cursor = CMTime.zero
72
+ for clip in clips {
73
+ let clipAsset = asset(for: clip)
74
+ let range = CMTimeRange(
75
+ start: CMTime(value: clip.trimStartMs, timescale: 1000),
76
+ end: CMTime(value: clip.effectiveTrimEndMs(), timescale: 1000)
77
+ )
78
+ if let sourceVideoTrack = clipAsset.tracks(withMediaType: .video).first {
79
+ try? videoTrack?.insertTimeRange(range, of: sourceVideoTrack, at: cursor)
80
+ videoTrack?.preferredTransform = sourceVideoTrack.preferredTransform
81
+ }
82
+ if let sourceAudioTrack = clipAsset.tracks(withMediaType: .audio).first {
83
+ try? audioTrack?.insertTimeRange(range, of: sourceAudioTrack, at: cursor)
84
+ }
85
+ cursor = CMTimeAdd(cursor, range.duration)
86
+ }
87
+ let item = AVPlayerItem(asset: composition)
88
+ player.replaceCurrentItem(with: item)
89
+ }
90
+
91
+ func release() {
92
+ statusObservation?.invalidate()
93
+ player.pause()
94
+ }
95
+ }
96
+
97
+ private extension Int64 {
98
+ func clamped(to range: ClosedRange<Int64>) -> Int64 { min(max(self, range.lowerBound), range.upperBound) }
99
+ }