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