@movementinfra/expo-twostep-video 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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +420 -0
  3. package/build/ExpoTwostepVideo.types.d.ts +65 -0
  4. package/build/ExpoTwostepVideo.types.d.ts.map +1 -0
  5. package/build/ExpoTwostepVideo.types.js +2 -0
  6. package/build/ExpoTwostepVideo.types.js.map +1 -0
  7. package/build/ExpoTwostepVideoModule.d.ts +70 -0
  8. package/build/ExpoTwostepVideoModule.d.ts.map +1 -0
  9. package/build/ExpoTwostepVideoModule.js +5 -0
  10. package/build/ExpoTwostepVideoModule.js.map +1 -0
  11. package/build/ExpoTwostepVideoModule.web.d.ts +14 -0
  12. package/build/ExpoTwostepVideoModule.web.d.ts.map +1 -0
  13. package/build/ExpoTwostepVideoModule.web.js +12 -0
  14. package/build/ExpoTwostepVideoModule.web.js.map +1 -0
  15. package/build/ExpoTwostepVideoView.d.ts +27 -0
  16. package/build/ExpoTwostepVideoView.d.ts.map +1 -0
  17. package/build/ExpoTwostepVideoView.js +47 -0
  18. package/build/ExpoTwostepVideoView.js.map +1 -0
  19. package/build/ExpoTwostepVideoView.web.d.ts +4 -0
  20. package/build/ExpoTwostepVideoView.web.d.ts.map +1 -0
  21. package/build/ExpoTwostepVideoView.web.js +8 -0
  22. package/build/ExpoTwostepVideoView.web.js.map +1 -0
  23. package/build/index.d.ts +569 -0
  24. package/build/index.d.ts.map +1 -0
  25. package/build/index.js +430 -0
  26. package/build/index.js.map +1 -0
  27. package/expo-module.config.json +10 -0
  28. package/ios/ExpoTwostepVideo.podspec +30 -0
  29. package/ios/ExpoTwostepVideoModule.swift +739 -0
  30. package/ios/ExpoTwostepVideoView.swift +223 -0
  31. package/ios/Package.swift +32 -0
  32. package/ios/TwoStepVideo/Core/AssetLoader.swift +175 -0
  33. package/ios/TwoStepVideo/Core/VideoExporter.swift +353 -0
  34. package/ios/TwoStepVideo/Core/VideoTransformer.swift +365 -0
  35. package/ios/TwoStepVideo/Core/VideoTrimmer.swift +300 -0
  36. package/ios/TwoStepVideo/Models/ExportConfiguration.swift +104 -0
  37. package/ios/TwoStepVideo/Models/LoopConfiguration.swift +101 -0
  38. package/ios/TwoStepVideo/Models/TimeRange.swift +98 -0
  39. package/ios/TwoStepVideo/Models/VideoAsset.swift +126 -0
  40. package/ios/TwoStepVideo/Models/VideoEditingError.swift +82 -0
  41. package/ios/TwoStepVideo/TwoStepVideo.swift +30 -0
  42. package/package.json +57 -0
