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