@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,104 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Configuration for video export operations
|
|
5
|
+
public struct ExportConfiguration {
|
|
6
|
+
|
|
7
|
+
/// Output file type
|
|
8
|
+
public let fileType: AVFileType
|
|
9
|
+
|
|
10
|
+
/// Video codec to use for export
|
|
11
|
+
public let videoCodec: AVVideoCodecType
|
|
12
|
+
|
|
13
|
+
/// Video quality preset
|
|
14
|
+
public let quality: Quality
|
|
15
|
+
|
|
16
|
+
/// Output URL for the exported file
|
|
17
|
+
public let outputURL: URL
|
|
18
|
+
|
|
19
|
+
/// Whether to optimize for network use
|
|
20
|
+
public let optimizeForNetworkUse: Bool
|
|
21
|
+
|
|
22
|
+
/// Custom video settings (overrides quality preset if provided)
|
|
23
|
+
public let customVideoSettings: [String: Any]?
|
|
24
|
+
|
|
25
|
+
/// Custom audio settings
|
|
26
|
+
public let customAudioSettings: [String: Any]?
|
|
27
|
+
|
|
28
|
+
/// Video quality presets
|
|
29
|
+
public enum Quality {
|
|
30
|
+
case low
|
|
31
|
+
case medium
|
|
32
|
+
case high
|
|
33
|
+
case highest
|
|
34
|
+
|
|
35
|
+
/// Recommended bitrate for the quality level
|
|
36
|
+
/// - Parameter resolution: The video resolution
|
|
37
|
+
/// - Returns: Bitrate in bits per second
|
|
38
|
+
func bitrate(for resolution: CGSize) -> Int {
|
|
39
|
+
let pixels = resolution.width * resolution.height
|
|
40
|
+
|
|
41
|
+
switch self {
|
|
42
|
+
case .low:
|
|
43
|
+
return Int(pixels * 0.1) // ~0.1 bits per pixel
|
|
44
|
+
case .medium:
|
|
45
|
+
return Int(pixels * 0.2) // ~0.2 bits per pixel
|
|
46
|
+
case .high:
|
|
47
|
+
return Int(pixels * 0.4) // ~0.4 bits per pixel
|
|
48
|
+
case .highest:
|
|
49
|
+
return Int(pixels * 0.8) // ~0.8 bits per pixel
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/// Initialize export configuration
|
|
55
|
+
/// - Parameters:
|
|
56
|
+
/// - outputURL: URL where the exported file will be saved
|
|
57
|
+
/// - fileType: Output file type (default: .mp4)
|
|
58
|
+
/// - videoCodec: Video codec (default: .h264)
|
|
59
|
+
/// - quality: Quality preset (default: .high)
|
|
60
|
+
/// - optimizeForNetworkUse: Optimize for network streaming (default: true)
|
|
61
|
+
/// - customVideoSettings: Custom video settings (default: nil)
|
|
62
|
+
/// - customAudioSettings: Custom audio settings (default: nil)
|
|
63
|
+
public init(
|
|
64
|
+
outputURL: URL,
|
|
65
|
+
fileType: AVFileType = .mp4,
|
|
66
|
+
videoCodec: AVVideoCodecType = .h264,
|
|
67
|
+
quality: Quality = .high,
|
|
68
|
+
optimizeForNetworkUse: Bool = true,
|
|
69
|
+
customVideoSettings: [String: Any]? = nil,
|
|
70
|
+
customAudioSettings: [String: Any]? = nil
|
|
71
|
+
) {
|
|
72
|
+
self.outputURL = outputURL
|
|
73
|
+
self.fileType = fileType
|
|
74
|
+
self.videoCodec = videoCodec
|
|
75
|
+
self.quality = quality
|
|
76
|
+
self.optimizeForNetworkUse = optimizeForNetworkUse
|
|
77
|
+
self.customVideoSettings = customVideoSettings
|
|
78
|
+
self.customAudioSettings = customAudioSettings
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// Create a default configuration with a generated temporary file URL
|
|
82
|
+
/// - Returns: A default export configuration
|
|
83
|
+
public static func `default`() -> ExportConfiguration {
|
|
84
|
+
let fileName = "export_\(UUID().uuidString).mp4"
|
|
85
|
+
let outputURL = FileManager.default.temporaryDirectory
|
|
86
|
+
.appendingPathComponent(fileName)
|
|
87
|
+
return ExportConfiguration(outputURL: outputURL)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: - CustomStringConvertible
|
|
92
|
+
extension ExportConfiguration: CustomStringConvertible {
|
|
93
|
+
public var description: String {
|
|
94
|
+
return """
|
|
95
|
+
ExportConfiguration(
|
|
96
|
+
outputURL: \(outputURL.lastPathComponent),
|
|
97
|
+
fileType: \(fileType.rawValue),
|
|
98
|
+
codec: \(videoCodec.rawValue),
|
|
99
|
+
quality: \(quality),
|
|
100
|
+
optimizeForNetwork: \(optimizeForNetworkUse)
|
|
101
|
+
)
|
|
102
|
+
"""
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Configuration for looping a video segment
|
|
5
|
+
public struct LoopConfiguration {
|
|
6
|
+
|
|
7
|
+
/// The time range to loop
|
|
8
|
+
public let timeRange: TimeRange
|
|
9
|
+
|
|
10
|
+
/// Number of times to repeat the segment (total plays = loopCount + 1)
|
|
11
|
+
/// For example, loopCount = 2 means the segment plays 3 times total
|
|
12
|
+
public let loopCount: Int
|
|
13
|
+
|
|
14
|
+
/// Whether to include a seamless transition between loops
|
|
15
|
+
/// When true, attempts to create smoother loop transitions
|
|
16
|
+
public let seamless: Bool
|
|
17
|
+
|
|
18
|
+
/// Initialize a loop configuration
|
|
19
|
+
/// - Parameters:
|
|
20
|
+
/// - timeRange: The time range to loop
|
|
21
|
+
/// - loopCount: Number of additional times to repeat (minimum 1)
|
|
22
|
+
/// - seamless: Whether to attempt seamless looping
|
|
23
|
+
/// - Throws: VideoEditingError.invalidConfiguration if loopCount < 1
|
|
24
|
+
public init(
|
|
25
|
+
timeRange: TimeRange,
|
|
26
|
+
loopCount: Int,
|
|
27
|
+
seamless: Bool = false
|
|
28
|
+
) throws {
|
|
29
|
+
guard loopCount >= 1 else {
|
|
30
|
+
throw VideoEditingError.invalidConfiguration(
|
|
31
|
+
reason: "Loop count must be at least 1 (total plays = loopCount + 1)"
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
guard loopCount <= 100 else {
|
|
36
|
+
throw VideoEditingError.invalidConfiguration(
|
|
37
|
+
reason: "Loop count cannot exceed 100 to prevent excessive memory usage"
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
self.timeRange = timeRange
|
|
42
|
+
self.loopCount = loopCount
|
|
43
|
+
self.seamless = seamless
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/// Create a loop configuration from seconds
|
|
47
|
+
/// - Parameters:
|
|
48
|
+
/// - startSeconds: Start time in seconds
|
|
49
|
+
/// - endSeconds: End time in seconds
|
|
50
|
+
/// - loopCount: Number of times to repeat
|
|
51
|
+
/// - seamless: Whether to attempt seamless looping
|
|
52
|
+
/// - Returns: A LoopConfiguration instance
|
|
53
|
+
/// - Throws: VideoEditingError if parameters are invalid
|
|
54
|
+
public static func fromSeconds(
|
|
55
|
+
start startSeconds: Double,
|
|
56
|
+
end endSeconds: Double,
|
|
57
|
+
loopCount: Int,
|
|
58
|
+
seamless: Bool = false
|
|
59
|
+
) throws -> LoopConfiguration {
|
|
60
|
+
let timeRange = try TimeRange.fromSeconds(start: startSeconds, end: endSeconds)
|
|
61
|
+
return try LoopConfiguration(
|
|
62
|
+
timeRange: timeRange,
|
|
63
|
+
loopCount: loopCount,
|
|
64
|
+
seamless: seamless
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Total number of times the segment will play (loopCount + 1)
|
|
69
|
+
public var totalPlays: Int {
|
|
70
|
+
return loopCount + 1
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// Total duration after looping
|
|
74
|
+
public var totalDuration: CMTime {
|
|
75
|
+
return CMTimeMultiply(timeRange.duration, multiplier: Int32(totalPlays))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// Validate that this loop configuration fits within an asset's duration
|
|
79
|
+
/// - Parameter asset: The video asset to validate against
|
|
80
|
+
/// - Throws: VideoEditingError if the time range exceeds the asset duration
|
|
81
|
+
public func validate(against asset: VideoAsset) throws {
|
|
82
|
+
try timeRange.validate(against: asset)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// MARK: - CustomStringConvertible
|
|
87
|
+
extension LoopConfiguration: CustomStringConvertible {
|
|
88
|
+
public var description: String {
|
|
89
|
+
let rangeDesc = timeRange.description
|
|
90
|
+
let totalSeconds = CMTimeGetSeconds(totalDuration)
|
|
91
|
+
return """
|
|
92
|
+
LoopConfiguration(
|
|
93
|
+
range: \(rangeDesc),
|
|
94
|
+
loopCount: \(loopCount),
|
|
95
|
+
totalPlays: \(totalPlays),
|
|
96
|
+
totalDuration: \(String(format: "%.2f", totalSeconds))s,
|
|
97
|
+
seamless: \(seamless)
|
|
98
|
+
)
|
|
99
|
+
"""
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Represents a time range for video editing operations
|
|
5
|
+
public struct TimeRange {
|
|
6
|
+
|
|
7
|
+
/// The start time of the range
|
|
8
|
+
public let start: CMTime
|
|
9
|
+
|
|
10
|
+
/// The end time of the range
|
|
11
|
+
public let end: CMTime
|
|
12
|
+
|
|
13
|
+
/// Duration of the time range
|
|
14
|
+
public var duration: CMTime {
|
|
15
|
+
return CMTimeSubtract(end, start)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// The CMTimeRange representation
|
|
19
|
+
public var cmTimeRange: CMTimeRange {
|
|
20
|
+
return CMTimeRange(start: start, duration: duration)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/// Initialize with start and end times
|
|
24
|
+
/// - Parameters:
|
|
25
|
+
/// - start: Start time
|
|
26
|
+
/// - end: End time
|
|
27
|
+
/// - Throws: VideoEditingError.invalidTimeRange if end <= start
|
|
28
|
+
public init(start: CMTime, end: CMTime) throws {
|
|
29
|
+
guard CMTimeCompare(end, start) > 0 else {
|
|
30
|
+
throw VideoEditingError.invalidTimeRange(
|
|
31
|
+
reason: "End time must be greater than start time"
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
self.start = start
|
|
35
|
+
self.end = end
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Initialize with start time and duration
|
|
39
|
+
/// - Parameters:
|
|
40
|
+
/// - start: Start time
|
|
41
|
+
/// - duration: Duration of the range
|
|
42
|
+
/// - Throws: VideoEditingError.invalidTimeRange if duration <= 0
|
|
43
|
+
public init(start: CMTime, duration: CMTime) throws {
|
|
44
|
+
guard CMTimeCompare(duration, .zero) > 0 else {
|
|
45
|
+
throw VideoEditingError.invalidTimeRange(
|
|
46
|
+
reason: "Duration must be greater than zero"
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
self.start = start
|
|
50
|
+
self.end = CMTimeAdd(start, duration)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Create a time range from seconds
|
|
54
|
+
/// - Parameters:
|
|
55
|
+
/// - startSeconds: Start time in seconds
|
|
56
|
+
/// - endSeconds: End time in seconds
|
|
57
|
+
/// - Returns: A TimeRange instance
|
|
58
|
+
/// - Throws: VideoEditingError.invalidTimeRange if end <= start
|
|
59
|
+
public static func fromSeconds(
|
|
60
|
+
start startSeconds: Double,
|
|
61
|
+
end endSeconds: Double
|
|
62
|
+
) throws -> TimeRange {
|
|
63
|
+
return try TimeRange(
|
|
64
|
+
start: CMTime(seconds: startSeconds, preferredTimescale: 600),
|
|
65
|
+
end: CMTime(seconds: endSeconds, preferredTimescale: 600)
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Validate that this time range fits within an asset's duration
|
|
70
|
+
/// - Parameter asset: The video asset to validate against
|
|
71
|
+
/// - Throws: VideoEditingError.invalidTimeRange if the range exceeds the asset duration
|
|
72
|
+
public func validate(against asset: VideoAsset) throws {
|
|
73
|
+
let assetDuration = asset.duration
|
|
74
|
+
|
|
75
|
+
guard CMTimeCompare(start, .zero) >= 0 else {
|
|
76
|
+
throw VideoEditingError.invalidTimeRange(
|
|
77
|
+
reason: "Start time cannot be negative"
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
guard CMTimeCompare(end, assetDuration) <= 0 else {
|
|
82
|
+
throw VideoEditingError.invalidTimeRange(
|
|
83
|
+
reason: "End time exceeds asset duration (\(CMTimeGetSeconds(assetDuration))s)"
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// MARK: - CustomStringConvertible
|
|
90
|
+
extension TimeRange: CustomStringConvertible {
|
|
91
|
+
public var description: String {
|
|
92
|
+
let startSeconds = CMTimeGetSeconds(start)
|
|
93
|
+
let endSeconds = CMTimeGetSeconds(end)
|
|
94
|
+
let durationSeconds = CMTimeGetSeconds(duration)
|
|
95
|
+
return String(format: "TimeRange(%.2fs - %.2fs, duration: %.2fs)",
|
|
96
|
+
startSeconds, endSeconds, durationSeconds)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Represents a video asset with its composition and metadata
|
|
5
|
+
public struct VideoAsset {
|
|
6
|
+
|
|
7
|
+
/// The underlying AVAsset
|
|
8
|
+
public let avAsset: AVAsset
|
|
9
|
+
|
|
10
|
+
/// The URL of the video asset (if available)
|
|
11
|
+
public let url: URL?
|
|
12
|
+
|
|
13
|
+
/// Cached duration of the video
|
|
14
|
+
private let _duration: CMTime?
|
|
15
|
+
|
|
16
|
+
/// Cached natural size of the video
|
|
17
|
+
private let _naturalSize: CGSize?
|
|
18
|
+
|
|
19
|
+
/// Cached frame rate of the video
|
|
20
|
+
private let _frameRate: Float?
|
|
21
|
+
|
|
22
|
+
/// Cached hasAudio flag
|
|
23
|
+
private let _hasAudio: Bool?
|
|
24
|
+
|
|
25
|
+
/// Duration of the video
|
|
26
|
+
public var duration: CMTime {
|
|
27
|
+
// Return cached duration (should always be set for properly loaded assets)
|
|
28
|
+
// For compositions, duration is passed during initialization
|
|
29
|
+
return _duration ?? .zero
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Natural size of the video (may return nil if not yet loaded)
|
|
33
|
+
public var naturalSize: CGSize? {
|
|
34
|
+
return _naturalSize
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/// Frame rate of the video (may return nil if not yet loaded)
|
|
38
|
+
public var frameRate: Float? {
|
|
39
|
+
return _frameRate
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/// Whether the asset has audio (defaults to false if not loaded)
|
|
43
|
+
public var hasAudio: Bool {
|
|
44
|
+
return _hasAudio ?? false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/// Initialize with an AVAsset (basic initialization without preloading)
|
|
48
|
+
/// - Parameters:
|
|
49
|
+
/// - avAsset: The AVAsset to wrap
|
|
50
|
+
/// - url: Optional URL of the asset
|
|
51
|
+
public init(avAsset: AVAsset, url: URL? = nil) {
|
|
52
|
+
self.avAsset = avAsset
|
|
53
|
+
self.url = url
|
|
54
|
+
self._duration = nil
|
|
55
|
+
self._naturalSize = nil
|
|
56
|
+
self._frameRate = nil
|
|
57
|
+
self._hasAudio = nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// Initialize with preloaded metadata
|
|
61
|
+
/// - Parameters:
|
|
62
|
+
/// - avAsset: The AVAsset to wrap
|
|
63
|
+
/// - url: Optional URL of the asset
|
|
64
|
+
/// - duration: Preloaded duration
|
|
65
|
+
/// - naturalSize: Preloaded natural size
|
|
66
|
+
/// - frameRate: Preloaded frame rate
|
|
67
|
+
/// - hasAudio: Preloaded hasAudio flag
|
|
68
|
+
public init(
|
|
69
|
+
avAsset: AVAsset,
|
|
70
|
+
url: URL? = nil,
|
|
71
|
+
duration: CMTime,
|
|
72
|
+
naturalSize: CGSize?,
|
|
73
|
+
frameRate: Float?,
|
|
74
|
+
hasAudio: Bool
|
|
75
|
+
) {
|
|
76
|
+
self.avAsset = avAsset
|
|
77
|
+
self.url = url
|
|
78
|
+
self._duration = duration
|
|
79
|
+
self._naturalSize = naturalSize
|
|
80
|
+
self._frameRate = frameRate
|
|
81
|
+
self._hasAudio = hasAudio
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Load metadata asynchronously and return a new VideoAsset with cached values
|
|
85
|
+
public static func load(from avAsset: AVAsset, url: URL? = nil) async throws -> VideoAsset {
|
|
86
|
+
let duration = try await avAsset.load(.duration)
|
|
87
|
+
|
|
88
|
+
let videoTracks = try await avAsset.loadTracks(withMediaType: .video)
|
|
89
|
+
var naturalSize: CGSize? = nil
|
|
90
|
+
var frameRate: Float? = nil
|
|
91
|
+
|
|
92
|
+
if let videoTrack = videoTracks.first {
|
|
93
|
+
naturalSize = try await videoTrack.load(.naturalSize)
|
|
94
|
+
frameRate = try await videoTrack.load(.nominalFrameRate)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let audioTracks = try await avAsset.loadTracks(withMediaType: .audio)
|
|
98
|
+
let hasAudio = !audioTracks.isEmpty
|
|
99
|
+
|
|
100
|
+
return VideoAsset(
|
|
101
|
+
avAsset: avAsset,
|
|
102
|
+
url: url,
|
|
103
|
+
duration: duration,
|
|
104
|
+
naturalSize: naturalSize,
|
|
105
|
+
frameRate: frameRate,
|
|
106
|
+
hasAudio: hasAudio
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// MARK: - CustomStringConvertible
|
|
112
|
+
extension VideoAsset: CustomStringConvertible {
|
|
113
|
+
public var description: String {
|
|
114
|
+
let durationSeconds = CMTimeGetSeconds(duration)
|
|
115
|
+
let size = naturalSize.map { "\($0.width)x\($0.height)" } ?? "unknown"
|
|
116
|
+
let fps = frameRate.map { String(format: "%.2f", $0) } ?? "unknown"
|
|
117
|
+
return """
|
|
118
|
+
VideoAsset(
|
|
119
|
+
duration: \(String(format: "%.2f", durationSeconds))s,
|
|
120
|
+
size: \(size),
|
|
121
|
+
fps: \(fps),
|
|
122
|
+
hasAudio: \(hasAudio)
|
|
123
|
+
)
|
|
124
|
+
"""
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Errors that can occur during video editing operations
|
|
5
|
+
public enum VideoEditingError: LocalizedError {
|
|
6
|
+
|
|
7
|
+
/// Failed to load asset
|
|
8
|
+
case assetLoadingFailed(reason: String, underlyingError: Error? = nil)
|
|
9
|
+
|
|
10
|
+
/// Invalid time range
|
|
11
|
+
case invalidTimeRange(reason: String)
|
|
12
|
+
|
|
13
|
+
/// No video track found in asset
|
|
14
|
+
case noVideoTrack
|
|
15
|
+
|
|
16
|
+
/// No audio track found in asset
|
|
17
|
+
case noAudioTrack
|
|
18
|
+
|
|
19
|
+
/// Export failed
|
|
20
|
+
case exportFailed(reason: String, underlyingError: Error? = nil)
|
|
21
|
+
|
|
22
|
+
/// Export cancelled
|
|
23
|
+
case exportCancelled
|
|
24
|
+
|
|
25
|
+
/// File already exists at output URL
|
|
26
|
+
case fileAlreadyExists(url: URL)
|
|
27
|
+
|
|
28
|
+
/// Composition creation failed
|
|
29
|
+
case compositionFailed(reason: String)
|
|
30
|
+
|
|
31
|
+
/// Invalid configuration
|
|
32
|
+
case invalidConfiguration(reason: String)
|
|
33
|
+
|
|
34
|
+
/// Unknown error
|
|
35
|
+
case unknown(underlyingError: Error)
|
|
36
|
+
|
|
37
|
+
public var errorDescription: String? {
|
|
38
|
+
switch self {
|
|
39
|
+
case .assetLoadingFailed(let reason, let error):
|
|
40
|
+
var description = "Asset loading failed: \(reason)"
|
|
41
|
+
if let error = error {
|
|
42
|
+
description += " - \(error.localizedDescription)"
|
|
43
|
+
}
|
|
44
|
+
return description
|
|
45
|
+
|
|
46
|
+
case .invalidTimeRange(let reason):
|
|
47
|
+
return "Invalid time range: \(reason)"
|
|
48
|
+
|
|
49
|
+
case .noVideoTrack:
|
|
50
|
+
return "No video track found in asset"
|
|
51
|
+
|
|
52
|
+
case .noAudioTrack:
|
|
53
|
+
return "No audio track found in asset"
|
|
54
|
+
|
|
55
|
+
case .exportFailed(let reason, let error):
|
|
56
|
+
var description = "Export failed: \(reason)"
|
|
57
|
+
if let error = error {
|
|
58
|
+
description += " - \(error.localizedDescription)"
|
|
59
|
+
}
|
|
60
|
+
return description
|
|
61
|
+
|
|
62
|
+
case .exportCancelled:
|
|
63
|
+
return "Export was cancelled"
|
|
64
|
+
|
|
65
|
+
case .fileAlreadyExists(let url):
|
|
66
|
+
return "File already exists at: \(url.path)"
|
|
67
|
+
|
|
68
|
+
case .compositionFailed(let reason):
|
|
69
|
+
return "Composition creation failed: \(reason)"
|
|
70
|
+
|
|
71
|
+
case .invalidConfiguration(let reason):
|
|
72
|
+
return "Invalid configuration: \(reason)"
|
|
73
|
+
|
|
74
|
+
case .unknown(let error):
|
|
75
|
+
return "Unknown error: \(error.localizedDescription)"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
public var failureReason: String? {
|
|
80
|
+
return errorDescription
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
|
|
4
|
+
/// Main entry point for the TwoStepVideo library
|
|
5
|
+
/// Provides a convenient API for common video editing operations
|
|
6
|
+
public class TwoStepVideo {
|
|
7
|
+
|
|
8
|
+
/// Shared singleton instance for convenience
|
|
9
|
+
public static let shared = TwoStepVideo()
|
|
10
|
+
|
|
11
|
+
/// Asset loader instance
|
|
12
|
+
public let assetLoader: AssetLoader
|
|
13
|
+
|
|
14
|
+
/// Video trimmer instance
|
|
15
|
+
public let videoTrimmer: VideoTrimmer
|
|
16
|
+
|
|
17
|
+
/// Video transformer instance for mirroring and speed adjustments
|
|
18
|
+
public let videoTransformer: VideoTransformer
|
|
19
|
+
|
|
20
|
+
/// Video exporter instance
|
|
21
|
+
public let videoExporter: VideoExporter
|
|
22
|
+
|
|
23
|
+
/// Initialize a new instance
|
|
24
|
+
public init() {
|
|
25
|
+
self.assetLoader = AssetLoader()
|
|
26
|
+
self.videoTrimmer = VideoTrimmer()
|
|
27
|
+
self.videoTransformer = VideoTransformer()
|
|
28
|
+
self.videoExporter = VideoExporter()
|
|
29
|
+
}
|
|
30
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@movementinfra/expo-twostep-video",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimal video editing for React Native using AVFoundation",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"build",
|
|
9
|
+
"ios",
|
|
10
|
+
"expo-module.config.json",
|
|
11
|
+
"!ios/Tests",
|
|
12
|
+
"!ios/.build",
|
|
13
|
+
"!ios/.swiftpm"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "expo-module build",
|
|
17
|
+
"clean": "expo-module clean",
|
|
18
|
+
"lint": "expo-module lint",
|
|
19
|
+
"test": "npm run test:swift",
|
|
20
|
+
"test:swift": "cd ios && swift test",
|
|
21
|
+
"test:js": "expo-module test",
|
|
22
|
+
"prepare": "expo-module prepare",
|
|
23
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
24
|
+
"expo-module": "expo-module",
|
|
25
|
+
"open:ios": "xed example/ios"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"react-native",
|
|
29
|
+
"expo",
|
|
30
|
+
"expo-module",
|
|
31
|
+
"video",
|
|
32
|
+
"video-editing",
|
|
33
|
+
"trim",
|
|
34
|
+
"export",
|
|
35
|
+
"avfoundation",
|
|
36
|
+
"ios"
|
|
37
|
+
],
|
|
38
|
+
"repository": "https://github.com/rguo123/twostep-video",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/rguo123/twostep-video/issues"
|
|
41
|
+
},
|
|
42
|
+
"author": "rguo123 <richardg7890@gmail.com> (rguo123)",
|
|
43
|
+
"license": "MIT",
|
|
44
|
+
"homepage": "https://github.com/rguo123/twostep-video#readme",
|
|
45
|
+
"dependencies": {},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/react": "~19.1.0",
|
|
48
|
+
"expo-module-scripts": "^5.0.7",
|
|
49
|
+
"expo": "^54.0.24",
|
|
50
|
+
"react-native": "0.81.5"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"expo": "*",
|
|
54
|
+
"react": "*",
|
|
55
|
+
"react-native": "*"
|
|
56
|
+
}
|
|
57
|
+
}
|