@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.
- package/LICENSE +21 -0
- package/README.md +420 -0
- package/build/ExpoTwostepVideo.types.d.ts +65 -0
- package/build/ExpoTwostepVideo.types.d.ts.map +1 -0
- package/build/ExpoTwostepVideo.types.js +2 -0
- package/build/ExpoTwostepVideo.types.js.map +1 -0
- package/build/ExpoTwostepVideoModule.d.ts +70 -0
- package/build/ExpoTwostepVideoModule.d.ts.map +1 -0
- package/build/ExpoTwostepVideoModule.js +5 -0
- package/build/ExpoTwostepVideoModule.js.map +1 -0
- package/build/ExpoTwostepVideoModule.web.d.ts +14 -0
- package/build/ExpoTwostepVideoModule.web.d.ts.map +1 -0
- package/build/ExpoTwostepVideoModule.web.js +12 -0
- package/build/ExpoTwostepVideoModule.web.js.map +1 -0
- package/build/ExpoTwostepVideoView.d.ts +27 -0
- package/build/ExpoTwostepVideoView.d.ts.map +1 -0
- package/build/ExpoTwostepVideoView.js +47 -0
- package/build/ExpoTwostepVideoView.js.map +1 -0
- package/build/ExpoTwostepVideoView.web.d.ts +4 -0
- package/build/ExpoTwostepVideoView.web.d.ts.map +1 -0
- package/build/ExpoTwostepVideoView.web.js +8 -0
- package/build/ExpoTwostepVideoView.web.js.map +1 -0
- package/build/index.d.ts +569 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +430 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +10 -0
- package/ios/ExpoTwostepVideo.podspec +30 -0
- package/ios/ExpoTwostepVideoModule.swift +739 -0
- package/ios/ExpoTwostepVideoView.swift +223 -0
- package/ios/Package.swift +32 -0
- package/ios/TwoStepVideo/Core/AssetLoader.swift +175 -0
- package/ios/TwoStepVideo/Core/VideoExporter.swift +353 -0
- package/ios/TwoStepVideo/Core/VideoTransformer.swift +365 -0
- package/ios/TwoStepVideo/Core/VideoTrimmer.swift +300 -0
- package/ios/TwoStepVideo/Models/ExportConfiguration.swift +104 -0
- package/ios/TwoStepVideo/Models/LoopConfiguration.swift +101 -0
- package/ios/TwoStepVideo/Models/TimeRange.swift +98 -0
- package/ios/TwoStepVideo/Models/VideoAsset.swift +126 -0
- package/ios/TwoStepVideo/Models/VideoEditingError.swift +82 -0
- package/ios/TwoStepVideo/TwoStepVideo.swift +30 -0
- 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
|
+
}
|