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