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