@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.
- package/ImageVideoEditor.podspec +21 -0
- package/README.md +136 -0
- package/android/build.gradle +76 -0
- package/android/gradle.properties +5 -0
- package/android/src/main/AndroidManifest.xml +13 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +67 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +548 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaFileUtils.kt +29 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +305 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaPackage.kt +26 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaPickerModule.kt +111 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaPlayerModule.kt +34 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/RNCameraViewManager.kt +761 -0
- package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +317 -0
- package/ios/PrivacyInfo.xcprivacy +38 -0
- package/ios/RNCameraViewManager.m +420 -0
- package/ios/RNFrameGrabber.m +61 -0
- package/ios/RNMediaEditor.m +905 -0
- package/ios/RNMediaLibrary.m +389 -0
- package/ios/RNMediaPicker.m +144 -0
- package/ios/RNMediaPlayer.m +73 -0
- package/ios/RNVideoPreviewManager.m +263 -0
- package/ios/frames/film_vintage.png +0 -0
- package/ios/frames/floral_gold.png +0 -0
- package/ios/frames/minimal_double.png +0 -0
- package/ios/frames/polaroid_white.png +0 -0
- package/ios/frames/watercolor_floral.png +0 -0
- package/lib/module/assets/frames/film_vintage.png +0 -0
- package/lib/module/assets/frames/floral_gold.png +0 -0
- package/lib/module/assets/frames/minimal_double.png +0 -0
- package/lib/module/assets/frames/polaroid_white.png +0 -0
- package/lib/module/assets/frames/watercolor_floral.png +0 -0
- package/lib/module/components/VideoEditor.js +156 -0
- package/lib/module/components/VideoEditor.js.map +1 -0
- package/lib/module/index.js +4 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native/CameraView.js +104 -0
- package/lib/module/native/CameraView.js.map +1 -0
- package/lib/module/native/FrameGrabber.js +13 -0
- package/lib/module/native/FrameGrabber.js.map +1 -0
- package/lib/module/native/MediaEditor.js +19 -0
- package/lib/module/native/MediaEditor.js.map +1 -0
- package/lib/module/native/MediaLibrary.js +37 -0
- package/lib/module/native/MediaLibrary.js.map +1 -0
- package/lib/module/native/MediaPicker.js +13 -0
- package/lib/module/native/MediaPicker.js.map +1 -0
- package/lib/module/native/MediaPlayer.js +13 -0
- package/lib/module/native/MediaPlayer.js.map +1 -0
- package/lib/module/native/VideoPreview.js +12 -0
- package/lib/module/native/VideoPreview.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/screens/CropScreen.js +1211 -0
- package/lib/module/screens/CropScreen.js.map +1 -0
- package/lib/module/screens/EditorScreen.js +5752 -0
- package/lib/module/screens/EditorScreen.js.map +1 -0
- package/lib/module/screens/ExportScreen.js +289 -0
- package/lib/module/screens/ExportScreen.js.map +1 -0
- package/lib/module/screens/GalleryScreen.js +505 -0
- package/lib/module/screens/GalleryScreen.js.map +1 -0
- package/lib/module/screens/PickScreen.js +1195 -0
- package/lib/module/screens/PickScreen.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/src/components/VideoEditor.d.ts +13 -0
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/native/CameraView.d.ts +23 -0
- package/lib/typescript/src/native/FrameGrabber.d.ts +2 -0
- package/lib/typescript/src/native/MediaEditor.d.ts +3 -0
- package/lib/typescript/src/native/MediaLibrary.d.ts +16 -0
- package/lib/typescript/src/native/MediaPicker.d.ts +2 -0
- package/lib/typescript/src/native/MediaPlayer.d.ts +1 -0
- package/lib/typescript/src/native/VideoPreview.d.ts +19 -0
- package/lib/typescript/src/screens/CropScreen.d.ts +9 -0
- package/lib/typescript/src/screens/EditorScreen.d.ts +10 -0
- package/lib/typescript/src/screens/ExportScreen.d.ts +9 -0
- package/lib/typescript/src/screens/GalleryScreen.d.ts +8 -0
- package/lib/typescript/src/screens/PickScreen.d.ts +13 -0
- package/lib/typescript/src/types.d.ts +58 -0
- package/package.json +101 -0
- package/src/assets/frames/film_vintage.png +0 -0
- package/src/assets/frames/floral_gold.png +0 -0
- package/src/assets/frames/minimal_double.png +0 -0
- package/src/assets/frames/polaroid_white.png +0 -0
- package/src/assets/frames/watercolor_floral.png +0 -0
- package/src/components/VideoEditor.tsx +182 -0
- package/src/index.tsx +2 -0
- package/src/native/CameraView.tsx +95 -0
- package/src/native/FrameGrabber.ts +21 -0
- package/src/native/MediaEditor.ts +33 -0
- package/src/native/MediaLibrary.ts +69 -0
- package/src/native/MediaPicker.ts +17 -0
- package/src/native/MediaPlayer.ts +16 -0
- package/src/native/VideoPreview.tsx +20 -0
- package/src/screens/CropScreen.tsx +968 -0
- package/src/screens/EditorScreen.tsx +4517 -0
- package/src/screens/ExportScreen.tsx +282 -0
- package/src/screens/GalleryScreen.tsx +412 -0
- package/src/screens/PickScreen.tsx +1094 -0
- 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
|
+
|