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