@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,365 @@
1
+ import Foundation
2
+ import AVFoundation
3
+ import CoreImage
4
+
5
+ /// Handles video transformation operations including mirroring and speed adjustment
6
+ public class VideoTransformer {
7
+
8
+ /// Mirror axis options
9
+ public enum MirrorAxis {
10
+ /// Mirror horizontally (flip left-right)
11
+ case horizontal
12
+ /// Mirror vertically (flip top-bottom)
13
+ case vertical
14
+ /// Mirror both axes
15
+ case both
16
+ }
17
+
18
+ /// Initialize a new VideoTransformer
19
+ public init() {}
20
+
21
+ // MARK: - Mirroring
22
+
23
+ /// Create a mirrored composition from a video asset
24
+ /// - Parameters:
25
+ /// - asset: The video asset to mirror
26
+ /// - axis: The axis to mirror on
27
+ /// - timeRange: Optional time range to apply (nil = full video)
28
+ /// - Returns: A tuple of (composition, videoComposition) for export
29
+ /// - Throws: VideoEditingError if mirroring fails
30
+ public func mirror(
31
+ asset: VideoAsset,
32
+ axis: MirrorAxis,
33
+ timeRange: TimeRange? = nil
34
+ ) async throws -> (AVMutableComposition, AVMutableVideoComposition) {
35
+ let videoTracks = try await asset.avAsset.loadTracks(withMediaType: .video)
36
+ guard let sourceVideoTrack = videoTracks.first else {
37
+ throw VideoEditingError.noVideoTrack
38
+ }
39
+
40
+ let composition = AVMutableComposition()
41
+
42
+ // Determine time range
43
+ let effectiveRange: CMTimeRange
44
+ if let timeRange = timeRange {
45
+ try timeRange.validate(against: asset)
46
+ effectiveRange = timeRange.cmTimeRange
47
+ } else {
48
+ effectiveRange = CMTimeRange(start: .zero, duration: asset.duration)
49
+ }
50
+
51
+ // Add video track
52
+ guard let compositionVideoTrack = composition.addMutableTrack(
53
+ withMediaType: .video,
54
+ preferredTrackID: kCMPersistentTrackID_Invalid
55
+ ) else {
56
+ throw VideoEditingError.compositionFailed(
57
+ reason: "Failed to add video track to composition"
58
+ )
59
+ }
60
+
61
+ do {
62
+ try compositionVideoTrack.insertTimeRange(
63
+ effectiveRange,
64
+ of: sourceVideoTrack,
65
+ at: .zero
66
+ )
67
+ } catch {
68
+ throw VideoEditingError.compositionFailed(
69
+ reason: "Failed to insert video track: \(error.localizedDescription)"
70
+ )
71
+ }
72
+
73
+ // Add audio track if present
74
+ let audioTracks = try? await asset.avAsset.loadTracks(withMediaType: .audio)
75
+ if let sourceAudioTrack = audioTracks?.first {
76
+ if let compositionAudioTrack = composition.addMutableTrack(
77
+ withMediaType: .audio,
78
+ preferredTrackID: kCMPersistentTrackID_Invalid
79
+ ) {
80
+ try? compositionAudioTrack.insertTimeRange(
81
+ effectiveRange,
82
+ of: sourceAudioTrack,
83
+ at: .zero
84
+ )
85
+ }
86
+ }
87
+
88
+ // Get video properties
89
+ let preferredTransform = try await sourceVideoTrack.load(.preferredTransform)
90
+ let naturalSize = try await sourceVideoTrack.load(.naturalSize)
91
+
92
+ // Calculate the actual render size accounting for the transform
93
+ let transformedSize = naturalSize.applying(preferredTransform)
94
+ let renderSize = CGSize(
95
+ width: abs(transformedSize.width),
96
+ height: abs(transformedSize.height)
97
+ )
98
+
99
+ // Create video composition for mirroring
100
+ let videoComposition = AVMutableVideoComposition()
101
+ videoComposition.renderSize = renderSize
102
+
103
+ // Get frame rate
104
+ let frameRate = try await sourceVideoTrack.load(.nominalFrameRate)
105
+ videoComposition.frameDuration = CMTime(value: 1, timescale: CMTimeScale(frameRate > 0 ? frameRate : 30))
106
+
107
+ // Create instruction
108
+ let instruction = AVMutableVideoCompositionInstruction()
109
+ instruction.timeRange = CMTimeRange(start: .zero, duration: composition.duration)
110
+
111
+ // Create layer instruction with mirror transform
112
+ let layerInstruction = AVMutableVideoCompositionLayerInstruction(assetTrack: compositionVideoTrack)
113
+
114
+ let mirrorTransform = createMirrorTransform(
115
+ axis: axis,
116
+ size: renderSize,
117
+ originalTransform: preferredTransform
118
+ )
119
+
120
+ layerInstruction.setTransform(mirrorTransform, at: .zero)
121
+
122
+ instruction.layerInstructions = [layerInstruction]
123
+ videoComposition.instructions = [instruction]
124
+
125
+ return (composition, videoComposition)
126
+ }
127
+
128
+ // MARK: - Speed Adjustment
129
+
130
+ /// Create a composition with adjusted playback speed
131
+ /// - Parameters:
132
+ /// - asset: The video asset to adjust
133
+ /// - speed: Speed multiplier (0.25 = 4x slower, 2.0 = 2x faster, etc.)
134
+ /// - timeRange: Optional time range to apply (nil = full video)
135
+ /// - preservesPitch: Whether to preserve audio pitch when changing speed (default: true)
136
+ /// - Returns: A composition with adjusted speed
137
+ /// - Throws: VideoEditingError if speed adjustment fails
138
+ public func adjustSpeed(
139
+ asset: VideoAsset,
140
+ speed: Float,
141
+ timeRange: TimeRange? = nil,
142
+ preservesPitch: Bool = true
143
+ ) async throws -> AVMutableComposition {
144
+ guard speed > 0 else {
145
+ throw VideoEditingError.invalidConfiguration(
146
+ reason: "Speed must be greater than 0"
147
+ )
148
+ }
149
+
150
+ guard speed >= 0.0625 && speed <= 16.0 else {
151
+ throw VideoEditingError.invalidConfiguration(
152
+ reason: "Speed must be between 0.0625 (1/16x) and 16.0 (16x)"
153
+ )
154
+ }
155
+
156
+ let videoTracks = try await asset.avAsset.loadTracks(withMediaType: .video)
157
+ guard let sourceVideoTrack = videoTracks.first else {
158
+ throw VideoEditingError.noVideoTrack
159
+ }
160
+
161
+ let composition = AVMutableComposition()
162
+
163
+ // Determine time range
164
+ let effectiveRange: CMTimeRange
165
+ if let timeRange = timeRange {
166
+ try timeRange.validate(against: asset)
167
+ effectiveRange = timeRange.cmTimeRange
168
+ } else {
169
+ effectiveRange = CMTimeRange(start: .zero, duration: asset.duration)
170
+ }
171
+
172
+ // Add video track
173
+ guard let compositionVideoTrack = composition.addMutableTrack(
174
+ withMediaType: .video,
175
+ preferredTrackID: kCMPersistentTrackID_Invalid
176
+ ) else {
177
+ throw VideoEditingError.compositionFailed(
178
+ reason: "Failed to add video track to composition"
179
+ )
180
+ }
181
+
182
+ do {
183
+ try compositionVideoTrack.insertTimeRange(
184
+ effectiveRange,
185
+ of: sourceVideoTrack,
186
+ at: .zero
187
+ )
188
+
189
+ // Preserve the original transform
190
+ let preferredTransform = try await sourceVideoTrack.load(.preferredTransform)
191
+ compositionVideoTrack.preferredTransform = preferredTransform
192
+ } catch {
193
+ throw VideoEditingError.compositionFailed(
194
+ reason: "Failed to insert video track: \(error.localizedDescription)"
195
+ )
196
+ }
197
+
198
+ // Add audio track if present
199
+ let audioTracks = try? await asset.avAsset.loadTracks(withMediaType: .audio)
200
+ if let sourceAudioTrack = audioTracks?.first {
201
+ if let compositionAudioTrack = composition.addMutableTrack(
202
+ withMediaType: .audio,
203
+ preferredTrackID: kCMPersistentTrackID_Invalid
204
+ ) {
205
+ try? compositionAudioTrack.insertTimeRange(
206
+ effectiveRange,
207
+ of: sourceAudioTrack,
208
+ at: .zero
209
+ )
210
+ }
211
+ }
212
+
213
+ // Calculate the new duration
214
+ let originalDuration = effectiveRange.duration
215
+ let newDuration = CMTimeMultiplyByFloat64(originalDuration, multiplier: Float64(1.0 / speed))
216
+
217
+ // Scale the time range for all tracks in the composition
218
+ let compositionVideoTracks = composition.tracks(withMediaType: .video)
219
+ let compositionAudioTracks = composition.tracks(withMediaType: .audio)
220
+
221
+ for track in compositionVideoTracks {
222
+ track.scaleTimeRange(
223
+ CMTimeRange(start: .zero, duration: originalDuration),
224
+ toDuration: newDuration
225
+ )
226
+ }
227
+
228
+ for track in compositionAudioTracks {
229
+ track.scaleTimeRange(
230
+ CMTimeRange(start: .zero, duration: originalDuration),
231
+ toDuration: newDuration
232
+ )
233
+ }
234
+
235
+ return composition
236
+ }
237
+
238
+ /// Create a composition with adjusted speed and optional mirroring
239
+ /// - Parameters:
240
+ /// - asset: The video asset to transform
241
+ /// - speed: Speed multiplier
242
+ /// - mirrorAxis: Optional mirror axis
243
+ /// - timeRange: Optional time range
244
+ /// - Returns: A tuple of (composition, optional videoComposition)
245
+ /// - Throws: VideoEditingError if transformation fails
246
+ public func transform(
247
+ asset: VideoAsset,
248
+ speed: Float = 1.0,
249
+ mirrorAxis: MirrorAxis? = nil,
250
+ timeRange: TimeRange? = nil
251
+ ) async throws -> (AVMutableComposition, AVMutableVideoComposition?) {
252
+ // First apply mirroring if needed
253
+ if let axis = mirrorAxis {
254
+ let (composition, videoComposition) = try await mirror(
255
+ asset: asset,
256
+ axis: axis,
257
+ timeRange: timeRange
258
+ )
259
+
260
+ // Then apply speed adjustment if needed
261
+ if speed != 1.0 {
262
+ let originalDuration = composition.duration
263
+ let newDuration = CMTimeMultiplyByFloat64(originalDuration, multiplier: Float64(1.0 / speed))
264
+
265
+ for track in composition.tracks {
266
+ track.scaleTimeRange(
267
+ CMTimeRange(start: .zero, duration: originalDuration),
268
+ toDuration: newDuration
269
+ )
270
+ }
271
+
272
+ // Update video composition instruction time range
273
+ if let instruction = videoComposition.instructions.first as? AVMutableVideoCompositionInstruction {
274
+ instruction.timeRange = CMTimeRange(start: .zero, duration: newDuration)
275
+ }
276
+ }
277
+
278
+ return (composition, videoComposition)
279
+ } else if speed != 1.0 {
280
+ // Only speed adjustment
281
+ let composition = try await adjustSpeed(
282
+ asset: asset,
283
+ speed: speed,
284
+ timeRange: timeRange
285
+ )
286
+ return (composition, nil)
287
+ } else if let timeRange = timeRange {
288
+ // Just trimming
289
+ let videoTracks = try await asset.avAsset.loadTracks(withMediaType: .video)
290
+ guard let sourceVideoTrack = videoTracks.first else {
291
+ throw VideoEditingError.noVideoTrack
292
+ }
293
+
294
+ let composition = AVMutableComposition()
295
+ try timeRange.validate(against: asset)
296
+
297
+ guard let compositionVideoTrack = composition.addMutableTrack(
298
+ withMediaType: .video,
299
+ preferredTrackID: kCMPersistentTrackID_Invalid
300
+ ) else {
301
+ throw VideoEditingError.compositionFailed(reason: "Failed to add video track")
302
+ }
303
+
304
+ try compositionVideoTrack.insertTimeRange(
305
+ timeRange.cmTimeRange,
306
+ of: sourceVideoTrack,
307
+ at: .zero
308
+ )
309
+
310
+ let preferredTransform = try await sourceVideoTrack.load(.preferredTransform)
311
+ compositionVideoTrack.preferredTransform = preferredTransform
312
+
313
+ let audioTracks = try? await asset.avAsset.loadTracks(withMediaType: .audio)
314
+ if let sourceAudioTrack = audioTracks?.first {
315
+ if let compositionAudioTrack = composition.addMutableTrack(
316
+ withMediaType: .audio,
317
+ preferredTrackID: kCMPersistentTrackID_Invalid
318
+ ) {
319
+ try? compositionAudioTrack.insertTimeRange(
320
+ timeRange.cmTimeRange,
321
+ of: sourceAudioTrack,
322
+ at: .zero
323
+ )
324
+ }
325
+ }
326
+
327
+ return (composition, nil)
328
+ } else {
329
+ // No transformation needed, create a simple copy composition
330
+ throw VideoEditingError.invalidConfiguration(
331
+ reason: "No transformation specified"
332
+ )
333
+ }
334
+ }
335
+
336
+ // MARK: - Private Helpers
337
+
338
+ /// Create the transform matrix for mirroring
339
+ private func createMirrorTransform(
340
+ axis: MirrorAxis,
341
+ size: CGSize,
342
+ originalTransform: CGAffineTransform
343
+ ) -> CGAffineTransform {
344
+ var transform = originalTransform
345
+
346
+ switch axis {
347
+ case .horizontal:
348
+ // Flip horizontally: scale x by -1, translate to compensate
349
+ transform = transform.concatenating(CGAffineTransform(scaleX: -1, y: 1))
350
+ transform = transform.concatenating(CGAffineTransform(translationX: size.width, y: 0))
351
+
352
+ case .vertical:
353
+ // Flip vertically: scale y by -1, translate to compensate
354
+ transform = transform.concatenating(CGAffineTransform(scaleX: 1, y: -1))
355
+ transform = transform.concatenating(CGAffineTransform(translationX: 0, y: size.height))
356
+
357
+ case .both:
358
+ // Flip both axes
359
+ transform = transform.concatenating(CGAffineTransform(scaleX: -1, y: -1))
360
+ transform = transform.concatenating(CGAffineTransform(translationX: size.width, y: size.height))
361
+ }
362
+
363
+ return transform
364
+ }
365
+ }
@@ -0,0 +1,300 @@
1
+ import Foundation
2
+ import AVFoundation
3
+
4
+ /// Handles video trimming operations
5
+ public class VideoTrimmer {
6
+
7
+ /// Initialize a new VideoTrimmer
8
+ public init() {}
9
+
10
+ /// Trim a video asset to the specified time range
11
+ /// This creates a new composition with only the specified time range
12
+ /// - Parameters:
13
+ /// - asset: The video asset to trim
14
+ /// - timeRange: The time range to extract
15
+ /// - Returns: An AVMutableComposition containing the trimmed video
16
+ /// - Throws: VideoEditingError if trimming fails
17
+ public func trim(
18
+ asset: VideoAsset,
19
+ to timeRange: TimeRange
20
+ ) async throws -> AVMutableComposition {
21
+ // Validate the time range
22
+ try timeRange.validate(against: asset)
23
+
24
+ let composition = AVMutableComposition()
25
+
26
+ do {
27
+ // Add video track
28
+ try await addVideoTrack(
29
+ from: asset,
30
+ to: composition,
31
+ timeRange: timeRange
32
+ )
33
+
34
+ // Add audio track if present
35
+ if asset.hasAudio {
36
+ try await addAudioTrack(
37
+ from: asset,
38
+ to: composition,
39
+ timeRange: timeRange
40
+ )
41
+ }
42
+
43
+ return composition
44
+
45
+ } catch let error as VideoEditingError {
46
+ throw error
47
+ } catch {
48
+ throw VideoEditingError.compositionFailed(
49
+ reason: "Failed to create trimmed composition: \(error.localizedDescription)"
50
+ )
51
+ }
52
+ }
53
+
54
+ /// Create a composition with multiple trimmed segments
55
+ /// - Parameters:
56
+ /// - asset: The video asset to trim
57
+ /// - timeRanges: Array of time ranges to extract and concatenate
58
+ /// - Returns: An AVMutableComposition containing all segments
59
+ /// - Throws: VideoEditingError if trimming fails
60
+ public func trim(
61
+ asset: VideoAsset,
62
+ to timeRanges: [TimeRange]
63
+ ) async throws -> AVMutableComposition {
64
+ guard !timeRanges.isEmpty else {
65
+ throw VideoEditingError.invalidConfiguration(
66
+ reason: "At least one time range must be provided"
67
+ )
68
+ }
69
+
70
+ // Validate all time ranges
71
+ for timeRange in timeRanges {
72
+ try timeRange.validate(against: asset)
73
+ }
74
+
75
+ let composition = AVMutableComposition()
76
+
77
+ // Track the current insert time in the composition
78
+ var currentTime = CMTime.zero
79
+
80
+ do {
81
+ for timeRange in timeRanges {
82
+ // Add video segment
83
+ currentTime = try await addVideoTrack(
84
+ from: asset,
85
+ to: composition,
86
+ timeRange: timeRange,
87
+ atTime: currentTime
88
+ )
89
+
90
+ // Add audio segment if present
91
+ if asset.hasAudio {
92
+ _ = try await addAudioTrack(
93
+ from: asset,
94
+ to: composition,
95
+ timeRange: timeRange,
96
+ atTime: CMTimeSubtract(currentTime, timeRange.duration)
97
+ )
98
+ }
99
+ }
100
+
101
+ return composition
102
+
103
+ } catch let error as VideoEditingError {
104
+ throw error
105
+ } catch {
106
+ throw VideoEditingError.compositionFailed(
107
+ reason: "Failed to create multi-segment composition: \(error.localizedDescription)"
108
+ )
109
+ }
110
+ }
111
+
112
+ /// Create a looped composition from a video segment
113
+ /// - Parameters:
114
+ /// - asset: The video asset to loop
115
+ /// - loopConfig: Configuration specifying the segment and loop count
116
+ /// - Returns: An AVMutableComposition containing the looped video
117
+ /// - Throws: VideoEditingError if looping fails
118
+ public func loop(
119
+ asset: VideoAsset,
120
+ configuration loopConfig: LoopConfiguration
121
+ ) async throws -> AVMutableComposition {
122
+ // Validate the loop configuration
123
+ try loopConfig.validate(against: asset)
124
+
125
+ let composition = AVMutableComposition()
126
+
127
+ do {
128
+ var currentTime = CMTime.zero
129
+
130
+ // Insert the segment multiple times
131
+ for _ in 0..<loopConfig.totalPlays {
132
+ // Add video segment
133
+ currentTime = try await addVideoTrack(
134
+ from: asset,
135
+ to: composition,
136
+ timeRange: loopConfig.timeRange,
137
+ atTime: currentTime
138
+ )
139
+
140
+ // Reset to previous position for audio
141
+ let audioInsertTime = CMTimeSubtract(currentTime, loopConfig.timeRange.duration)
142
+
143
+ // Add audio segment if present
144
+ if asset.hasAudio {
145
+ _ = try await addAudioTrack(
146
+ from: asset,
147
+ to: composition,
148
+ timeRange: loopConfig.timeRange,
149
+ atTime: audioInsertTime
150
+ )
151
+ }
152
+ }
153
+
154
+ return composition
155
+
156
+ } catch let error as VideoEditingError {
157
+ throw error
158
+ } catch {
159
+ throw VideoEditingError.compositionFailed(
160
+ reason: "Failed to create looped composition: \(error.localizedDescription)"
161
+ )
162
+ }
163
+ }
164
+
165
+ /// Create a looped composition with additional options
166
+ /// - Parameters:
167
+ /// - asset: The video asset to loop
168
+ /// - startTime: Start time of the segment to loop (in seconds)
169
+ /// - endTime: End time of the segment to loop (in seconds)
170
+ /// - loopCount: Number of times to repeat the segment
171
+ /// - Returns: An AVMutableComposition containing the looped video
172
+ /// - Throws: VideoEditingError if looping fails
173
+ public func loop(
174
+ asset: VideoAsset,
175
+ startTime: Double,
176
+ endTime: Double,
177
+ loopCount: Int
178
+ ) async throws -> AVMutableComposition {
179
+ let config = try LoopConfiguration.fromSeconds(
180
+ start: startTime,
181
+ end: endTime,
182
+ loopCount: loopCount
183
+ )
184
+ return try await loop(asset: asset, configuration: config)
185
+ }
186
+
187
+ /// Get thumbnail images from a video at specific times
188
+ /// - Parameters:
189
+ /// - asset: The video asset
190
+ /// - times: Array of times to extract thumbnails
191
+ /// - size: Desired size for thumbnails (maintains aspect ratio)
192
+ /// - Returns: Array of CGImage thumbnails
193
+ /// - Throws: VideoEditingError if thumbnail generation fails
194
+ public func generateThumbnails(
195
+ from asset: VideoAsset,
196
+ at times: [CMTime],
197
+ size: CGSize = CGSize(width: 200, height: 200)
198
+ ) async throws -> [CGImage] {
199
+ let generator = AVAssetImageGenerator(asset: asset.avAsset)
200
+ generator.appliesPreferredTrackTransform = true
201
+ generator.maximumSize = size
202
+
203
+ var images: [CGImage] = []
204
+
205
+ for time in times {
206
+ do {
207
+ let cgImage = try await generator.image(at: time).image
208
+ images.append(cgImage)
209
+ } catch {
210
+ throw VideoEditingError.compositionFailed(
211
+ reason: "Failed to generate thumbnail at \(CMTimeGetSeconds(time))s: \(error.localizedDescription)"
212
+ )
213
+ }
214
+ }
215
+
216
+ return images
217
+ }
218
+
219
+ // MARK: - Private Helper Methods
220
+
221
+ /// Add a video track to the composition
222
+ @discardableResult
223
+ private func addVideoTrack(
224
+ from asset: VideoAsset,
225
+ to composition: AVMutableComposition,
226
+ timeRange: TimeRange,
227
+ atTime insertTime: CMTime = .zero
228
+ ) async throws -> CMTime {
229
+ let videoTracks = try await asset.avAsset.loadTracks(withMediaType: .video)
230
+ guard let sourceVideoTrack = videoTracks.first else {
231
+ throw VideoEditingError.noVideoTrack
232
+ }
233
+
234
+ guard let compositionVideoTrack = composition.addMutableTrack(
235
+ withMediaType: .video,
236
+ preferredTrackID: kCMPersistentTrackID_Invalid
237
+ ) else {
238
+ throw VideoEditingError.compositionFailed(
239
+ reason: "Failed to add video track to composition"
240
+ )
241
+ }
242
+
243
+ do {
244
+ try compositionVideoTrack.insertTimeRange(
245
+ timeRange.cmTimeRange,
246
+ of: sourceVideoTrack,
247
+ at: insertTime
248
+ )
249
+
250
+ // Preserve the original video track's preferred transform
251
+ let preferredTransform = try await sourceVideoTrack.load(.preferredTransform)
252
+ compositionVideoTrack.preferredTransform = preferredTransform
253
+
254
+ return CMTimeAdd(insertTime, timeRange.duration)
255
+
256
+ } catch {
257
+ throw VideoEditingError.compositionFailed(
258
+ reason: "Failed to insert video track: \(error.localizedDescription)"
259
+ )
260
+ }
261
+ }
262
+
263
+ /// Add an audio track to the composition
264
+ @discardableResult
265
+ private func addAudioTrack(
266
+ from asset: VideoAsset,
267
+ to composition: AVMutableComposition,
268
+ timeRange: TimeRange,
269
+ atTime insertTime: CMTime = .zero
270
+ ) async throws -> CMTime {
271
+ let audioTracks = try await asset.avAsset.loadTracks(withMediaType: .audio)
272
+ guard let sourceAudioTrack = audioTracks.first else {
273
+ throw VideoEditingError.noAudioTrack
274
+ }
275
+
276
+ guard let compositionAudioTrack = composition.addMutableTrack(
277
+ withMediaType: .audio,
278
+ preferredTrackID: kCMPersistentTrackID_Invalid
279
+ ) else {
280
+ throw VideoEditingError.compositionFailed(
281
+ reason: "Failed to add audio track to composition"
282
+ )
283
+ }
284
+
285
+ do {
286
+ try compositionAudioTrack.insertTimeRange(
287
+ timeRange.cmTimeRange,
288
+ of: sourceAudioTrack,
289
+ at: insertTime
290
+ )
291
+
292
+ return CMTimeAdd(insertTime, timeRange.duration)
293
+
294
+ } catch {
295
+ throw VideoEditingError.compositionFailed(
296
+ reason: "Failed to insert audio track: \(error.localizedDescription)"
297
+ )
298
+ }
299
+ }
300
+ }