@technotoil/image-video-editor 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 (99) hide show
  1. package/ImageVideoEditor.podspec +21 -0
  2. package/README.md +136 -0
  3. package/android/build.gradle +76 -0
  4. package/android/gradle.properties +5 -0
  5. package/android/src/main/AndroidManifest.xml +13 -0
  6. package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +67 -0
  7. package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +548 -0
  8. package/android/src/main/java/com/technotoil/image_videoeditor/MediaFileUtils.kt +29 -0
  9. package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +305 -0
  10. package/android/src/main/java/com/technotoil/image_videoeditor/MediaPackage.kt +26 -0
  11. package/android/src/main/java/com/technotoil/image_videoeditor/MediaPickerModule.kt +111 -0
  12. package/android/src/main/java/com/technotoil/image_videoeditor/MediaPlayerModule.kt +34 -0
  13. package/android/src/main/java/com/technotoil/image_videoeditor/RNCameraViewManager.kt +761 -0
  14. package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +317 -0
  15. package/ios/PrivacyInfo.xcprivacy +38 -0
  16. package/ios/RNCameraViewManager.m +420 -0
  17. package/ios/RNFrameGrabber.m +61 -0
  18. package/ios/RNMediaEditor.m +905 -0
  19. package/ios/RNMediaLibrary.m +389 -0
  20. package/ios/RNMediaPicker.m +144 -0
  21. package/ios/RNMediaPlayer.m +73 -0
  22. package/ios/RNVideoPreviewManager.m +263 -0
  23. package/ios/frames/film_vintage.png +0 -0
  24. package/ios/frames/floral_gold.png +0 -0
  25. package/ios/frames/minimal_double.png +0 -0
  26. package/ios/frames/polaroid_white.png +0 -0
  27. package/ios/frames/watercolor_floral.png +0 -0
  28. package/lib/module/assets/frames/film_vintage.png +0 -0
  29. package/lib/module/assets/frames/floral_gold.png +0 -0
  30. package/lib/module/assets/frames/minimal_double.png +0 -0
  31. package/lib/module/assets/frames/polaroid_white.png +0 -0
  32. package/lib/module/assets/frames/watercolor_floral.png +0 -0
  33. package/lib/module/components/VideoEditor.js +156 -0
  34. package/lib/module/components/VideoEditor.js.map +1 -0
  35. package/lib/module/index.js +4 -0
  36. package/lib/module/index.js.map +1 -0
  37. package/lib/module/native/CameraView.js +104 -0
  38. package/lib/module/native/CameraView.js.map +1 -0
  39. package/lib/module/native/FrameGrabber.js +13 -0
  40. package/lib/module/native/FrameGrabber.js.map +1 -0
  41. package/lib/module/native/MediaEditor.js +19 -0
  42. package/lib/module/native/MediaEditor.js.map +1 -0
  43. package/lib/module/native/MediaLibrary.js +37 -0
  44. package/lib/module/native/MediaLibrary.js.map +1 -0
  45. package/lib/module/native/MediaPicker.js +13 -0
  46. package/lib/module/native/MediaPicker.js.map +1 -0
  47. package/lib/module/native/MediaPlayer.js +13 -0
  48. package/lib/module/native/MediaPlayer.js.map +1 -0
  49. package/lib/module/native/VideoPreview.js +12 -0
  50. package/lib/module/native/VideoPreview.js.map +1 -0
  51. package/lib/module/package.json +1 -0
  52. package/lib/module/screens/CropScreen.js +1211 -0
  53. package/lib/module/screens/CropScreen.js.map +1 -0
  54. package/lib/module/screens/EditorScreen.js +5752 -0
  55. package/lib/module/screens/EditorScreen.js.map +1 -0
  56. package/lib/module/screens/ExportScreen.js +289 -0
  57. package/lib/module/screens/ExportScreen.js.map +1 -0
  58. package/lib/module/screens/GalleryScreen.js +505 -0
  59. package/lib/module/screens/GalleryScreen.js.map +1 -0
  60. package/lib/module/screens/PickScreen.js +1195 -0
  61. package/lib/module/screens/PickScreen.js.map +1 -0
  62. package/lib/module/types.js +2 -0
  63. package/lib/module/types.js.map +1 -0
  64. package/lib/typescript/src/components/VideoEditor.d.ts +13 -0
  65. package/lib/typescript/src/index.d.ts +2 -0
  66. package/lib/typescript/src/native/CameraView.d.ts +23 -0
  67. package/lib/typescript/src/native/FrameGrabber.d.ts +2 -0
  68. package/lib/typescript/src/native/MediaEditor.d.ts +3 -0
  69. package/lib/typescript/src/native/MediaLibrary.d.ts +16 -0
  70. package/lib/typescript/src/native/MediaPicker.d.ts +2 -0
  71. package/lib/typescript/src/native/MediaPlayer.d.ts +1 -0
  72. package/lib/typescript/src/native/VideoPreview.d.ts +19 -0
  73. package/lib/typescript/src/screens/CropScreen.d.ts +9 -0
  74. package/lib/typescript/src/screens/EditorScreen.d.ts +10 -0
  75. package/lib/typescript/src/screens/ExportScreen.d.ts +9 -0
  76. package/lib/typescript/src/screens/GalleryScreen.d.ts +8 -0
  77. package/lib/typescript/src/screens/PickScreen.d.ts +13 -0
  78. package/lib/typescript/src/types.d.ts +58 -0
  79. package/package.json +101 -0
  80. package/src/assets/frames/film_vintage.png +0 -0
  81. package/src/assets/frames/floral_gold.png +0 -0
  82. package/src/assets/frames/minimal_double.png +0 -0
  83. package/src/assets/frames/polaroid_white.png +0 -0
  84. package/src/assets/frames/watercolor_floral.png +0 -0
  85. package/src/components/VideoEditor.tsx +182 -0
  86. package/src/index.tsx +2 -0
  87. package/src/native/CameraView.tsx +95 -0
  88. package/src/native/FrameGrabber.ts +21 -0
  89. package/src/native/MediaEditor.ts +33 -0
  90. package/src/native/MediaLibrary.ts +69 -0
  91. package/src/native/MediaPicker.ts +17 -0
  92. package/src/native/MediaPlayer.ts +16 -0
  93. package/src/native/VideoPreview.tsx +20 -0
  94. package/src/screens/CropScreen.tsx +968 -0
  95. package/src/screens/EditorScreen.tsx +4517 -0
  96. package/src/screens/ExportScreen.tsx +282 -0
  97. package/src/screens/GalleryScreen.tsx +412 -0
  98. package/src/screens/PickScreen.tsx +1094 -0
  99. package/src/types.ts +58 -0
