@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,739 @@
|
|
|
1
|
+
import ExpoModulesCore
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import Photos
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
// MARK: - Bridge Module
|
|
7
|
+
|
|
8
|
+
/// Expo Module that bridges TwoStepVideo Swift library to JavaScript
|
|
9
|
+
public class ExpoTwoStepVideoModule: Module {
|
|
10
|
+
|
|
11
|
+
// MARK: - Properties
|
|
12
|
+
|
|
13
|
+
private let twoStep = TwoStepVideo()
|
|
14
|
+
private var activeAssets: [String: VideoAsset] = [:]
|
|
15
|
+
private var activeCompositions: [String: AVMutableComposition] = [:]
|
|
16
|
+
private var activeVideoCompositions: [String: AVMutableVideoComposition] = [:]
|
|
17
|
+
|
|
18
|
+
// MARK: - Module Definition
|
|
19
|
+
|
|
20
|
+
public func definition() -> ModuleDefinition {
|
|
21
|
+
|
|
22
|
+
// Module name for JavaScript
|
|
23
|
+
Name("ExpoTwoStepVideo")
|
|
24
|
+
|
|
25
|
+
// MARK: - Events
|
|
26
|
+
|
|
27
|
+
/// Event emitted during video export with progress updates
|
|
28
|
+
/// Event payload: { progress: number (0.0 to 1.0), assetId?: string }
|
|
29
|
+
Events("onExportProgress")
|
|
30
|
+
|
|
31
|
+
// MARK: - Constants
|
|
32
|
+
|
|
33
|
+
/// Quality preset constants
|
|
34
|
+
Property("QUALITY_LOW") { "low" }
|
|
35
|
+
Property("QUALITY_MEDIUM") { "medium" }
|
|
36
|
+
Property("QUALITY_HIGH") { "high" }
|
|
37
|
+
Property("QUALITY_HIGHEST") { "highest" }
|
|
38
|
+
|
|
39
|
+
/// Mirror axis constants
|
|
40
|
+
Property("MIRROR_HORIZONTAL") { "horizontal" }
|
|
41
|
+
Property("MIRROR_VERTICAL") { "vertical" }
|
|
42
|
+
Property("MIRROR_BOTH") { "both" }
|
|
43
|
+
|
|
44
|
+
// MARK: - Functions
|
|
45
|
+
|
|
46
|
+
/// Load a video asset from a file URI
|
|
47
|
+
/// - Parameter uri: File URI (e.g., "file:///path/to/video.mp4")
|
|
48
|
+
/// - Returns: Asset ID and metadata
|
|
49
|
+
AsyncFunction("loadAsset") { (uri: String, promise: Promise) in
|
|
50
|
+
Task {
|
|
51
|
+
do {
|
|
52
|
+
guard let url = URL(string: uri) else {
|
|
53
|
+
promise.reject("INVALID_URI", "Invalid file URI: \(uri)")
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let asset = try await self.twoStep.assetLoader.loadAsset(from: url)
|
|
58
|
+
let assetId = UUID().uuidString
|
|
59
|
+
|
|
60
|
+
// Store asset for later use
|
|
61
|
+
self.activeAssets[assetId] = asset
|
|
62
|
+
|
|
63
|
+
// Return asset info to JavaScript
|
|
64
|
+
promise.resolve([
|
|
65
|
+
"id": assetId,
|
|
66
|
+
"duration": CMTimeGetSeconds(asset.duration),
|
|
67
|
+
"width": asset.naturalSize?.width ?? 0,
|
|
68
|
+
"height": asset.naturalSize?.height ?? 0,
|
|
69
|
+
"frameRate": asset.frameRate ?? 0,
|
|
70
|
+
"hasAudio": asset.hasAudio
|
|
71
|
+
])
|
|
72
|
+
} catch let error as VideoEditingError {
|
|
73
|
+
promise.reject("LOAD_FAILED", error.localizedDescription)
|
|
74
|
+
} catch {
|
|
75
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/// Load a video asset from the Photos library
|
|
81
|
+
/// - Parameter localIdentifier: PHAsset local identifier
|
|
82
|
+
/// - Returns: Asset ID and metadata
|
|
83
|
+
AsyncFunction("loadAssetFromPhotos") { (localIdentifier: String, promise: Promise) in
|
|
84
|
+
Task {
|
|
85
|
+
do {
|
|
86
|
+
let fetchResult = PHAsset.fetchAssets(
|
|
87
|
+
withLocalIdentifiers: [localIdentifier],
|
|
88
|
+
options: nil
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
guard let phAsset = fetchResult.firstObject else {
|
|
92
|
+
promise.reject("NOT_FOUND", "PHAsset not found: \(localIdentifier)")
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let asset = try await self.twoStep.assetLoader.loadAsset(from: phAsset)
|
|
97
|
+
let assetId = UUID().uuidString
|
|
98
|
+
|
|
99
|
+
self.activeAssets[assetId] = asset
|
|
100
|
+
|
|
101
|
+
promise.resolve([
|
|
102
|
+
"id": assetId,
|
|
103
|
+
"duration": CMTimeGetSeconds(asset.duration),
|
|
104
|
+
"width": asset.naturalSize?.width ?? 0,
|
|
105
|
+
"height": asset.naturalSize?.height ?? 0,
|
|
106
|
+
"frameRate": asset.frameRate ?? 0,
|
|
107
|
+
"hasAudio": asset.hasAudio
|
|
108
|
+
])
|
|
109
|
+
} catch let error as VideoEditingError {
|
|
110
|
+
promise.reject("LOAD_FAILED", error.localizedDescription)
|
|
111
|
+
} catch {
|
|
112
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// Validate a video file URI without loading the full asset
|
|
118
|
+
/// - Parameter uri: File URI to validate
|
|
119
|
+
/// - Returns: Boolean indicating if valid
|
|
120
|
+
AsyncFunction("validateVideoUri") { (uri: String, promise: Promise) in
|
|
121
|
+
Task {
|
|
122
|
+
guard let url = URL(string: uri) else {
|
|
123
|
+
promise.resolve(false)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let isValid = await self.twoStep.assetLoader.validateVideoURL(url)
|
|
128
|
+
promise.resolve(isValid)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/// Trim a video to a single time range
|
|
133
|
+
/// - Parameters:
|
|
134
|
+
/// - assetId: ID of the loaded asset
|
|
135
|
+
/// - startTime: Start time in seconds
|
|
136
|
+
/// - endTime: End time in seconds
|
|
137
|
+
/// - Returns: Composition ID
|
|
138
|
+
AsyncFunction("trimVideo") { (assetId: String, startTime: Double, endTime: Double, promise: Promise) in
|
|
139
|
+
Task {
|
|
140
|
+
do {
|
|
141
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
142
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let timeRange = try TimeRange.fromSeconds(start: startTime, end: endTime)
|
|
147
|
+
let composition = try await self.twoStep.videoTrimmer.trim(
|
|
148
|
+
asset: asset,
|
|
149
|
+
to: timeRange
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
let compositionId = UUID().uuidString
|
|
153
|
+
self.activeCompositions[compositionId] = composition
|
|
154
|
+
|
|
155
|
+
promise.resolve([
|
|
156
|
+
"id": compositionId,
|
|
157
|
+
"duration": CMTimeGetSeconds(composition.duration)
|
|
158
|
+
])
|
|
159
|
+
} catch let error as VideoEditingError {
|
|
160
|
+
promise.reject("TRIM_FAILED", error.localizedDescription)
|
|
161
|
+
} catch {
|
|
162
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// Trim a video to multiple time ranges (creates segments)
|
|
168
|
+
/// - Parameters:
|
|
169
|
+
/// - assetId: ID of the loaded asset
|
|
170
|
+
/// - segments: Array of {start: number, end: number}
|
|
171
|
+
/// - Returns: Composition ID
|
|
172
|
+
AsyncFunction("trimVideoMultiple") { (assetId: String, segments: [[String: Double]], promise: Promise) in
|
|
173
|
+
Task {
|
|
174
|
+
do {
|
|
175
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
176
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Convert JS segments to TimeRanges
|
|
181
|
+
var timeRanges: [TimeRange] = []
|
|
182
|
+
for segment in segments {
|
|
183
|
+
guard let start = segment["start"],
|
|
184
|
+
let end = segment["end"] else {
|
|
185
|
+
promise.reject("INVALID_SEGMENT", "Segment missing start or end")
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
let range = try TimeRange.fromSeconds(start: start, end: end)
|
|
189
|
+
timeRanges.append(range)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let composition = try await self.twoStep.videoTrimmer.trim(
|
|
193
|
+
asset: asset,
|
|
194
|
+
to: timeRanges
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
let compositionId = UUID().uuidString
|
|
198
|
+
self.activeCompositions[compositionId] = composition
|
|
199
|
+
|
|
200
|
+
promise.resolve([
|
|
201
|
+
"id": compositionId,
|
|
202
|
+
"duration": CMTimeGetSeconds(composition.duration)
|
|
203
|
+
])
|
|
204
|
+
} catch let error as VideoEditingError {
|
|
205
|
+
promise.reject("TRIM_FAILED", error.localizedDescription)
|
|
206
|
+
} catch {
|
|
207
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/// Mirror a video horizontally, vertically, or both
|
|
213
|
+
/// - Parameters:
|
|
214
|
+
/// - assetId: ID of the loaded asset
|
|
215
|
+
/// - axis: Mirror axis: "horizontal", "vertical", or "both"
|
|
216
|
+
/// - startTime: Optional start time in seconds
|
|
217
|
+
/// - endTime: Optional end time in seconds
|
|
218
|
+
/// - Returns: Composition ID
|
|
219
|
+
AsyncFunction("mirrorVideo") { (assetId: String, axis: String, startTime: Double?, endTime: Double?, promise: Promise) in
|
|
220
|
+
Task {
|
|
221
|
+
do {
|
|
222
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
223
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Parse mirror axis
|
|
228
|
+
let mirrorAxis: VideoTransformer.MirrorAxis
|
|
229
|
+
switch axis.lowercased() {
|
|
230
|
+
case "horizontal": mirrorAxis = .horizontal
|
|
231
|
+
case "vertical": mirrorAxis = .vertical
|
|
232
|
+
case "both": mirrorAxis = .both
|
|
233
|
+
default:
|
|
234
|
+
promise.reject("INVALID_AXIS", "Invalid mirror axis: \(axis). Use 'horizontal', 'vertical', or 'both'")
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Parse optional time range
|
|
239
|
+
var timeRange: TimeRange? = nil
|
|
240
|
+
if let start = startTime, let end = endTime {
|
|
241
|
+
timeRange = try TimeRange.fromSeconds(start: start, end: end)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let (composition, videoComposition) = try await self.twoStep.videoTransformer.mirror(
|
|
245
|
+
asset: asset,
|
|
246
|
+
axis: mirrorAxis,
|
|
247
|
+
timeRange: timeRange
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
let compositionId = UUID().uuidString
|
|
251
|
+
self.activeCompositions[compositionId] = composition
|
|
252
|
+
self.activeVideoCompositions[compositionId] = videoComposition
|
|
253
|
+
|
|
254
|
+
promise.resolve([
|
|
255
|
+
"id": compositionId,
|
|
256
|
+
"duration": CMTimeGetSeconds(composition.duration)
|
|
257
|
+
])
|
|
258
|
+
} catch let error as VideoEditingError {
|
|
259
|
+
promise.reject("MIRROR_FAILED", error.localizedDescription)
|
|
260
|
+
} catch {
|
|
261
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/// Adjust playback speed of a video
|
|
267
|
+
/// - Parameters:
|
|
268
|
+
/// - assetId: ID of the loaded asset
|
|
269
|
+
/// - speed: Speed multiplier (0.25 = 4x slower, 2.0 = 2x faster)
|
|
270
|
+
/// - startTime: Optional start time in seconds
|
|
271
|
+
/// - endTime: Optional end time in seconds
|
|
272
|
+
/// - Returns: Composition ID
|
|
273
|
+
AsyncFunction("adjustSpeed") { (assetId: String, speed: Double, startTime: Double?, endTime: Double?, promise: Promise) in
|
|
274
|
+
Task {
|
|
275
|
+
do {
|
|
276
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
277
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Parse optional time range
|
|
282
|
+
var timeRange: TimeRange? = nil
|
|
283
|
+
if let start = startTime, let end = endTime {
|
|
284
|
+
timeRange = try TimeRange.fromSeconds(start: start, end: end)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
let composition = try await self.twoStep.videoTransformer.adjustSpeed(
|
|
288
|
+
asset: asset,
|
|
289
|
+
speed: Float(speed),
|
|
290
|
+
timeRange: timeRange
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
let compositionId = UUID().uuidString
|
|
294
|
+
self.activeCompositions[compositionId] = composition
|
|
295
|
+
|
|
296
|
+
promise.resolve([
|
|
297
|
+
"id": compositionId,
|
|
298
|
+
"duration": CMTimeGetSeconds(composition.duration)
|
|
299
|
+
])
|
|
300
|
+
} catch let error as VideoEditingError {
|
|
301
|
+
promise.reject("SPEED_FAILED", error.localizedDescription)
|
|
302
|
+
} catch {
|
|
303
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Transform a video with combined trimming, mirroring, and speed adjustment
|
|
309
|
+
/// - Parameters:
|
|
310
|
+
/// - assetId: ID of the loaded asset
|
|
311
|
+
/// - speed: Speed multiplier (default 1.0)
|
|
312
|
+
/// - mirrorAxis: Optional mirror axis: "horizontal", "vertical", or "both"
|
|
313
|
+
/// - startTime: Optional start time in seconds
|
|
314
|
+
/// - endTime: Optional end time in seconds
|
|
315
|
+
/// - Returns: Composition ID
|
|
316
|
+
AsyncFunction("transformVideo") { (assetId: String, speed: Double?, mirrorAxis: String?, startTime: Double?, endTime: Double?, promise: Promise) in
|
|
317
|
+
Task {
|
|
318
|
+
do {
|
|
319
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
320
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Parse mirror axis
|
|
325
|
+
var axis: VideoTransformer.MirrorAxis? = nil
|
|
326
|
+
if let mirrorAxisStr = mirrorAxis {
|
|
327
|
+
switch mirrorAxisStr.lowercased() {
|
|
328
|
+
case "horizontal": axis = .horizontal
|
|
329
|
+
case "vertical": axis = .vertical
|
|
330
|
+
case "both": axis = .both
|
|
331
|
+
default:
|
|
332
|
+
promise.reject("INVALID_AXIS", "Invalid mirror axis: \(mirrorAxisStr)")
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Parse optional time range
|
|
338
|
+
var timeRange: TimeRange? = nil
|
|
339
|
+
if let start = startTime, let end = endTime {
|
|
340
|
+
timeRange = try TimeRange.fromSeconds(start: start, end: end)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
let (composition, videoComposition) = try await self.twoStep.videoTransformer.transform(
|
|
344
|
+
asset: asset,
|
|
345
|
+
speed: Float(speed ?? 1.0),
|
|
346
|
+
mirrorAxis: axis,
|
|
347
|
+
timeRange: timeRange
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
let compositionId = UUID().uuidString
|
|
351
|
+
self.activeCompositions[compositionId] = composition
|
|
352
|
+
if let vc = videoComposition {
|
|
353
|
+
self.activeVideoCompositions[compositionId] = vc
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
promise.resolve([
|
|
357
|
+
"id": compositionId,
|
|
358
|
+
"duration": CMTimeGetSeconds(composition.duration)
|
|
359
|
+
])
|
|
360
|
+
} catch let error as VideoEditingError {
|
|
361
|
+
promise.reject("TRANSFORM_FAILED", error.localizedDescription)
|
|
362
|
+
} catch {
|
|
363
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/// Loop a segment of a video
|
|
369
|
+
/// - Parameters:
|
|
370
|
+
/// - assetId: ID of the loaded asset
|
|
371
|
+
/// - startTime: Start time of the segment to loop (in seconds)
|
|
372
|
+
/// - endTime: End time of the segment to loop (in seconds)
|
|
373
|
+
/// - loopCount: Number of times to repeat the segment (total plays = loopCount + 1)
|
|
374
|
+
/// - Returns: Composition ID
|
|
375
|
+
AsyncFunction("loopSegment") { (assetId: String, startTime: Double, endTime: Double, loopCount: Int, promise: Promise) in
|
|
376
|
+
Task {
|
|
377
|
+
do {
|
|
378
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
379
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let composition = try await self.twoStep.videoTrimmer.loop(
|
|
384
|
+
asset: asset,
|
|
385
|
+
startTime: startTime,
|
|
386
|
+
endTime: endTime,
|
|
387
|
+
loopCount: loopCount
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
let compositionId = UUID().uuidString
|
|
391
|
+
self.activeCompositions[compositionId] = composition
|
|
392
|
+
|
|
393
|
+
promise.resolve([
|
|
394
|
+
"id": compositionId,
|
|
395
|
+
"duration": CMTimeGetSeconds(composition.duration),
|
|
396
|
+
"loopCount": loopCount,
|
|
397
|
+
"totalPlays": loopCount + 1
|
|
398
|
+
])
|
|
399
|
+
} catch let error as VideoEditingError {
|
|
400
|
+
promise.reject("LOOP_FAILED", error.localizedDescription)
|
|
401
|
+
} catch {
|
|
402
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// Generate thumbnails from a video at specific times
|
|
408
|
+
/// - Parameters:
|
|
409
|
+
/// - assetId: ID of the loaded asset
|
|
410
|
+
/// - times: Array of times in seconds
|
|
411
|
+
/// - size: Optional size {width: number, height: number}
|
|
412
|
+
/// - Returns: Array of base64 encoded images
|
|
413
|
+
AsyncFunction("generateThumbnails") { (assetId: String, times: [Double], size: [String: Double]?, promise: Promise) in
|
|
414
|
+
Task {
|
|
415
|
+
do {
|
|
416
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
417
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Convert times to CMTime
|
|
422
|
+
let cmTimes = times.map { CMTime(seconds: $0, preferredTimescale: 600) }
|
|
423
|
+
|
|
424
|
+
// Determine size
|
|
425
|
+
var thumbnailSize = CGSize(width: 200, height: 200)
|
|
426
|
+
if let size = size,
|
|
427
|
+
let width = size["width"],
|
|
428
|
+
let height = size["height"] {
|
|
429
|
+
thumbnailSize = CGSize(width: width, height: height)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Generate thumbnails
|
|
433
|
+
let images = try await self.twoStep.videoTrimmer.generateThumbnails(
|
|
434
|
+
from: asset,
|
|
435
|
+
at: cmTimes,
|
|
436
|
+
size: thumbnailSize
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
// Convert to base64
|
|
440
|
+
let base64Images = images.compactMap { cgImage -> String? in
|
|
441
|
+
let uiImage = UIImage(cgImage: cgImage)
|
|
442
|
+
guard let pngData = uiImage.pngData() else {
|
|
443
|
+
return nil
|
|
444
|
+
}
|
|
445
|
+
return pngData.base64EncodedString()
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
promise.resolve(base64Images)
|
|
449
|
+
} catch let error as VideoEditingError {
|
|
450
|
+
promise.reject("THUMBNAIL_FAILED", error.localizedDescription)
|
|
451
|
+
} catch {
|
|
452
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/// Export a composition to a file
|
|
458
|
+
/// - Parameters:
|
|
459
|
+
/// - compositionId: ID of the composition to export
|
|
460
|
+
/// - outputUri: Output file URI (optional, uses temp if not provided)
|
|
461
|
+
/// - quality: Quality preset: "low", "medium", "high", "highest"
|
|
462
|
+
/// - Returns: Output file URI
|
|
463
|
+
AsyncFunction("exportVideo") { (compositionId: String, outputUri: String?, quality: String?, promise: Promise) in
|
|
464
|
+
Task {
|
|
465
|
+
do {
|
|
466
|
+
guard let composition = self.activeCompositions[compositionId] else {
|
|
467
|
+
promise.reject("COMPOSITION_NOT_FOUND", "Composition not found: \(compositionId)")
|
|
468
|
+
return
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Determine output URL
|
|
472
|
+
let outputURL: URL
|
|
473
|
+
if let outputUri = outputUri, let url = URL(string: outputUri) {
|
|
474
|
+
outputURL = url
|
|
475
|
+
} else {
|
|
476
|
+
// Use temp directory
|
|
477
|
+
let fileName = "export_\(UUID().uuidString).mp4"
|
|
478
|
+
outputURL = FileManager.default.temporaryDirectory
|
|
479
|
+
.appendingPathComponent(fileName)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Get optional video composition (for mirroring)
|
|
483
|
+
let videoComposition = self.activeVideoCompositions[compositionId]
|
|
484
|
+
|
|
485
|
+
// Export with or without video composition
|
|
486
|
+
let resultURL: URL
|
|
487
|
+
if let videoComposition = videoComposition {
|
|
488
|
+
// Export with video composition (for mirrored videos)
|
|
489
|
+
resultURL = try await self.exportWithVideoComposition(
|
|
490
|
+
composition: composition,
|
|
491
|
+
videoComposition: videoComposition,
|
|
492
|
+
outputURL: outputURL,
|
|
493
|
+
compositionId: compositionId
|
|
494
|
+
)
|
|
495
|
+
} else {
|
|
496
|
+
// Determine quality
|
|
497
|
+
let exportQuality: ExportConfiguration.Quality
|
|
498
|
+
switch quality?.lowercased() {
|
|
499
|
+
case "low": exportQuality = .low
|
|
500
|
+
case "medium": exportQuality = .medium
|
|
501
|
+
case "highest": exportQuality = .highest
|
|
502
|
+
default: exportQuality = .high
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Create config
|
|
506
|
+
let config = ExportConfiguration(
|
|
507
|
+
outputURL: outputURL,
|
|
508
|
+
quality: exportQuality
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
// Export with progress
|
|
512
|
+
resultURL = try await self.twoStep.videoExporter.export(
|
|
513
|
+
composition: composition,
|
|
514
|
+
configuration: config,
|
|
515
|
+
progressHandler: { [weak self] progress in
|
|
516
|
+
// Emit progress event to JavaScript
|
|
517
|
+
self?.sendEvent("onExportProgress", [
|
|
518
|
+
"progress": progress,
|
|
519
|
+
"compositionId": compositionId
|
|
520
|
+
])
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
promise.resolve([
|
|
526
|
+
"uri": resultURL.absoluteString,
|
|
527
|
+
"path": resultURL.path
|
|
528
|
+
])
|
|
529
|
+
} catch let error as VideoEditingError {
|
|
530
|
+
promise.reject("EXPORT_FAILED", error.localizedDescription)
|
|
531
|
+
} catch {
|
|
532
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/// Export an asset directly without composition
|
|
538
|
+
/// - Parameters:
|
|
539
|
+
/// - assetId: ID of the asset to export
|
|
540
|
+
/// - outputUri: Output file URI (optional)
|
|
541
|
+
/// - quality: Quality preset
|
|
542
|
+
/// - Returns: Output file URI
|
|
543
|
+
AsyncFunction("exportAsset") { (assetId: String, outputUri: String?, quality: String?, promise: Promise) in
|
|
544
|
+
Task {
|
|
545
|
+
do {
|
|
546
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
547
|
+
promise.reject("ASSET_NOT_FOUND", "Asset not found: \(assetId)")
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let outputURL: URL
|
|
552
|
+
if let outputUri = outputUri, let url = URL(string: outputUri) {
|
|
553
|
+
outputURL = url
|
|
554
|
+
} else {
|
|
555
|
+
let fileName = "export_\(UUID().uuidString).mp4"
|
|
556
|
+
outputURL = FileManager.default.temporaryDirectory
|
|
557
|
+
.appendingPathComponent(fileName)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let exportQuality: ExportConfiguration.Quality
|
|
561
|
+
switch quality?.lowercased() {
|
|
562
|
+
case "low": exportQuality = .low
|
|
563
|
+
case "medium": exportQuality = .medium
|
|
564
|
+
case "highest": exportQuality = .highest
|
|
565
|
+
default: exportQuality = .high
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
let config = ExportConfiguration(
|
|
569
|
+
outputURL: outputURL,
|
|
570
|
+
quality: exportQuality
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
let resultURL = try await self.twoStep.videoExporter.export(
|
|
574
|
+
asset: asset,
|
|
575
|
+
configuration: config,
|
|
576
|
+
progressHandler: { [weak self] progress in
|
|
577
|
+
self?.sendEvent("onExportProgress", [
|
|
578
|
+
"progress": progress,
|
|
579
|
+
"assetId": assetId
|
|
580
|
+
])
|
|
581
|
+
}
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
promise.resolve([
|
|
585
|
+
"uri": resultURL.absoluteString,
|
|
586
|
+
"path": resultURL.path
|
|
587
|
+
])
|
|
588
|
+
} catch let error as VideoEditingError {
|
|
589
|
+
promise.reject("EXPORT_FAILED", error.localizedDescription)
|
|
590
|
+
} catch {
|
|
591
|
+
promise.reject("UNKNOWN_ERROR", error.localizedDescription)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/// Clean up a temporary file
|
|
597
|
+
/// - Parameter uri: File URI to clean up
|
|
598
|
+
Function("cleanupFile") { (uri: String) in
|
|
599
|
+
guard let url = URL(string: uri) else { return }
|
|
600
|
+
self.twoStep.videoExporter.cleanupFile(at: url)
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/// Release an asset from memory
|
|
604
|
+
/// - Parameter assetId: ID of the asset to release
|
|
605
|
+
Function("releaseAsset") { (assetId: String) in
|
|
606
|
+
self.activeAssets.removeValue(forKey: assetId)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/// Release a composition from memory
|
|
610
|
+
/// - Parameter compositionId: ID of the composition to release
|
|
611
|
+
Function("releaseComposition") { (compositionId: String) in
|
|
612
|
+
self.activeCompositions.removeValue(forKey: compositionId)
|
|
613
|
+
self.activeVideoCompositions.removeValue(forKey: compositionId)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/// Release all cached assets and compositions
|
|
617
|
+
Function("releaseAll") {
|
|
618
|
+
self.activeAssets.removeAll()
|
|
619
|
+
self.activeCompositions.removeAll()
|
|
620
|
+
self.activeVideoCompositions.removeAll()
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// MARK: - Video Player View
|
|
624
|
+
|
|
625
|
+
View(ExpoTwostepVideoView.self) {
|
|
626
|
+
// Events emitted by the view
|
|
627
|
+
Events("onPlaybackStatusChange", "onProgress", "onEnd", "onError")
|
|
628
|
+
|
|
629
|
+
// Prop to set composition ID - view will load it
|
|
630
|
+
Prop("compositionId") { (view: ExpoTwostepVideoView, compositionId: String?) in
|
|
631
|
+
guard let compositionId = compositionId else { return }
|
|
632
|
+
guard let composition = self.activeCompositions[compositionId] else {
|
|
633
|
+
view.onError(["error": "Composition not found: \(compositionId)"])
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
let videoComposition = self.activeVideoCompositions[compositionId]
|
|
637
|
+
view.loadComposition(compositionId: compositionId, composition: composition, videoComposition: videoComposition)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Prop to set asset ID - view will load it for preview
|
|
641
|
+
Prop("assetId") { (view: ExpoTwostepVideoView, assetId: String?) in
|
|
642
|
+
guard let assetId = assetId else { return }
|
|
643
|
+
guard let asset = self.activeAssets[assetId] else {
|
|
644
|
+
view.onError(["error": "Asset not found: \(assetId)"])
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
view.loadAsset(assetId: assetId, asset: asset.avAsset)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Prop to enable continuous looping
|
|
651
|
+
Prop("loop") { (view: ExpoTwostepVideoView, loop: Bool?) in
|
|
652
|
+
view.shouldLoop = loop ?? false
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Playback control functions
|
|
656
|
+
AsyncFunction("play") { (view: ExpoTwostepVideoView) in
|
|
657
|
+
view.play()
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
AsyncFunction("pause") { (view: ExpoTwostepVideoView) in
|
|
661
|
+
view.pause()
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
AsyncFunction("seek") { (view: ExpoTwostepVideoView, time: Double) in
|
|
665
|
+
view.seek(to: time)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
AsyncFunction("replay") { (view: ExpoTwostepVideoView) in
|
|
669
|
+
view.replay()
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// MARK: - Private Helper Methods
|
|
675
|
+
|
|
676
|
+
/// Export a composition with a video composition (for mirrored videos)
|
|
677
|
+
private func exportWithVideoComposition(
|
|
678
|
+
composition: AVMutableComposition,
|
|
679
|
+
videoComposition: AVMutableVideoComposition,
|
|
680
|
+
outputURL: URL,
|
|
681
|
+
compositionId: String
|
|
682
|
+
) async throws -> URL {
|
|
683
|
+
// Remove existing file if present
|
|
684
|
+
if FileManager.default.fileExists(atPath: outputURL.path) {
|
|
685
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
guard let exportSession = AVAssetExportSession(
|
|
689
|
+
asset: composition,
|
|
690
|
+
presetName: AVAssetExportPresetHighestQuality
|
|
691
|
+
) else {
|
|
692
|
+
throw VideoEditingError.exportFailed(
|
|
693
|
+
reason: "Failed to create export session"
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
exportSession.outputURL = outputURL
|
|
698
|
+
exportSession.outputFileType = .mp4
|
|
699
|
+
exportSession.videoComposition = videoComposition
|
|
700
|
+
exportSession.shouldOptimizeForNetworkUse = true
|
|
701
|
+
|
|
702
|
+
// Start progress monitoring
|
|
703
|
+
let progressTask = Task {
|
|
704
|
+
while !Task.isCancelled && exportSession.status == .exporting {
|
|
705
|
+
self.sendEvent("onExportProgress", [
|
|
706
|
+
"progress": exportSession.progress,
|
|
707
|
+
"compositionId": compositionId
|
|
708
|
+
])
|
|
709
|
+
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Perform export
|
|
714
|
+
await exportSession.export()
|
|
715
|
+
|
|
716
|
+
// Cancel progress monitoring
|
|
717
|
+
progressTask.cancel()
|
|
718
|
+
|
|
719
|
+
// Check export status
|
|
720
|
+
switch exportSession.status {
|
|
721
|
+
case .completed:
|
|
722
|
+
return outputURL
|
|
723
|
+
|
|
724
|
+
case .failed:
|
|
725
|
+
throw VideoEditingError.exportFailed(
|
|
726
|
+
reason: "Export session failed",
|
|
727
|
+
underlyingError: exportSession.error
|
|
728
|
+
)
|
|
729
|
+
|
|
730
|
+
case .cancelled:
|
|
731
|
+
throw VideoEditingError.exportCancelled
|
|
732
|
+
|
|
733
|
+
default:
|
|
734
|
+
throw VideoEditingError.exportFailed(
|
|
735
|
+
reason: "Export ended with unexpected status: \(exportSession.status.rawValue)"
|
|
736
|
+
)
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|