@@ -0,0 +1,223 @@
1
+ import ExpoModulesCore
2
+ import AVFoundation
3
+ import UIKit
4
+
5
+ /// Native video player view that can play compositions directly
6
+ class ExpoTwostepVideoView: ExpoView {
7
+
8
+ // MARK: - Properties
9
+
10
+ private var player: AVPlayer?
11
+ private var playerLayer: AVPlayerLayer?
12
+ private var timeObserver: Any?
13
+ private var isObservingStatus: Bool = false
14
+
15
+ /// Event dispatchers
16
+ let onPlaybackStatusChange = EventDispatcher()
17
+ let onProgress = EventDispatcher()
18
+ let onEnd = EventDispatcher()
19
+ let onError = EventDispatcher()
20
+
21
+ /// Current composition ID being played
22
+ private var currentCompositionId: String?
23
+ private var currentAssetId: String?
24
+
25
+ /// Whether to loop playback continuously
26
+ var shouldLoop: Bool = false
27
+
28
+ // MARK: - Initialization
29
+
30
+ required init(appContext: AppContext? = nil) {
31
+ super.init(appContext: appContext)
32
+ clipsToBounds = true
33
+ backgroundColor = .black
34
+ setupPlayerLayer()
35
+ }
36
+
37
+ private func setupPlayerLayer() {
38
+ playerLayer = AVPlayerLayer()
39
+ playerLayer?.videoGravity = .resizeAspect
40
+ playerLayer?.backgroundColor = UIColor.black.cgColor
41
+ if let playerLayer = playerLayer {
42
+ layer.addSublayer(playerLayer)
43
+ }
44
+ }
45
+
46
+ override func layoutSubviews() {
47
+ super.layoutSubviews()
48
+ playerLayer?.frame = bounds
49
+ }
50
+
51
+ // MARK: - Public Methods (called from module)
52
+
53
+ func loadComposition(compositionId: String, composition: AVMutableComposition, videoComposition: AVMutableVideoComposition?) {
54
+ // Clean up previous player
55
+ cleanup()
56
+
57
+ currentCompositionId = compositionId
58
+ currentAssetId = nil
59
+
60
+ // Create player item from composition
61
+ let playerItem = AVPlayerItem(asset: composition)
62
+
63
+ // Apply video composition for transforms (mirror, etc.)
64
+ if let videoComposition = videoComposition {
65
+ playerItem.videoComposition = videoComposition
66
+ }
67
+
68
+ setupPlayer(with: playerItem)
69
+ }
70
+
71
+ func loadAsset(assetId: String, asset: AVAsset) {
72
+ // Clean up previous player
73
+ cleanup()
74
+
75
+ currentAssetId = assetId
76
+ currentCompositionId = nil
77
+
78
+ let playerItem = AVPlayerItem(asset: asset)
79
+ setupPlayer(with: playerItem)
80
+ }
81
+
82
+ private func setupPlayer(with playerItem: AVPlayerItem) {
83
+ // Create player
84
+ player = AVPlayer(playerItem: playerItem)
85
+ playerLayer?.player = player
86
+
87
+ // Observe playback status (track that we added this observer)
88
+ playerItem.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
89
+ isObservingStatus = true
90
+
91
+ // Observe when playback ends
92
+ NotificationCenter.default.addObserver(
93
+ self,
94
+ selector: #selector(playerDidFinishPlaying),
95
+ name: .AVPlayerItemDidPlayToEndTime,
96
+ object: playerItem
97
+ )
98
+
99
+ // Add periodic time observer for progress (0.25s interval is sufficient and reduces overhead)
100
+ let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
101
+ timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
102
+ guard let self = self,
103
+ let duration = self.player?.currentItem?.duration,
104
+ duration.isNumeric && !duration.isIndefinite else { return }
105
+
106
+ let currentTime = CMTimeGetSeconds(time)
107
+ let totalDuration = CMTimeGetSeconds(duration)
108
+
109
+ self.onProgress([
110
+ "currentTime": currentTime,
111
+ "duration": totalDuration,
112
+ "progress": totalDuration > 0 ? currentTime / totalDuration : 0
113
+ ])
114
+ }
115
+
116
+ onPlaybackStatusChange(["status": "ready"])
117
+ }
118
+
119
+ func play() {
120
+ player?.play()
121
+ onPlaybackStatusChange(["status": "playing"])
122
+ }
123
+
124
+ func pause() {
125
+ player?.pause()
126
+ onPlaybackStatusChange(["status": "paused"])
127
+ }
128
+
129
+ func seek(to time: Double) {
130
+ let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
131
+ player?.seek(to: cmTime, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] finished in
132
+ if finished {
133
+ self?.onPlaybackStatusChange(["status": "seeked", "time": time])
134
+ }
135
+ }
136
+ }
137
+
138
+ func replay() {
139
+ seek(to: 0)
140
+ play()
141
+ }
142
+
143
+ var isPlaying: Bool {
144
+ return player?.rate != 0 && player?.error == nil
145
+ }
146
+
147
+ var currentTime: Double {
148
+ guard let time = player?.currentTime() else { return 0 }
149
+ return CMTimeGetSeconds(time)
150
+ }
151
+
152
+ var duration: Double {
153
+ guard let duration = player?.currentItem?.duration,
154
+ duration.isNumeric && !duration.isIndefinite else { return 0 }
155
+ return CMTimeGetSeconds(duration)
156
+ }
157
+
158
+ // MARK: - Observers
159
+
160
+ override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
161
+ if keyPath == "status" {
162
+ if let playerItem = object as? AVPlayerItem {
163
+ switch playerItem.status {
164
+ case .readyToPlay:
165
+ onPlaybackStatusChange(["status": "ready"])
166
+ case .failed:
167
+ onError(["error": playerItem.error?.localizedDescription ?? "Unknown error"])
168
+ default:
169
+ break
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ @objc private func playerDidFinishPlaying() {
176
+ if shouldLoop {
177
+ // Seek back to start and play again
178
+ player?.seek(to: .zero) { [weak self] _ in
179
+ self?.player?.play()
180
+ }
181
+ } else {
182
+ onEnd([:])
183
+ onPlaybackStatusChange(["status": "ended"])
184
+ }
185
+ }
186
+
187
+ // MARK: - Cleanup
188
+
189
+ private func cleanup() {
190
+ // Remove time observer first
191
+ if let observer = timeObserver, let player = player {
192
+ player.removeTimeObserver(observer)
193
+ }
194
+ timeObserver = nil
195
+
196
+ // Remove KVO observer only if we added it
197
+ if isObservingStatus, let playerItem = player?.currentItem {
198
+ playerItem.removeObserver(self, forKeyPath: "status")
199
+ isObservingStatus = false
200
+ }
201
+
202
+ // Remove notification observer
203
+ if let playerItem = player?.currentItem {
204
+ NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
205
+ }
206
+
207
+ // Stop and clear player
208
+ player?.pause()
209
+ player?.replaceCurrentItem(with: nil)
210
+ player = nil
211
+ playerLayer?.player = nil
212
+
213
+ currentCompositionId = nil
214
+ currentAssetId = nil
215
+ }
216
+
217
+ deinit {
218
+ cleanup()
219
+ // Also remove the layer to break any potential retain cycles
220
+ playerLayer?.removeFromSuperlayer()
221
+ playerLayer = nil
222
+ }
223
+ }
@@ -0,0 +1,32 @@
1
+ // swift-tools-version: 5.9
2
+ // The swift-tools-version declares the minimum version of Swift required to build this package.
3
+
4
+ import PackageDescription
5
+
6
+ let package = Package(
7
+ name: "TwoStepVideo",
8
+ platforms: [
9
+ .iOS(.v16),
10
+ .macOS(.v13)
11
+ ],
12
+ products: [
13
+ .library(
14
+ name: "TwoStepVideo",
15
+ targets: ["TwoStepVideo"]),
16
+ ],
17
+ targets: [
18
+ // Swift library target
19
+ .target(
20
+ name: "TwoStepVideo",
21
+ path: "TwoStepVideo",
22
+ exclude: []
23
+ ),
24
+
25
+ // Test target
26
+ .testTarget(
27
+ name: "TwoStepVideoTests",
28
+ dependencies: ["TwoStepVideo"],
29
+ path: "Tests"
30
+ ),
31
+ ]
32
+ )
@@ -0,0 +1,175 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import Photos
4
+
5
+ /// Handles loading video assets from various sources
6
+ public class AssetLoader {
7
+
8
+ /// Initialize a new AssetLoader
9
+ public init() {}
10
+
11
+ /// Load a video asset from a file URL
12
+ /// - Parameter url: The file URL of the video
13
+ /// - Returns: A VideoAsset
14
+ /// - Throws: VideoEditingError if loading fails
15
+ public func loadAsset(from url: URL) async throws -> VideoAsset {
16
+ let avAsset = AVURLAsset(url: url)
17
+
18
+ // Verify the asset is readable
19
+ do {
20
+ let isReadable = try await avAsset.load(.isReadable)
21
+ guard isReadable else {
22
+ throw VideoEditingError.assetLoadingFailed(
23
+ reason: "Asset is not readable"
24
+ )
25
+ }
26
+
27
+ // Load using the VideoAsset.load method which handles all async loading
28
+ let videoAsset = try await VideoAsset.load(from: avAsset, url: url)
29
+
30
+ // Verify the asset has a video track
31
+ if videoAsset.naturalSize == nil {
32
+ throw VideoEditingError.noVideoTrack
33
+ }
34
+
35
+ return videoAsset
36
+
37
+ } catch let error as VideoEditingError {
38
+ throw error
39
+ } catch {
40
+ throw VideoEditingError.assetLoadingFailed(
41
+ reason: "Failed to load asset properties",
42
+ underlyingError: error
43
+ )
44
+ }
45
+ }
46
+
47
+ /// Load a video asset from a PHAsset (Photos library)
48
+ /// - Parameter phAsset: The PHAsset from the Photos library
49
+ /// - Returns: A VideoAsset
50
+ /// - Throws: VideoEditingError if loading fails
51
+ public func loadAsset(from phAsset: PHAsset) async throws -> VideoAsset {
52
+ guard phAsset.mediaType == .video else {
53
+ throw VideoEditingError.assetLoadingFailed(
54
+ reason: "PHAsset is not a video"
55
+ )
56
+ }
57
+
58
+ let avAsset: AVAsset = try await withCheckedThrowingContinuation { continuation in
59
+ let options = PHVideoRequestOptions()
60
+ options.version = .current
61
+ options.deliveryMode = .highQualityFormat
62
+ options.isNetworkAccessAllowed = true
63
+
64
+ PHImageManager.default().requestAVAsset(
65
+ forVideo: phAsset,
66
+ options: options
67
+ ) { avAsset, audioMix, info in
68
+ if let error = info?[PHImageErrorKey] as? Error {
69
+ continuation.resume(
70
+ throwing: VideoEditingError.assetLoadingFailed(
71
+ reason: "Failed to load PHAsset",
72
+ underlyingError: error
73
+ )
74
+ )
75
+ return
76
+ }
77
+
78
+ guard let avAsset = avAsset else {
79
+ continuation.resume(
80
+ throwing: VideoEditingError.assetLoadingFailed(
81
+ reason: "PHAsset returned nil AVAsset"
82
+ )
83
+ )
84
+ return
85
+ }
86
+
87
+ continuation.resume(returning: avAsset)
88
+ }
89
+ }
90
+
91
+ // Load metadata using async API
92
+ return try await VideoAsset.load(from: avAsset, url: nil)
93
+ }
94
+
95
+ /// Load asset metadata without creating a full VideoAsset
96
+ /// Useful for quick validation and preview purposes
97
+ /// - Parameter url: The file URL of the video
98
+ /// - Returns: A dictionary containing metadata
99
+ /// - Throws: VideoEditingError if loading fails
100
+ public func loadMetadata(from url: URL) async throws -> [String: Any] {
101
+ let avAsset = AVURLAsset(url: url)
102
+
103
+ do {
104
+ let duration = try await avAsset.load(.duration)
105
+ let tracks = try await avAsset.load(.tracks)
106
+ let videoTracks = tracks.filter { $0.mediaType == .video }
107
+ let audioTracks = tracks.filter { $0.mediaType == .audio }
108
+
109
+ var metadata: [String: Any] = [
110
+ "duration": CMTimeGetSeconds(duration),
111
+ "hasVideo": !videoTracks.isEmpty,
112
+ "hasAudio": !audioTracks.isEmpty
113
+ ]
114
+
115
+ if let videoTrack = videoTracks.first {
116
+ let naturalSize = try await videoTrack.load(.naturalSize)
117
+ let frameRate = try await videoTrack.load(.nominalFrameRate)
118
+
119
+ metadata["width"] = naturalSize.width
120
+ metadata["height"] = naturalSize.height
121
+ metadata["frameRate"] = frameRate
122
+ }
123
+
124
+ return metadata
125
+
126
+ } catch {
127
+ throw VideoEditingError.assetLoadingFailed(
128
+ reason: "Failed to load metadata",
129
+ underlyingError: error
130
+ )
131
+ }
132
+ }
133
+
134
+ /// Validate that a URL points to a valid video file
135
+ /// - Parameter url: The file URL to validate
136
+ /// - Returns: True if the URL points to a valid video file
137
+ public func validateVideoURL(_ url: URL) async -> Bool {
138
+ do {
139
+ _ = try await loadMetadata(from: url)
140
+ return true
141
+ } catch {
142
+ return false
143
+ }
144
+ }
145
+
146
+ /// Preload asset properties for faster access
147
+ /// This method loads commonly used properties into memory
148
+ /// - Parameter asset: The video asset to preload
149
+ /// - Throws: VideoEditingError if preloading fails
150
+ public func preloadAsset(_ asset: VideoAsset) async throws {
151
+ let avAsset = asset.avAsset
152
+
153
+ do {
154
+ // Preload commonly used properties
155
+ _ = try await avAsset.load(.duration)
156
+ _ = try await avAsset.load(.tracks)
157
+ _ = try await avAsset.load(.isPlayable)
158
+ _ = try await avAsset.load(.isReadable)
159
+
160
+ // Preload video track properties
161
+ let videoTracks = try await avAsset.loadTracks(withMediaType: .video)
162
+ if let videoTrack = videoTracks.first {
163
+ _ = try await videoTrack.load(.naturalSize)
164
+ _ = try await videoTrack.load(.nominalFrameRate)
165
+ _ = try await videoTrack.load(.timeRange)
166
+ }
167
+
168
+ } catch {
169
+ throw VideoEditingError.assetLoadingFailed(
170
+ reason: "Failed to preload asset properties",
171
+ underlyingError: error
172
+ )
173
+ }
174
+ }
175
+ }