@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,353 @@
1
+ import Foundation
2
+ import AVFoundation
3
+
4
+ /// Handles video export operations
5
+ public class VideoExporter {
6
+
7
+ /// Progress callback closure
8
+ public typealias ProgressHandler = (Float) -> Void
9
+
10
+ /// Completion callback closure
11
+ public typealias CompletionHandler = (Result<URL, VideoEditingError>) -> Void
12
+
13
+ /// Initialize a new VideoExporter
14
+ public init() {}
15
+
16
+ /// Export a composition to a file
17
+ /// - Parameters:
18
+ /// - composition: The composition to export
19
+ /// - configuration: Export configuration
20
+ /// - progressHandler: Optional progress callback (0.0 to 1.0)
21
+ /// - Returns: The URL of the exported file
22
+ /// - Throws: VideoEditingError if export fails
23
+ public func export(
24
+ composition: AVComposition,
25
+ configuration: ExportConfiguration,
26
+ progressHandler: ProgressHandler? = nil
27
+ ) async throws -> URL {
28
+ // Remove existing file if present
29
+ if FileManager.default.fileExists(atPath: configuration.outputURL.path) {
30
+ try? FileManager.default.removeItem(at: configuration.outputURL)
31
+ }
32
+
33
+ // Create export session
34
+ guard let exportSession = AVAssetExportSession(
35
+ asset: composition,
36
+ presetName: AVAssetExportPresetPassthrough
37
+ ) else {
38
+ throw VideoEditingError.exportFailed(
39
+ reason: "Failed to create export session"
40
+ )
41
+ }
42
+
43
+ // Configure export session
44
+ exportSession.outputURL = configuration.outputURL
45
+ exportSession.outputFileType = configuration.fileType
46
+ exportSession.shouldOptimizeForNetworkUse = configuration.optimizeForNetworkUse
47
+
48
+ // Apply custom video settings if provided
49
+ if let customVideoSettings = configuration.customVideoSettings {
50
+ exportSession.videoComposition = try await createVideoComposition(
51
+ for: composition,
52
+ settings: customVideoSettings
53
+ )
54
+ }
55
+
56
+ // Start progress monitoring if handler provided
57
+ let progressTask: Task<Void, Never>? = progressHandler.map { handler in
58
+ Task {
59
+ await monitorProgress(of: exportSession, handler: handler)
60
+ }
61
+ }
62
+
63
+ // Perform export
64
+ await exportSession.export()
65
+
66
+ // Cancel progress monitoring
67
+ progressTask?.cancel()
68
+
69
+ // Check export status
70
+ switch exportSession.status {
71
+ case .completed:
72
+ return configuration.outputURL
73
+
74
+ case .failed:
75
+ let error = exportSession.error
76
+ throw VideoEditingError.exportFailed(
77
+ reason: "Export session failed",
78
+ underlyingError: error
79
+ )
80
+
81
+ case .cancelled:
82
+ throw VideoEditingError.exportCancelled
83
+
84
+ default:
85
+ throw VideoEditingError.exportFailed(
86
+ reason: "Export ended with unexpected status: \(exportSession.status.rawValue)"
87
+ )
88
+ }
89
+ }
90
+
91
+ /// Export a video asset directly (without composition)
92
+ /// - Parameters:
93
+ /// - asset: The video asset to export
94
+ /// - configuration: Export configuration
95
+ /// - progressHandler: Optional progress callback
96
+ /// - Returns: The URL of the exported file
97
+ /// - Throws: VideoEditingError if export fails
98
+ public func export(
99
+ asset: VideoAsset,
100
+ configuration: ExportConfiguration,
101
+ progressHandler: ProgressHandler? = nil
102
+ ) async throws -> URL {
103
+ // Remove existing file if present
104
+ if FileManager.default.fileExists(atPath: configuration.outputURL.path) {
105
+ try? FileManager.default.removeItem(at: configuration.outputURL)
106
+ }
107
+
108
+ // Get appropriate preset for quality
109
+ let preset = exportPreset(for: configuration.quality)
110
+
111
+ guard let exportSession = AVAssetExportSession(
112
+ asset: asset.avAsset,
113
+ presetName: preset
114
+ ) else {
115
+ throw VideoEditingError.exportFailed(
116
+ reason: "Failed to create export session with preset: \(preset)"
117
+ )
118
+ }
119
+
120
+ // Configure export session
121
+ exportSession.outputURL = configuration.outputURL
122
+ exportSession.outputFileType = configuration.fileType
123
+ exportSession.shouldOptimizeForNetworkUse = configuration.optimizeForNetworkUse
124
+
125
+ // Start progress monitoring if handler provided
126
+ let progressTask: Task<Void, Never>? = progressHandler.map { handler in
127
+ Task {
128
+ await monitorProgress(of: exportSession, handler: handler)
129
+ }
130
+ }
131
+
132
+ // Perform export
133
+ await exportSession.export()
134
+
135
+ // Cancel progress monitoring
136
+ progressTask?.cancel()
137
+
138
+ // Check export status
139
+ switch exportSession.status {
140
+ case .completed:
141
+ return configuration.outputURL
142
+
143
+ case .failed:
144
+ // Clean up partial file on failure
145
+ cleanupPartialFile(at: configuration.outputURL)
146
+
147
+ let error = exportSession.error
148
+ throw VideoEditingError.exportFailed(
149
+ reason: "Export session failed",
150
+ underlyingError: error
151
+ )
152
+
153
+ case .cancelled:
154
+ // Clean up partial file on cancellation
155
+ cleanupPartialFile(at: configuration.outputURL)
156
+
157
+ throw VideoEditingError.exportCancelled
158
+
159
+ default:
160
+ // Clean up partial file on unexpected status
161
+ cleanupPartialFile(at: configuration.outputURL)
162
+
163
+ throw VideoEditingError.exportFailed(
164
+ reason: "Export ended with unexpected status: \(exportSession.status.rawValue)"
165
+ )
166
+ }
167
+ }
168
+
169
+ /// Export with a custom AVAssetExportSession configuration
170
+ /// Provides full control over export settings
171
+ /// - Parameters:
172
+ /// - composition: The composition to export
173
+ /// - outputURL: Output file URL
174
+ /// - sessionConfigurator: Closure to configure the export session
175
+ /// - progressHandler: Optional progress callback
176
+ /// - Returns: The URL of the exported file
177
+ /// - Throws: VideoEditingError if export fails
178
+ public func exportWithCustomSession(
179
+ composition: AVComposition,
180
+ outputURL: URL,
181
+ sessionConfigurator: (AVAssetExportSession) -> Void,
182
+ progressHandler: ProgressHandler? = nil
183
+ ) async throws -> URL {
184
+ // Remove existing file if present
185
+ if FileManager.default.fileExists(atPath: outputURL.path) {
186
+ try? FileManager.default.removeItem(at: outputURL)
187
+ }
188
+
189
+ guard let exportSession = AVAssetExportSession(
190
+ asset: composition,
191
+ presetName: AVAssetExportPresetHighestQuality
192
+ ) else {
193
+ throw VideoEditingError.exportFailed(
194
+ reason: "Failed to create export session"
195
+ )
196
+ }
197
+
198
+ // Apply custom configuration
199
+ sessionConfigurator(exportSession)
200
+ exportSession.outputURL = outputURL
201
+
202
+ // Start progress monitoring if handler provided
203
+ let progressTask: Task<Void, Never>? = progressHandler.map { handler in
204
+ Task {
205
+ await monitorProgress(of: exportSession, handler: handler)
206
+ }
207
+ }
208
+
209
+ // Perform export
210
+ await exportSession.export()
211
+
212
+ // Cancel progress monitoring
213
+ progressTask?.cancel()
214
+
215
+ // Check export status
216
+ switch exportSession.status {
217
+ case .completed:
218
+ return outputURL
219
+
220
+ case .failed:
221
+ // Clean up partial file on failure
222
+ cleanupPartialFile(at: outputURL)
223
+
224
+ let error = exportSession.error
225
+ throw VideoEditingError.exportFailed(
226
+ reason: "Export session failed",
227
+ underlyingError: error
228
+ )
229
+
230
+ case .cancelled:
231
+ // Clean up partial file on cancellation
232
+ cleanupPartialFile(at: outputURL)
233
+
234
+ throw VideoEditingError.exportCancelled
235
+
236
+ default:
237
+ // Clean up partial file on unexpected status
238
+ cleanupPartialFile(at: outputURL)
239
+
240
+ throw VideoEditingError.exportFailed(
241
+ reason: "Export ended with unexpected status: \(exportSession.status.rawValue)"
242
+ )
243
+ }
244
+ }
245
+
246
+ /// Clean up a temporary or partial export file
247
+ /// - Parameter url: The URL of the file to remove
248
+ public func cleanupFile(at url: URL) {
249
+ cleanupPartialFile(at: url)
250
+ }
251
+
252
+ /// Cancel an ongoing export operation
253
+ /// Note: This requires keeping a reference to the export session
254
+ /// For managed cancellation, use the async/await methods which support Task cancellation
255
+ public func cancelExport() {
256
+ // This is a placeholder for potential future session management
257
+ // In practice, callers should cancel the Task that called the export method
258
+ }
259
+
260
+ // MARK: - Private Helper Methods
261
+
262
+ /// Clean up a partial or temporary file
263
+ private func cleanupPartialFile(at url: URL) {
264
+ guard FileManager.default.fileExists(atPath: url.path) else {
265
+ return
266
+ }
267
+
268
+ do {
269
+ try FileManager.default.removeItem(at: url)
270
+ } catch {
271
+ // Log but don't throw - cleanup is best-effort
272
+ print("Warning: Failed to clean up file at \(url.path): \(error)")
273
+ }
274
+ }
275
+
276
+ /// Monitor export progress
277
+ private func monitorProgress(
278
+ of session: AVAssetExportSession,
279
+ handler: @escaping ProgressHandler
280
+ ) async {
281
+ while !Task.isCancelled && session.status == .exporting {
282
+ handler(session.progress)
283
+ try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
284
+ }
285
+ }
286
+
287
+ /// Get export preset for quality level
288
+ private func exportPreset(for quality: ExportConfiguration.Quality) -> String {
289
+ switch quality {
290
+ case .low:
291
+ return AVAssetExportPresetLowQuality
292
+ case .medium:
293
+ return AVAssetExportPresetMediumQuality
294
+ case .high:
295
+ return AVAssetExportPresetHighestQuality
296
+ case .highest:
297
+ return AVAssetExportPresetHighestQuality
298
+ }
299
+ }
300
+
301
+ /// Create a video composition with custom settings
302
+ private func createVideoComposition(
303
+ for asset: AVComposition,
304
+ settings: [String: Any]
305
+ ) async throws -> AVMutableVideoComposition {
306
+ let videoTracks = try await asset.loadTracks(withMediaType: .video)
307
+ guard let videoTrack = videoTracks.first else {
308
+ throw VideoEditingError.noVideoTrack
309
+ }
310
+
311
+ let naturalSize = try await videoTrack.load(.naturalSize)
312
+
313
+ let videoComposition = AVMutableVideoComposition()
314
+ videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
315
+ videoComposition.renderSize = naturalSize
316
+
317
+ let instruction = AVMutableVideoCompositionInstruction()
318
+ instruction.timeRange = CMTimeRange(
319
+ start: .zero,
320
+ duration: asset.duration
321
+ )
322
+
323
+ let layerInstruction = AVMutableVideoCompositionLayerInstruction(
324
+ assetTrack: videoTrack
325
+ )
326
+ instruction.layerInstructions = [layerInstruction]
327
+ videoComposition.instructions = [instruction]
328
+
329
+ return videoComposition
330
+ }
331
+ }
332
+
333
+ // MARK: - Export Session Status Extension
334
+ extension AVAssetExportSession.Status: @retroactive CustomStringConvertible {
335
+ public var description: String {
336
+ switch self {
337
+ case .unknown:
338
+ return "unknown"
339
+ case .waiting:
340
+ return "waiting"
341
+ case .exporting:
342
+ return "exporting"
343
+ case .completed:
344
+ return "completed"
345
+ case .failed:
346
+ return "failed"
347
+ case .cancelled:
348
+ return "cancelled"
349
+ @unknown default:
350
+ return "unknown(\(rawValue))"
351
+ }
352
+ }
353
+ }