@@ -0,0 +1,905 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <AVFoundation/AVFoundation.h>
3
+ #import <CoreImage/CoreImage.h>
4
+ #import <UIKit/UIKit.h>
5
+ #import <Vision/Vision.h>
6
+
7
+ @interface RNMediaEditor : NSObject <RCTBridgeModule>
8
+ @end
9
+
10
+ @implementation RNMediaEditor {
11
+ CIContext *_ciContext;
12
+ }
13
+
14
+ RCT_EXPORT_MODULE(RNMediaEditor)
15
+
16
+ + (BOOL)requiresMainQueueSetup {
17
+ return NO;
18
+ }
19
+
20
+ - (instancetype)init {
21
+ if (self = [super init]) {
22
+ _ciContext = [CIContext contextWithOptions:nil];
23
+ }
24
+ return self;
25
+ }
26
+
27
+ - (NSURL *)cleanURL:(NSString *)uriString {
28
+ NSURL *url = [NSURL URLWithString:uriString];
29
+ if ([url.scheme isEqualToString:@"file"]) {
30
+ return [NSURL fileURLWithPath:url.path];
31
+ }
32
+ return url;
33
+ }
34
+
35
+ - (NSURL *)downloadToCache:(NSURL *)remoteURL {
36
+ if (!remoteURL) return nil;
37
+
38
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
39
+ __block NSURL *localURL = nil;
40
+
41
+ NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] downloadTaskWithURL:remoteURL completionHandler:^(NSURL *location, NSURLResponse *response, NSError *error) {
42
+ if (!error && location) {
43
+ NSString *tempName = [NSString stringWithFormat:@"music_%@.mp3", [NSUUID UUID].UUIDString];
44
+ NSString *tempPath = [NSTemporaryDirectory() stringByAppendingPathComponent:tempName];
45
+ NSURL *destinationURL = [NSURL fileURLWithPath:tempPath];
46
+
47
+ NSError *moveError = nil;
48
+ [[NSFileManager defaultManager] moveItemAtURL:location toURL:destinationURL error:&moveError];
49
+ if (!moveError) {
50
+ localURL = destinationURL;
51
+ }
52
+ }
53
+ dispatch_semaphore_signal(sema);
54
+ }];
55
+ [task resume];
56
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
57
+
58
+ return localURL;
59
+ }
60
+
61
+ RCT_REMAP_METHOD(editImage,
62
+ editImageWithUri:(NSString *)uriString
63
+ options:(NSDictionary *)options
64
+ resolver:(RCTPromiseResolveBlock)resolve
65
+ rejecter:(RCTPromiseRejectBlock)reject)
66
+ {
67
+ NSURL *url = [self cleanURL:uriString];
68
+ if (!url) {
69
+ reject(@"bad_uri", @"Invalid image uri", nil);
70
+ return;
71
+ }
72
+
73
+ NSData *data = [NSData dataWithContentsOfURL:url];
74
+ UIImage *rawImage = data ? [UIImage imageWithData:data] : nil;
75
+ if (!rawImage) {
76
+ NSString *reason = [NSString stringWithFormat:@"Could not decode image from uri: %@", uriString];
77
+ reject(@"decode_failed", reason, nil);
78
+ return;
79
+ }
80
+
81
+ // Normalize EXIF orientation to avoid coordinate system mismatches when applying crops
82
+ UIGraphicsBeginImageContextWithOptions(rawImage.size, NO, 1.0);
83
+ [rawImage drawInRect:CGRectMake(0, 0, rawImage.size.width, rawImage.size.height)];
84
+ UIImage *originalImage = UIGraphicsGetImageFromCurrentImageContext();
85
+ UIGraphicsEndImageContext();
86
+
87
+ CIImage *ciImage = [[CIImage alloc] initWithImage:originalImage];
88
+
89
+ // 1. Transform (Rotate/Flip)
90
+ NSNumber *rotateDegrees = options[@"rotateDegrees"] ?: @0;
91
+ BOOL flipX = [options[@"flipX"] boolValue];
92
+ BOOL flipY = [options[@"flipY"] boolValue];
93
+
94
+ if (rotateDegrees.intValue != 0 || flipX || flipY) {
95
+ CGAffineTransform transform = CGAffineTransformIdentity;
96
+ if (flipX) transform = CGAffineTransformScale(transform, -1, 1);
97
+ if (flipY) transform = CGAffineTransformScale(transform, 1, -1);
98
+ if (rotateDegrees.intValue != 0) {
99
+ CGFloat radians = (CGFloat)(rotateDegrees.doubleValue * M_PI / 180.0);
100
+ transform = CGAffineTransformRotate(transform, radians);
101
+ }
102
+ ciImage = [ciImage imageByApplyingTransform:transform];
103
+ // Normalize transformed origin to 0,0 to prevent coordinate shifts during crop
104
+ ciImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeTranslation(-ciImage.extent.origin.x, -ciImage.extent.origin.y)];
105
+ }
106
+
107
+ // 2. Final Crop / Canvas Logic
108
+ NSDictionary *crop = options[@"crop"];
109
+ if ([crop isKindOfClass:NSDictionary.class] && crop.count > 0) {
110
+ CGFloat cx = [crop[@"x"] doubleValue];
111
+ CGFloat cy = [crop[@"y"] doubleValue];
112
+ CGFloat cw = [crop[@"width"] doubleValue];
113
+ CGFloat ch = [crop[@"height"] doubleValue];
114
+
115
+ CGFloat maxW = ciImage.extent.size.width;
116
+ CGFloat maxH = ciImage.extent.size.height;
117
+
118
+ if (maxW > 0 && maxH > 0) {
119
+ if (cx < 0) cx = 0;
120
+ if (cy < 0) cy = 0;
121
+ if (cx + cw > maxW) cw = maxW - cx;
122
+ if (cy + ch > maxH) ch = maxH - cy;
123
+
124
+ if (cw > 0 && ch > 0) {
125
+ CGRect canvasRect = CGRectMake(0, 0, cw, ch);
126
+ CIImage *blackBase = [[CIImage imageWithColor:[CIColor blackColor]] imageByCroppingToRect:canvasRect];
127
+
128
+ CGFloat imgH = ciImage.extent.size.height;
129
+ CGFloat ciOriginX = -cx;
130
+ CGFloat ciOriginY = ch - (imgH - cy);
131
+
132
+ CIImage *positionedImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeTranslation(ciOriginX, ciOriginY)];
133
+ ciImage = [positionedImage imageByCompositingOverImage:blackBase];
134
+ ciImage = [ciImage imageByCroppingToRect:canvasRect];
135
+ ciImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeTranslation(-ciImage.extent.origin.x, -ciImage.extent.origin.y)];
136
+ }
137
+ }
138
+ }
139
+
140
+ // 3. Apply Frame after crop
141
+ NSString *frameKey = options[@"frame"];
142
+ BOOL frameApplied = NO;
143
+ if ([frameKey isKindOfClass:NSString.class] && frameKey.length > 0) {
144
+ // Bulletproof frame loading:
145
+ // 1. Try Assets Catalog / Flat Bundle
146
+ UIImage *uiFrame = [UIImage imageNamed:frameKey];
147
+
148
+ // 2. Try various bundle paths
149
+ if (!uiFrame) {
150
+ NSArray *searchPaths = @[
151
+ [[NSBundle mainBundle] pathForResource:frameKey ofType:@"png" inDirectory:@"frames"],
152
+ [[NSBundle mainBundle] pathForResource:frameKey ofType:@"png" inDirectory:@"videoEditor/frames"],
153
+ [[NSBundle mainBundle] pathForResource:frameKey ofType:@"png"]
154
+ ];
155
+ for (NSString *p in searchPaths) {
156
+ if (p) {
157
+ uiFrame = [UIImage imageWithContentsOfFile:p];
158
+ if (uiFrame) break;
159
+ }
160
+ }
161
+ }
162
+
163
+ if (uiFrame) {
164
+ NSLog(@"[RNMediaEditor] Frame loaded successfully: %@ size: %.0fx%.0f", frameKey, uiFrame.size.width, uiFrame.size.height);
165
+ CIImage *frameImage = [CIImage imageWithCGImage:uiFrame.CGImage];
166
+ if (frameImage) {
167
+ // Ensure frame starts at 0,0
168
+ frameImage = [frameImage imageByApplyingTransform:CGAffineTransformMakeTranslation(-frameImage.extent.origin.x, -frameImage.extent.origin.y)];
169
+
170
+ CGFloat scaleX = ciImage.extent.size.width / frameImage.extent.size.width;
171
+ CGFloat scaleY = ciImage.extent.size.height / frameImage.extent.size.height;
172
+ CIImage *scaledFrame = [frameImage imageByApplyingTransform:CGAffineTransformMakeScale(scaleX, scaleY)];
173
+
174
+ // Scale down original image slightly to fit inside the frame opening
175
+ CGFloat insetScale = [options[@"frameScale"] doubleValue] ?: 0.88;
176
+ CGFloat offsetYRatio = [options[@"frameOffsetY"] doubleValue] ?: 0.0;
177
+
178
+ CGFloat tx = ciImage.extent.size.width * (1.0 - insetScale) / 2.0;
179
+ CGFloat ty = ciImage.extent.size.height * (1.0 - insetScale) / 2.0;
180
+
181
+ // Apply relative vertical offset
182
+ CGFloat extraTY = ciImage.extent.size.height * offsetYRatio;
183
+
184
+ CGAffineTransform insetTransform = CGAffineTransformConcat(
185
+ CGAffineTransformMakeScale(insetScale, insetScale),
186
+ CGAffineTransformMakeTranslation(tx, ty + extraTY)
187
+ );
188
+
189
+ // Normalize photo origin after potential crop
190
+ ciImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeTranslation(-ciImage.extent.origin.x, -ciImage.extent.origin.y)];
191
+
192
+ CGRect targetRect = scaledFrame.extent;
193
+ // Use an opaque black base so that transparent frame areas render as black.
194
+ CIImage *opaqueBase = [[CIImage imageWithColor:[CIColor colorWithRed:0 green:0 blue:0 alpha:1]] imageByCroppingToRect:targetRect];
195
+
196
+ // 1. Photo over opaque base (at 0,0)
197
+ CIImage *photoLayer = [[ciImage imageByApplyingTransform:insetTransform] imageByCompositingOverImage:opaqueBase];
198
+
199
+ // 2. Frame over photo
200
+ ciImage = [scaledFrame imageByCompositingOverImage:photoLayer];
201
+
202
+ // Reset result to 0,0 for final render
203
+ ciImage = [ciImage imageByApplyingTransform:CGAffineTransformMakeTranslation(-ciImage.extent.origin.x, -ciImage.extent.origin.y)];
204
+ frameApplied = YES;
205
+ }
206
+ } else {
207
+ NSLog(@"[RNMediaEditor] ERROR: Frame image NOT FOUND for key: %@", frameKey);
208
+ }
209
+ }
210
+
211
+ // 4. Adjustments (Brightness, Contrast, Saturation)
212
+ NSNumber *brightness = options[@"brightness"] ?: @0;
213
+ NSNumber *contrast = options[@"contrast"] ?: @1;
214
+ NSNumber *saturation = options[@"saturation"] ?: @1;
215
+ BOOL grayscale = [options[@"grayscale"] boolValue];
216
+
217
+ CIFilter *controlsFilter = [CIFilter filterWithName:@"CIColorControls"];
218
+ [controlsFilter setValue:ciImage forKey:kCIInputImageKey];
219
+ [controlsFilter setValue:(grayscale ? @0 : saturation) forKey:kCIInputSaturationKey];
220
+ [controlsFilter setValue:brightness forKey:kCIInputBrightnessKey];
221
+ [controlsFilter setValue:contrast forKey:kCIInputContrastKey];
222
+
223
+ CIImage *outputCI = controlsFilter.outputImage ?: ciImage;
224
+
225
+ NSString *arFilter = options[@"arFilter"];
226
+ if (arFilter != nil && arFilter.length > 0) {
227
+ outputCI = [self applyARFilter:arFilter toCIImage:outputCI];
228
+ }
229
+
230
+ // 3. Render into a temporary bitmap image to support drawing overlays
231
+ CGRect renderExtent = outputCI.extent;
232
+ if (CGRectIsInfinite(renderExtent) || CGRectIsEmpty(renderExtent)) {
233
+ renderExtent = ciImage.extent;
234
+ }
235
+
236
+ CGImageRef cgImage = [_ciContext createCGImage:outputCI fromRect:renderExtent];
237
+ if (!cgImage) {
238
+ reject(@"render_failed", @"Failed to render CoreImage", nil);
239
+ return;
240
+ }
241
+ UIImage *workingImage = [UIImage imageWithCGImage:cgImage];
242
+ CGImageRelease(cgImage);
243
+
244
+ // 4. Draw Overlays (Color Tints, Text)
245
+ UIGraphicsBeginImageContextWithOptions(workingImage.size, NO, 1.0);
246
+ [workingImage drawAtPoint:CGPointZero];
247
+
248
+ CGContextRef ctx = UIGraphicsGetCurrentContext();
249
+ CGRect fullRect = CGRectMake(0, 0, workingImage.size.width, workingImage.size.height);
250
+
251
+ // Tint color UI replica
252
+ if (options[@"tintColor"] && options[@"tintOpacity"]) {
253
+ NSString *hexString = options[@"tintColor"];
254
+ CGFloat tintOp = [options[@"tintOpacity"] doubleValue];
255
+ if (tintOp > 0 && [hexString hasPrefix:@"#"]) {
256
+ unsigned rgbValue = 0;
257
+ NSScanner *scanner = [NSScanner scannerWithString:hexString];
258
+ [scanner setScanLocation:1];
259
+ [scanner scanHexInt:&rgbValue];
260
+ UIColor *tintColor = [UIColor colorWithRed:((rgbValue & 0xFF0000) >> 16)/255.0
261
+ green:((rgbValue & 0xFF00) >> 8)/255.0
262
+ blue:(rgbValue & 0xFF)/255.0
263
+ alpha:tintOp];
264
+ [tintColor setFill];
265
+ CGContextFillRect(ctx, fullRect);
266
+ }
267
+ }
268
+
269
+ NSArray *overlays = options[@"overlays"];
270
+ if ([overlays isKindOfClass:NSArray.class] && overlays.count > 0) {
271
+
272
+ for (NSDictionary *overlay in overlays) {
273
+ NSString *text = overlay[@"text"];
274
+ NSNumber *x = overlay[@"x"];
275
+ NSNumber *y = overlay[@"y"];
276
+ NSNumber *fontSize = overlay[@"fontSize"] ?: @24;
277
+ NSString *colorHex = overlay[@"color"] ?: @"#FFFFFF";
278
+
279
+ if (text && x && y) {
280
+ UIColor *color = [self colorFromHexString:colorHex];
281
+ NSDictionary *attrs = @{
282
+ NSFontAttributeName: [UIFont boldSystemFontOfSize:fontSize.floatValue],
283
+ NSForegroundColorAttributeName: color
284
+ };
285
+ [text drawAtPoint:CGPointMake(x.doubleValue, y.doubleValue) withAttributes:attrs];
286
+ }
287
+ }
288
+ }
289
+
290
+ workingImage = UIGraphicsGetImageFromCurrentImageContext();
291
+ UIGraphicsEndImageContext();
292
+
293
+ // 5. Save to persistent directory
294
+ NSString *ext = frameApplied ? @"png" : @"jpg";
295
+ NSString *fileName = [NSString stringWithFormat:@"edited_%@.%@", [[NSUUID UUID] UUIDString], ext];
296
+
297
+ NSString *docsDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
298
+ NSString *editedPath = [docsDir stringByAppendingPathComponent:@"edited_media"];
299
+
300
+ NSFileManager *fm = [NSFileManager defaultManager];
301
+ if (![fm fileExistsAtPath:editedPath]) {
302
+ [fm createDirectoryAtPath:editedPath withIntermediateDirectories:YES attributes:nil error:nil];
303
+ }
304
+
305
+ NSString *outPath = [editedPath stringByAppendingPathComponent:fileName];
306
+ NSURL *outUrl = [NSURL fileURLWithPath:outPath];
307
+
308
+ NSData *outData;
309
+ if (frameApplied) {
310
+ outData = UIImagePNGRepresentation(workingImage);
311
+ } else {
312
+ outData = UIImageJPEGRepresentation(workingImage, 0.9);
313
+ }
314
+ if (outData) {
315
+ [outData writeToURL:outUrl atomically:YES];
316
+ resolve(outUrl.absoluteString);
317
+ } else {
318
+ reject(@"write_failed", @"Failed to write edited image to disk", nil);
319
+ }
320
+ }
321
+
322
+ - (CIImage *)applyARFilter:(NSString *)filter toCIImage:(CIImage *)ciImage {
323
+ VNSequenceRequestHandler *handler = [[VNSequenceRequestHandler alloc] init];
324
+ VNDetectFaceRectanglesRequest *request = [[VNDetectFaceRectanglesRequest alloc] init];
325
+ NSError *error = nil;
326
+ [handler performRequests:@[request] onCIImage:ciImage error:&error];
327
+ if (error || request.results.count == 0) return ciImage;
328
+
329
+ CIImage *result = ciImage;
330
+ NSString *emoji = [filter isEqualToString:@"sunglasses"] ? @"🕶️" : ([filter isEqualToString:@"dog"] ? @"🐶" : @"🤓");
331
+
332
+ for (VNFaceObservation *face in request.results) {
333
+ CGRect bb = face.boundingBox;
334
+ CGFloat extentWidth = result.extent.size.width;
335
+ CGFloat extentHeight = result.extent.size.height;
336
+
337
+ CGRect faceRect = CGRectMake(bb.origin.x * extentWidth, bb.origin.y * extentHeight, bb.size.width * extentWidth, bb.size.height * extentHeight);
338
+
339
+ CGFloat scaleMultiplier = [filter isEqualToString:@"sunglasses"] ? 1.0 : 1.3;
340
+ CGRect targetRect = CGRectMake(0, 0, faceRect.size.width * scaleMultiplier, faceRect.size.height * scaleMultiplier);
341
+
342
+ UIGraphicsBeginImageContextWithOptions(targetRect.size, NO, 1.0);
343
+ UIFont *font = [UIFont systemFontOfSize:targetRect.size.height * 0.8];
344
+
345
+ CGFloat yOffset = targetRect.size.height * 0.1;
346
+ if ([filter isEqualToString:@"sunglasses"]) { yOffset = targetRect.size.height * 0.3; }
347
+
348
+ NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
349
+ style.alignment = NSTextAlignmentCenter;
350
+ [emoji drawInRect:CGRectMake(0, yOffset, targetRect.size.width, targetRect.size.height)
351
+ withAttributes:@{NSFontAttributeName: font, NSParagraphStyleAttributeName: style}];
352
+ UIImage *emojiImage = UIGraphicsGetImageFromCurrentImageContext();
353
+ UIGraphicsEndImageContext();
354
+
355
+ CIImage *emojiCI = [[CIImage alloc] initWithImage:emojiImage];
356
+ emojiCI = [emojiCI imageByApplyingTransform:CGAffineTransformMakeScale(1, -1)];
357
+ emojiCI = [emojiCI imageByApplyingTransform:CGAffineTransformMakeTranslation(0, targetRect.size.height)];
358
+
359
+ CGFloat xTrans = faceRect.origin.x - (targetRect.size.width - faceRect.size.width) / 2.0;
360
+ CGFloat yTrans = faceRect.origin.y - (targetRect.size.height - faceRect.size.height) / 2.0;
361
+
362
+ emojiCI = [emojiCI imageByApplyingTransform:CGAffineTransformMakeTranslation(xTrans, yTrans)];
363
+ result = [emojiCI imageByCompositingOverImage:result];
364
+ }
365
+ return result;
366
+ }
367
+
368
+ - (UIColor *)colorFromHexString:(NSString *)hex {
369
+ unsigned rgbValue = 0;
370
+ NSScanner *scanner = [NSScanner scannerWithString:hex];
371
+ if ([hex hasPrefix:@"#"]) scanner.scanLocation = 1;
372
+ [scanner scanHexInt:&rgbValue];
373
+ return [UIColor colorWithRed:((rgbValue >> 16) & 0xFF) / 255.0
374
+ green:((rgbValue >> 8) & 0xFF) / 255.0
375
+ blue:(rgbValue & 0xFF) / 255.0
376
+ alpha:1.0];
377
+ }
378
+
379
+ RCT_REMAP_METHOD(trimVideo,
380
+ trimVideoWithUri:(NSString *)uriString
381
+ options:(NSDictionary *)options
382
+ resolver:(RCTPromiseResolveBlock)resolve
383
+ rejecter:(RCTPromiseRejectBlock)reject)
384
+ {
385
+ NSURL *url = [self cleanURL:uriString];
386
+ if (!url) {
387
+ reject(@"bad_uri", @"Invalid video uri", nil);
388
+ return;
389
+ }
390
+
391
+ BOOL isImage = [options[@"isImage"] boolValue];
392
+ NSString *musicUri = options[@"musicUri"];
393
+
394
+ AVAsset *asset = nil;
395
+ CMTimeRange range;
396
+ __block NSString *tempVideoToDelete = nil;
397
+ __block NSURL *tempMusicToDelete = nil;
398
+
399
+ if (isImage) {
400
+ UIImage *image = [UIImage imageWithContentsOfFile:url.path];
401
+ if (!image) {
402
+ reject(@"bad_image", @"Could not load image", nil);
403
+ return;
404
+ }
405
+ NSString *tempVideoName = [NSString stringWithFormat:@"temp_img_video_%@.mp4", [NSUUID UUID].UUIDString];
406
+ NSString *tempVideoPath = [NSTemporaryDirectory() stringByAppendingPathComponent:tempVideoName];
407
+ tempVideoToDelete = tempVideoPath;
408
+
409
+ dispatch_semaphore_t sema = dispatch_semaphore_create(0);
410
+ __block NSError *writeError = nil;
411
+ [self createVideoFromImage:image duration:10.0 outputPath:tempVideoPath completion:^(BOOL success, NSError *err) {
412
+ writeError = err;
413
+ dispatch_semaphore_signal(sema);
414
+ }];
415
+ dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
416
+
417
+ if (writeError) {
418
+ reject(@"image_to_video_failed", writeError.localizedDescription, writeError);
419
+ return;
420
+ }
421
+
422
+ asset = [AVAsset assetWithURL:[NSURL fileURLWithPath:tempVideoPath]];
423
+ range = CMTimeRangeMake(kCMTimeZero, CMTimeMakeWithSeconds(10.0, 600));
424
+ } else {
425
+ asset = [AVAsset assetWithURL:url];
426
+ double startMs = [options[@"startMs"] doubleValue];
427
+ double endMs = [options[@"endMs"] doubleValue];
428
+ CMTime startTime = CMTimeMakeWithSeconds(startMs / 1000.0, 600);
429
+ CMTime endTime = CMTimeMakeWithSeconds(endMs / 1000.0, 600);
430
+ range = CMTimeRangeFromTimeToTime(startTime, endTime);
431
+ }
432
+
433
+ BOOL mute = [options[@"mute"] boolValue];
434
+
435
+ // Use AVMutableComposition to handle Mute by optionally adding audio tracks
436
+ AVMutableComposition *composition = [AVMutableComposition composition];
437
+
438
+ // Video track
439
+ AVMutableCompositionTrack *videoTrack = [composition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
440
+ NSArray<AVAssetTrack *> *origVideoTracks = [asset tracksWithMediaType:AVMediaTypeVideo];
441
+ if (origVideoTracks.count > 0) {
442
+ AVAssetTrack *track = origVideoTracks.firstObject;
443
+ CMTime trackDuration = track.timeRange.duration;
444
+ CMTime startTime = CMTimeMinimum(range.start, trackDuration);
445
+ CMTime duration = CMTimeMinimum(range.duration, CMTimeSubtract(trackDuration, startTime));
446
+ CMTimeRange safeRange = CMTimeRangeMake(startTime, duration);
447
+ [videoTrack insertTimeRange:safeRange ofTrack:track atTime:kCMTimeZero error:nil];
448
+ videoTrack.preferredTransform = track.preferredTransform;
449
+ }
450
+
451
+ // Audio track (only if not muted)
452
+ if (!mute) {
453
+ NSArray<AVAssetTrack *> *origAudioTracks = [asset tracksWithMediaType:AVMediaTypeAudio];
454
+ if (origAudioTracks.count > 0) {
455
+ AVMutableCompositionTrack *audioTrack = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
456
+ AVAssetTrack *track = origAudioTracks.firstObject;
457
+ CMTime trackDuration = track.timeRange.duration;
458
+ CMTime startTime = CMTimeMinimum(range.start, trackDuration);
459
+ CMTime duration = CMTimeMinimum(range.duration, CMTimeSubtract(trackDuration, startTime));
460
+ CMTimeRange safeRange = CMTimeRangeMake(startTime, duration);
461
+ [audioTrack insertTimeRange:safeRange ofTrack:track atTime:kCMTimeZero error:nil];
462
+ }
463
+ }
464
+
465
+ // Music track overlay/mix
466
+ if (musicUri && musicUri.length > 0) {
467
+ NSURL *musicURL = [self cleanURL:musicUri];
468
+ if ([musicURL.scheme isEqualToString:@"http"] || [musicURL.scheme isEqualToString:@"https"]) {
469
+ NSURL *cachedURL = [self downloadToCache:musicURL];
470
+ if (cachedURL) {
471
+ musicURL = cachedURL;
472
+ tempMusicToDelete = cachedURL;
473
+ }
474
+ }
475
+ if (musicURL) {
476
+ AVAsset *musicAsset = [AVAsset assetWithURL:musicURL];
477
+ NSArray<AVAssetTrack *> *musicAudioTracks = [musicAsset tracksWithMediaType:AVMediaTypeAudio];
478
+ if (musicAudioTracks.count > 0) {
479
+ AVMutableCompositionTrack *musicCompositionTrack = [composition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
480
+ CMTime musicDuration = musicAsset.duration;
481
+ CMTime targetDuration = range.duration;
482
+ CMTime insertDuration = CMTimeMinimum(targetDuration, musicDuration);
483
+ [musicCompositionTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, insertDuration) ofTrack:musicAudioTracks.firstObject atTime:kCMTimeZero error:nil];
484
+ }
485
+ }
486
+ }
487
+
488
+
489
+ AVAssetExportSession *export = [[AVAssetExportSession alloc] initWithAsset:composition presetName:AVAssetExportPresetHighestQuality];
490
+ if (!export) {
491
+ reject(@"export_failed", @"Could not create export session", nil);
492
+ return;
493
+ }
494
+
495
+ // --- Filtering Logic (using CI filters) ---
496
+ NSNumber *brightness = options[@"brightness"] ?: @0;
497
+ NSNumber *contrast = options[@"contrast"] ?: @1;
498
+ NSNumber *saturation = options[@"saturation"] ?: @1;
499
+ BOOL grayscale = [options[@"grayscale"] boolValue];
500
+ NSString *tintHex = options[@"tintColor"];
501
+ NSNumber *tintOpacity = options[@"tintOpacity"];
502
+ NSString *frameKey = options[@"frame"];
503
+
504
+ NSNumber *rotateDegrees = options[@"rotateDegrees"] ?: @0;
505
+ BOOL flipX = [options[@"flipX"] boolValue];
506
+ BOOL flipY = [options[@"flipY"] boolValue];
507
+
508
+ // Prepare Frame before block to avoid reloading it 30-60 times a second
509
+ CIImage *capturedFrameImg = nil;
510
+ if (frameKey && frameKey.length > 0) {
511
+ // Bulletproof frame loading (same as editImage)
512
+ UIImage *uiFrame = [UIImage imageNamed:frameKey];
513
+ if (!uiFrame) {
514
+ NSArray *searchPaths = @[
515
+ [[NSBundle mainBundle] pathForResource:frameKey ofType:@"png" inDirectory:@"frames"],
516
+ [[NSBundle mainBundle] pathForResource:frameKey ofType:@"png" inDirectory:@"videoEditor/frames"],
517
+ [[NSBundle mainBundle] pathForResource:frameKey ofType:@"png"]
518
+ ];
519
+ for (NSString *p in searchPaths) {
520
+ if (p) {
521
+ uiFrame = [UIImage imageWithContentsOfFile:p];
522
+ if (uiFrame) break;
523
+ }
524
+ }
525
+ }
526
+
527
+ if (uiFrame) {
528
+ NSLog(@"[RNMediaEditor] Video frame overlay loaded: %@", frameKey);
529
+ capturedFrameImg = [CIImage imageWithCGImage:uiFrame.CGImage];
530
+ if (capturedFrameImg) {
531
+ // Ensure frame starts at 0,0
532
+ capturedFrameImg = [capturedFrameImg imageByApplyingTransform:CGAffineTransformMakeTranslation(-capturedFrameImg.extent.origin.x, -capturedFrameImg.extent.origin.y)];
533
+ }
534
+ } else {
535
+ NSLog(@"[RNMediaEditor] ERROR: Video frame overlay NOT FOUND for key: %@", frameKey);
536
+ }
537
+ }
538
+
539
+ // Pre-render text overlays if any
540
+ NSArray *vOverlays = options[@"overlays"];
541
+ CIImage *textOverlayCI = nil;
542
+ if ([vOverlays isKindOfClass:NSArray.class] && vOverlays.count > 0) {
543
+ // Determine final output size to create a correctly scaled overlay
544
+ NSDictionary *vCrop = options[@"crop"];
545
+ CGSize targetSize;
546
+ if (vCrop) {
547
+ targetSize = CGSizeMake([vCrop[@"width"] doubleValue], [vCrop[@"height"] doubleValue]);
548
+ } else {
549
+ targetSize = videoTrack.naturalSize;
550
+ if (ABS(videoTrack.preferredTransform.b) > 0.5) {
551
+ targetSize = CGSizeMake(targetSize.height, targetSize.width);
552
+ }
553
+ }
554
+
555
+ if (targetSize.width > 0 && targetSize.height > 0) {
556
+ UIGraphicsBeginImageContextWithOptions(targetSize, NO, 1.0);
557
+ for (NSDictionary *overlay in vOverlays) {
558
+ NSString *text = overlay[@"text"];
559
+ NSNumber *x = overlay[@"x"];
560
+ NSNumber *y = overlay[@"y"];
561
+ NSNumber *fontSize = overlay[@"fontSize"] ?: @24;
562
+ NSString *colorHex = overlay[@"color"] ?: @"#FFFFFF";
563
+
564
+ if (text && x && y) {
565
+ UIColor *color = [self colorFromHexString:colorHex];
566
+ NSDictionary *attrs = @{
567
+ NSFontAttributeName: [UIFont boldSystemFontOfSize:fontSize.floatValue],
568
+ NSForegroundColorAttributeName: color
569
+ };
570
+ [text drawAtPoint:CGPointMake(x.doubleValue, y.doubleValue) withAttributes:attrs];
571
+ }
572
+ }
573
+ UIImage *overlayImg = UIGraphicsGetImageFromCurrentImageContext();
574
+ UIGraphicsEndImageContext();
575
+ if (overlayImg) {
576
+ textOverlayCI = [[CIImage alloc] initWithImage:overlayImg];
577
+ // UIImage (top-left) to CIImage (bottom-left) conversion requires vertical flip
578
+ // to maintain the correct visual position for the video compositor.
579
+ textOverlayCI = [textOverlayCI imageByApplyingTransform:CGAffineTransformMakeScale(1, -1)];
580
+ textOverlayCI = [textOverlayCI imageByApplyingTransform:CGAffineTransformMakeTranslation(0, targetSize.height)];
581
+ }
582
+ }
583
+ }
584
+
585
+ NSDictionary *cropOption = options[@"crop"];
586
+ if (brightness.floatValue != 0 || contrast.floatValue != 1 || saturation.floatValue != 1 || grayscale || (tintHex && tintOpacity.floatValue > 0) || rotateDegrees.intValue != 0 || flipX || flipY || (cropOption && cropOption.count > 0) || capturedFrameImg || textOverlayCI) {
587
+ AVVideoComposition *videoComposition = [AVVideoComposition videoCompositionWithAsset:composition applyingCIFiltersWithHandler:^(AVAsynchronousCIImageFilteringRequest *request) {
588
+ CIImage *output = request.sourceImage;
589
+
590
+ // 1. Transform (Rotate/Flip)
591
+ NSNumber *rDeg = options[@"rotateDegrees"] ?: @0;
592
+ BOOL fX = [options[@"flipX"] boolValue];
593
+ BOOL fY = [options[@"flipY"] boolValue];
594
+
595
+ if (rDeg.intValue != 0 || fX || fY) {
596
+ CGAffineTransform t = CGAffineTransformIdentity;
597
+ if (fX) t = CGAffineTransformScale(t, -1, 1);
598
+ if (fY) t = CGAffineTransformScale(t, 1, -1);
599
+ if (rDeg.intValue != 0) {
600
+ t = CGAffineTransformRotate(t, (CGFloat)(rDeg.doubleValue * M_PI / 180.0));
601
+ }
602
+ output = [output imageByApplyingTransform:t];
603
+
604
+ // Ensure image starts at (0,0) after transform
605
+ output = [output imageByApplyingTransform:CGAffineTransformMakeTranslation(-output.extent.origin.x, -output.extent.origin.y)];
606
+ }
607
+
608
+ // 2. Crop
609
+ NSDictionary *vCrop = options[@"crop"];
610
+ if ([vCrop isKindOfClass:NSDictionary.class] && vCrop.count > 0) {
611
+ CGFloat cx = [vCrop[@"x"] doubleValue];
612
+ CGFloat cy = [vCrop[@"y"] doubleValue];
613
+ CGFloat cw = [vCrop[@"width"] doubleValue];
614
+ CGFloat ch = [vCrop[@"height"] doubleValue];
615
+
616
+ CGFloat maxW = output.extent.size.width;
617
+ CGFloat maxH = output.extent.size.height;
618
+
619
+ if (maxW > 0 && maxH > 0) {
620
+ if (cx < 0) cx = 0;
621
+ if (cy < 0) cy = 0;
622
+ if (cx + cw > maxW) cw = maxW - cx;
623
+ if (cy + ch > maxH) ch = maxH - cy;
624
+
625
+ NSInteger icw = (NSInteger)cw;
626
+ NSInteger ich = (NSInteger)ch;
627
+ if (icw % 2 != 0) icw = (icw > 1) ? icw - 1 : 2;
628
+ if (ich % 2 != 0) ich = (ich > 1) ? ich - 1 : 2;
629
+ cw = icw;
630
+ ch = ich;
631
+
632
+ if (cw > 0 && ch > 0) {
633
+ CGFloat flippedY = maxH - cy - ch;
634
+ output = [output imageByCroppingToRect:CGRectMake(cx, flippedY, cw, ch)];
635
+ output = [output imageByApplyingTransform:CGAffineTransformMakeTranslation(-output.extent.origin.x, -output.extent.origin.y)];
636
+ }
637
+ }
638
+ }
639
+
640
+ // 3. Apply Frame after crop
641
+ if (capturedFrameImg) {
642
+ CGFloat scaleX = output.extent.size.width / capturedFrameImg.extent.size.width;
643
+ CGFloat scaleY = output.extent.size.height / capturedFrameImg.extent.size.height;
644
+ CIImage *scaledFrame = [capturedFrameImg imageByApplyingTransform:CGAffineTransformMakeScale(scaleX, scaleY)];
645
+
646
+ // Scale down video content slightly to fit inside the frame opening
647
+ CGFloat insetScale = [options[@"frameScale"] doubleValue] ?: 0.88;
648
+ CGFloat offsetYRatio = [options[@"frameOffsetY"] doubleValue] ?: 0.0;
649
+
650
+ CGFloat tx = output.extent.size.width * (1.0 - insetScale) / 2.0;
651
+ CGFloat ty = output.extent.size.height * (1.0 - insetScale) / 2.0;
652
+ CGFloat extraTY = output.extent.size.height * offsetYRatio;
653
+
654
+ CGAffineTransform insetTransform = CGAffineTransformConcat(
655
+ CGAffineTransformMakeScale(insetScale, insetScale),
656
+ CGAffineTransformMakeTranslation(tx, ty + extraTY)
657
+ );
658
+
659
+ // Normalize video frame origin
660
+ output = [output imageByApplyingTransform:CGAffineTransformMakeTranslation(-output.extent.origin.x, -output.extent.origin.y)];
661
+
662
+ CGRect targetRect = scaledFrame.extent;
663
+ // H.264 video does not support alpha, so use an opaque black base.
664
+ // This ensures transparent areas of the frame PNG render as black
665
+ // (matching the preview dark background in the editor UI).
666
+ CIImage *opaqueBase = [[CIImage imageWithColor:[CIColor colorWithRed:0 green:0 blue:0 alpha:1]] imageByCroppingToRect:targetRect];
667
+
668
+ // 1. Video frame over opaque base (at 0,0)
669
+ CIImage *videoLayer = [[output imageByApplyingTransform:insetTransform] imageByCompositingOverImage:opaqueBase];
670
+
671
+ // 2. Frame overlay over video layer
672
+ output = [scaledFrame imageByCompositingOverImage:videoLayer];
673
+
674
+ // Final normalization for generator
675
+ output = [output imageByApplyingTransform:CGAffineTransformMakeTranslation(-output.extent.origin.x, -output.extent.origin.y)];
676
+ }
677
+
678
+ // 4. Color adjustments (Apply after crop/frame so everything is affected)
679
+ CIFilter *filter = [CIFilter filterWithName:@"CIColorControls"];
680
+ [filter setValue:output forKey:kCIInputImageKey];
681
+ [filter setValue:(grayscale ? @0 : saturation) forKey:kCIInputSaturationKey];
682
+ [filter setValue:brightness forKey:kCIInputBrightnessKey];
683
+ [filter setValue:contrast forKey:kCIInputContrastKey];
684
+ output = filter.outputImage ?: output;
685
+
686
+ // Tint overlay
687
+ if (tintHex && tintOpacity.doubleValue > 0 && [tintHex hasPrefix:@"#"]) {
688
+ unsigned rgbValue = 0;
689
+ NSScanner *scanner = [NSScanner scannerWithString:tintHex];
690
+ [scanner setScanLocation:1];
691
+ [scanner scanHexInt:&rgbValue];
692
+ CGFloat r = ((rgbValue & 0xFF0000) >> 16)/255.0;
693
+ CGFloat g = ((rgbValue & 0xFF00) >> 8)/255.0;
694
+ CGFloat b = (rgbValue & 0xFF)/255.0;
695
+ CIColor *cColor = [CIColor colorWithRed:r green:g blue:b alpha:tintOpacity.doubleValue];
696
+ CIImage *overlay = [[CIImage imageWithColor:cColor] imageByCroppingToRect:output.extent];
697
+ output = [overlay imageByCompositingOverImage:output];
698
+ }
699
+
700
+ // 5. Text Overlays (Apply at the very end)
701
+ if (textOverlayCI) {
702
+ output = [textOverlayCI imageByCompositingOverImage:output];
703
+ }
704
+
705
+ // 6. AR Filter Face Tracking
706
+ NSString *arFilter = options[@"arFilter"];
707
+ if (arFilter && arFilter.length > 0) {
708
+ // VNSequenceRequestHandler requires an strong ref. Since this is async blocks,
709
+ // we use a fresh VNSequenceRequestHandler per request.
710
+ output = [self applyARFilter:arFilter toCIImage:output];
711
+ }
712
+
713
+ [request finishWithImage:output context:nil];
714
+ }];
715
+
716
+ // Set proper size for composition based on track transform + user requested transforms
717
+ CGSize naturalSize = videoTrack.naturalSize;
718
+ CGAffineTransform trackT = videoTrack.preferredTransform;
719
+
720
+ NSNumber *userRotate = options[@"rotateDegrees"] ?: @0;
721
+ CGAffineTransform userT = CGAffineTransformIdentity;
722
+ if ([options[@"flipX"] boolValue]) userT = CGAffineTransformScale(userT, -1, 1);
723
+ if ([options[@"flipY"] boolValue]) userT = CGAffineTransformScale(userT, 1, -1);
724
+ if (userRotate.intValue != 0) {
725
+ userT = CGAffineTransformRotate(userT, (CGFloat)(userRotate.doubleValue * M_PI / 180.0));
726
+ }
727
+
728
+ CGAffineTransform combinedT = CGAffineTransformConcat(trackT, userT);
729
+ CGRect finalRect = CGRectApplyAffineTransform(CGRectMake(0, 0, naturalSize.width, naturalSize.height), combinedT);
730
+
731
+ CGSize renderSize = CGSizeMake(ABS(finalRect.size.width), ABS(finalRect.size.height));
732
+
733
+ NSDictionary *finalCrop = options[@"crop"];
734
+ if ([finalCrop isKindOfClass:NSDictionary.class]) {
735
+ renderSize = CGSizeMake([finalCrop[@"width"] doubleValue], [finalCrop[@"height"] doubleValue]);
736
+ }
737
+
738
+ NSInteger rWidth = (NSInteger)renderSize.width;
739
+ NSInteger rHeight = (NSInteger)renderSize.height;
740
+ if (rWidth % 2 != 0) rWidth = (rWidth > 1) ? rWidth - 1 : 2;
741
+ if (rHeight % 2 != 0) rHeight = (rHeight > 1) ? rHeight - 1 : 2;
742
+ renderSize = CGSizeMake(rWidth, rHeight);
743
+
744
+ AVMutableVideoComposition *mutableVideoComposition = [videoComposition mutableCopy];
745
+ mutableVideoComposition.renderSize = renderSize;
746
+ export.videoComposition = mutableVideoComposition;
747
+ }
748
+
749
+ // ------------------------
750
+
751
+ NSString *docsDir = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
752
+ NSString *editedPath = [docsDir stringByAppendingPathComponent:@"edited_media"];
753
+
754
+ NSFileManager *fm = [NSFileManager defaultManager];
755
+ if (![fm fileExistsAtPath:editedPath]) {
756
+ [fm createDirectoryAtPath:editedPath withIntermediateDirectories:YES attributes:nil error:nil];
757
+ }
758
+
759
+ NSString *fileName = [NSString stringWithFormat:@"trimmed_%@.mp4", [NSUUID UUID].UUIDString];
760
+ NSString *outPath = [editedPath stringByAppendingPathComponent:fileName];
761
+ NSURL *outUrl = [NSURL fileURLWithPath:outPath];
762
+ export.outputURL = outUrl;
763
+ export.outputFileType = AVFileTypeMPEG4;
764
+ // Note: timeRange is now kCMTimeRangeInvalid (meaning whole composition)
765
+ // because we already trimmed while building the composition tracks.
766
+ export.timeRange = CMTimeRangeMake(kCMTimeZero, composition.duration);
767
+
768
+ [export exportAsynchronouslyWithCompletionHandler:^{
769
+ if (tempVideoToDelete) {
770
+ [[NSFileManager defaultManager] removeItemAtPath:tempVideoToDelete error:nil];
771
+ }
772
+ if (tempMusicToDelete) {
773
+ [[NSFileManager defaultManager] removeItemAtURL:tempMusicToDelete error:nil];
774
+ }
775
+ switch (export.status) {
776
+ case AVAssetExportSessionStatusCompleted:
777
+ resolve(outUrl.absoluteString);
778
+ break;
779
+ case AVAssetExportSessionStatusFailed:
780
+ case AVAssetExportSessionStatusCancelled:
781
+ reject(@"export_failed", export.error.localizedDescription ?: @"Export failed", export.error);
782
+ break;
783
+ default:
784
+ reject(@"export_failed", @"Export did not complete", nil);
785
+ break;
786
+ }
787
+ }];
788
+ }
789
+
790
+ - (CVPixelBufferRef)pixelBufferFromCGImage:(CGImageRef)image width:(NSInteger)width height:(NSInteger)height {
791
+ NSDictionary *options = @{
792
+ (id)kCVPixelBufferCGImageCompatibilityKey: @YES,
793
+ (id)kCVPixelBufferCGBitmapContextCompatibilityKey: @YES
794
+ };
795
+ CVPixelBufferRef pxbuffer = NULL;
796
+ CVReturn status = CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32ARGB, (__bridge CFDictionaryRef)options, &pxbuffer);
797
+ if (status != kCVReturnSuccess || pxbuffer == NULL) {
798
+ return NULL;
799
+ }
800
+
801
+ CVPixelBufferLockBaseAddress(pxbuffer, 0);
802
+ void *pxdata = CVPixelBufferGetBaseAddress(pxbuffer);
803
+ size_t bytesPerRow = CVPixelBufferGetBytesPerRow(pxbuffer);
804
+
805
+ CGColorSpaceRef rgbColorSpace = CGColorSpaceCreateDeviceRGB();
806
+ CGContextRef context = CGBitmapContextCreate(pxdata, width, height, 8, bytesPerRow, rgbColorSpace, kCGImageAlphaNoneSkipFirst);
807
+ if (context == NULL) {
808
+ CVPixelBufferUnlockBaseAddress(pxbuffer, 0);
809
+ CVPixelBufferRelease(pxbuffer);
810
+ CGColorSpaceRelease(rgbColorSpace);
811
+ return NULL;
812
+ }
813
+
814
+ CGContextDrawImage(context, CGRectMake(0, 0, width, height), image);
815
+ CGColorSpaceRelease(rgbColorSpace);
816
+ CGContextRelease(context);
817
+
818
+ CVPixelBufferUnlockBaseAddress(pxbuffer, 0);
819
+ return pxbuffer;
820
+ }
821
+
822
+ - (void)createVideoFromImage:(UIImage *)image duration:(NSTimeInterval)duration outputPath:(NSString *)outputPath completion:(void (^)(BOOL success, NSError *error))completion {
823
+ NSError *error = nil;
824
+ AVAssetWriter *writer = [[AVAssetWriter alloc] initWithURL:[NSURL fileURLWithPath:outputPath] fileType:AVFileTypeMPEG4 error:&error];
825
+ if (error) {
826
+ completion(NO, error);
827
+ return;
828
+ }
829
+
830
+ CGImageRef cgImage = image.CGImage;
831
+ BOOL shouldReleaseCGImage = NO;
832
+ if (!cgImage && image.CIImage) {
833
+ CIContext *ciContext = [CIContext contextWithOptions:nil];
834
+ cgImage = [ciContext createCGImage:image.CIImage fromRect:image.CIImage.extent];
835
+ shouldReleaseCGImage = YES;
836
+ }
837
+
838
+ if (!cgImage) {
839
+ completion(NO, [NSError errorWithDomain:@"RNMediaEditor" code:-1 userInfo:@{NSLocalizedDescriptionKey: @"Could not get CGImage from UIImage"}]);
840
+ return;
841
+ }
842
+
843
+ CGFloat originalWidth = CGImageGetWidth(cgImage);
844
+ CGFloat originalHeight = CGImageGetHeight(cgImage);
845
+ NSInteger width = ((NSInteger)originalWidth / 2) * 2;
846
+ NSInteger height = ((NSInteger)originalHeight / 2) * 2;
847
+
848
+ NSDictionary *videoSettings = @{
849
+ AVVideoCodecKey: AVVideoCodecTypeH264,
850
+ AVVideoWidthKey: @(width),
851
+ AVVideoHeightKey: @(height)
852
+ };
853
+
854
+ AVAssetWriterInput *input = [AVAssetWriterInput assetWriterInputWithMediaType:AVMediaTypeVideo outputSettings:videoSettings];
855
+ NSDictionary *bufferAttributes = @{
856
+ (id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32ARGB),
857
+ (id)kCVPixelBufferWidthKey: @(width),
858
+ (id)kCVPixelBufferHeightKey: @(height)
859
+ };
860
+ AVAssetWriterInputPixelBufferAdaptor *adaptor = [AVAssetWriterInputPixelBufferAdaptor assetWriterInputPixelBufferAdaptorWithAssetWriterInput:input sourcePixelBufferAttributes:bufferAttributes];
861
+
862
+ [writer addInput:input];
863
+ if (![writer startWriting]) {
864
+ if (shouldReleaseCGImage) {
865
+ CGImageRelease(cgImage);
866
+ }
867
+ completion(NO, writer.error ?: [NSError errorWithDomain:@"RNMediaEditor" code:-2 userInfo:@{NSLocalizedDescriptionKey: @"Failed to start writing"}]);
868
+ return;
869
+ }
870
+
871
+ NSInteger fps = 10;
872
+ NSInteger totalFrames = (NSInteger)(duration * fps);
873
+
874
+ [writer startSessionAtSourceTime:kCMTimeZero];
875
+
876
+ for (NSInteger i = 0; i < totalFrames; i++) {
877
+ int retryCount = 0;
878
+ while (!input.isReadyForMoreMediaData && retryCount < 50) {
879
+ [NSThread sleepForTimeInterval:0.01];
880
+ retryCount++;
881
+ }
882
+
883
+ CVPixelBufferRef buffer = [self pixelBufferFromCGImage:cgImage width:width height:height];
884
+ if (buffer) {
885
+ [adaptor appendPixelBuffer:buffer withPresentationTime:CMTimeMake(i, (int32_t)fps)];
886
+ CVPixelBufferRelease(buffer);
887
+ }
888
+ }
889
+
890
+ if (shouldReleaseCGImage) {
891
+ CGImageRelease(cgImage);
892
+ }
893
+
894
+ [input markAsFinished];
895
+ [writer finishWritingWithCompletionHandler:^{
896
+ if (writer.status == AVAssetWriterStatusFailed) {
897
+ completion(NO, writer.error);
898
+ } else {
899
+ completion(YES, nil);
900
+ }
901
+ }];
902
+ }
903
+
904
+ @end
905
+