@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,968 @@
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Dimensions,
7
+ PanResponder,
8
+ Pressable,
9
+ Image,
10
+ ScrollView,
11
+ Platform,
12
+ Alert,
13
+ Modal,
14
+ } from 'react-native';
15
+ import { SafeAreaView } from 'react-native-safe-area-context';
16
+ import type { MediaItem } from '../types';
17
+ import { editImage, trimVideo } from '../native/MediaEditor';
18
+ import { captureFrame } from '../native/FrameGrabber';
19
+ import { VideoPreview } from '../native/VideoPreview';
20
+
21
+ const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
22
+
23
+ // --- Icons (View-based) ---
24
+ const HomeIcon = () => (
25
+ <View style={iconStyles.homeContainer}>
26
+ <View style={iconStyles.homeRoof} />
27
+ <View style={iconStyles.homeBase} />
28
+ </View>
29
+ );
30
+ const UndoIcon = () => (
31
+ <View style={iconStyles.arrowContainer}>
32
+ <Text style={iconStyles.arrowText}>⟲</Text>
33
+ </View>
34
+ );
35
+ const RedoIcon = () => (
36
+ <View style={iconStyles.arrowContainer}>
37
+ <Text style={iconStyles.arrowText}>⟳</Text>
38
+ </View>
39
+ );
40
+ const EyeIcon = () => (
41
+ <View style={iconStyles.eyeContainer}>
42
+ <View style={iconStyles.eyeOuter} />
43
+ <View style={iconStyles.eyeInner} />
44
+ </View>
45
+ );
46
+ const ShareIcon = () => (
47
+ <View style={iconStyles.shareContainer}>
48
+ <View style={iconStyles.shareArrow} />
49
+ <View style={iconStyles.shareBox} />
50
+ </View>
51
+ );
52
+ const ResetIcon = () => (
53
+ <Text style={iconStyles.resetIconText}>↺</Text>
54
+ );
55
+ const FlipIcon = () => (
56
+ <View style={iconStyles.flipContainer}>
57
+ <View style={iconStyles.flipHalf} />
58
+ <View style={[iconStyles.flipHalf, iconStyles.flipRight]} />
59
+ </View>
60
+ );
61
+ const RotateIcon = () => (
62
+ <Text style={iconStyles.rotateIconText}>↻</Text>
63
+ );
64
+ const ChevronDown = () => (
65
+ <Text style={{ color: '#fff', fontSize: 10 }}>▼</Text>
66
+ );
67
+
68
+ const iconStyles = StyleSheet.create({
69
+ homeContainer: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },
70
+ homeRoof: { width: 0, height: 0, borderLeftWidth: 10, borderRightWidth: 10, borderBottomWidth: 10, borderStyle: 'solid', backgroundColor: 'transparent', borderLeftColor: 'transparent', borderRightColor: 'transparent', borderBottomColor: '#fff', marginBottom: -2 },
71
+ homeBase: { width: 16, height: 10, backgroundColor: '#fff', borderRadius: 1 },
72
+ arrowContainer: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },
73
+ arrowText: { color: '#fff', fontSize: 20, fontWeight: 'bold' },
74
+ eyeContainer: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },
75
+ eyeOuter: { width: 18, height: 10, borderRadius: 5, borderWidth: 2, borderColor: '#fff' },
76
+ eyeInner: { position: 'absolute', width: 4, height: 4, borderRadius: 2, backgroundColor: '#fff' },
77
+ shareContainer: { width: 24, height: 24, alignItems: 'center', justifyContent: 'center' },
78
+ shareBox: { width: 14, height: 10, borderWidth: 2, borderColor: '#fff', borderTopWidth: 0, borderRadius: 1 },
79
+ shareArrow: { width: 2, height: 14, backgroundColor: '#fff', position: 'absolute', top: 2 },
80
+ resetIconText: { color: '#fff', fontSize: 18 },
81
+ flipContainer: { width: 24, height: 20, flexDirection: 'row', gap: 2 },
82
+ flipHalf: { flex: 1, backgroundColor: '#444', borderTopLeftRadius: 4, borderBottomLeftRadius: 4 },
83
+ flipRight: { backgroundColor: '#fff', borderTopLeftRadius: 0, borderBottomLeftRadius: 0, borderTopRightRadius: 4, borderBottomRightRadius: 4 },
84
+ rotateIconText: { color: '#fff', fontSize: 20 },
85
+ });
86
+
87
+ const ratios = [
88
+ { label: 'Free', ratio: null },
89
+ { label: 'Square', ratio: 1 },
90
+ { label: '4:5', ratio: 4 / 5 },
91
+ { label: '3:4', ratio: 3 / 4 },
92
+ { label: '9:16', ratio: 9 / 16 },
93
+ { label: '16:9', ratio: 16 / 9 },
94
+ { label: '2:3', ratio: 2 / 3 },
95
+ { label: '3:2', ratio: 3 / 2 },
96
+ ];
97
+
98
+ interface CropScreenProps {
99
+ item: MediaItem;
100
+ onBack: () => void;
101
+ onSave: (uri: string, thumb?: string, duration?: number) => void;
102
+ }
103
+ export function CropScreen({ item, onBack, onSave }: CropScreenProps) {
104
+ const [straightenAngle, setStraightenAngle] = useState(0);
105
+ const [rotation, setRotation] = useState(0);
106
+ const [selectedRatio, setSelectedRatio] = useState<string>('Free');
107
+ const [isFixedRatio, setIsFixedRatio] = useState(false);
108
+ const [loading, setLoading] = useState(true);
109
+ const [saving, setSaving] = useState(false);
110
+
111
+ const [imageSize, setImageSize] = useState({ width: 0, height: 0 });
112
+ const [mediaLayout, setMediaLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
113
+ const [renderedImageSize, setRenderedImageSize] = useState({ width: 0, height: 0, top: 0, left: 0 });
114
+ const renderedImageSizeRef = useRef(renderedImageSize);
115
+
116
+ // Crop State
117
+ const [cropBox, setCropBox] = useState({
118
+ top: 50,
119
+ left: 20,
120
+ width: SCREEN_WIDTH - 40,
121
+ height: 150,
122
+ });
123
+
124
+ const [resolution, setResolution] = useState({ w: 0, h: 0 });
125
+
126
+ // REF-BASED BACKUP FOR PANRESPONDERS (Prevents closure traps)
127
+ const cropBoxRef = useRef(cropBox);
128
+ useEffect(() => {
129
+ cropBoxRef.current = cropBox;
130
+ }, [cropBox]);
131
+
132
+ useEffect(() => {
133
+ renderedImageSizeRef.current = renderedImageSize;
134
+ }, [renderedImageSize]);
135
+
136
+ useEffect(() => {
137
+ if (item.type === 'image') {
138
+ Image.getSize(item.uri, (w, h) => {
139
+ setImageSize({ width: w, height: h });
140
+ });
141
+ } else {
142
+ (async () => {
143
+ try {
144
+ const frameUri = await captureFrame(item.uri, { timeMs: 0 });
145
+ Image.getSize(frameUri, (w, h) => {
146
+ setImageSize({ width: w, height: h });
147
+ });
148
+ } catch {
149
+ setImageSize({ width: 1080, height: 1920 });
150
+ }
151
+ })();
152
+ }
153
+ }, [item.uri, item.type]);
154
+
155
+ // Calculate the actual area occupied by the image (contain)
156
+ useEffect(() => {
157
+ if (imageSize.width && mediaLayout.width) {
158
+ const containerRatio = mediaLayout.width / mediaLayout.height;
159
+ const isRotated = (rotation % 180 === 90);
160
+ const imgW = isRotated ? imageSize.height : imageSize.width;
161
+ const imgH = isRotated ? imageSize.width : imageSize.height;
162
+ const imageRatio = imgW / imgH;
163
+
164
+ let w, h;
165
+ if (imageRatio > containerRatio) {
166
+ w = mediaLayout.width;
167
+ h = w / imageRatio;
168
+ } else {
169
+ h = mediaLayout.height;
170
+ w = h * imageRatio;
171
+ }
172
+
173
+ const layoutW = isRotated ? h : w;
174
+ const layoutH = isRotated ? w : h;
175
+ const layoutL = (w - layoutW) / 2;
176
+ const layoutT = (h - layoutH) / 2;
177
+
178
+ const rendered = {
179
+ width: w,
180
+ height: h,
181
+ top: (mediaLayout.height - h) / 2,
182
+ left: (mediaLayout.width - w) / 2,
183
+ imgW: layoutW,
184
+ imgH: layoutH,
185
+ imgL: layoutL,
186
+ imgT: layoutT
187
+ };
188
+
189
+ setRenderedImageSize(rendered);
190
+
191
+ setCropBox(() => {
192
+ const ratioObj = ratios.find(r => r.label === selectedRatio);
193
+ const ratio = ratioObj ? ratioObj.ratio : null;
194
+
195
+ if (ratio) {
196
+ let newWidth = rendered.width;
197
+ let newHeight = newWidth / ratio;
198
+
199
+ if (newHeight > rendered.height) {
200
+ newHeight = rendered.height;
201
+ newWidth = newHeight * ratio;
202
+ }
203
+ if (newWidth > rendered.width) {
204
+ newWidth = rendered.width;
205
+ newHeight = newWidth / ratio;
206
+ }
207
+
208
+ return {
209
+ width: newWidth,
210
+ height: newHeight,
211
+ left: rendered.left + (rendered.width - newWidth) / 2,
212
+ top: rendered.top + (rendered.height - newHeight) / 2,
213
+ };
214
+ } else {
215
+ return {
216
+ width: rendered.width,
217
+ height: rendered.height,
218
+ left: rendered.left,
219
+ top: rendered.top,
220
+ };
221
+ }
222
+ });
223
+ }
224
+ }, [imageSize, mediaLayout, rotation, selectedRatio]);
225
+
226
+
227
+ const [flipX, setFlipX] = useState(false);
228
+ const [flipY, setFlipY] = useState(false);
229
+
230
+ const handleRatioSelect = (label: string, ratio: number | null) => {
231
+ setSelectedRatio(label);
232
+ };
233
+
234
+ const updateResolution = () => {
235
+ if (imageSize.width && renderedImageSize.width) {
236
+ const isRotated = (rotation % 180 === 90);
237
+ const pixelWidth = isRotated ? imageSize.height : imageSize.width;
238
+ const scale = pixelWidth / renderedImageSize.width;
239
+ setResolution({
240
+ w: Math.round(cropBox.width * scale),
241
+ h: Math.round(cropBox.height * scale)
242
+ });
243
+ }
244
+ };
245
+
246
+ useEffect(() => {
247
+ updateResolution();
248
+ }, [cropBox, renderedImageSize, rotation, imageSize]);
249
+
250
+ const initialCrop = useRef({ top: 0, left: 0, width: 0, height: 0 }).current;
251
+
252
+ const getClampedBox = (left: number, top: number, width: number, height: number) => {
253
+ const rendered = renderedImageSizeRef.current;
254
+ if (rendered.width === 0) return { left, top, width, height };
255
+
256
+ const minSize = 60;
257
+ const imageRight = rendered.left + rendered.width;
258
+ const imageBottom = rendered.top + rendered.height;
259
+
260
+ // 1. Clamp Position to the VIRTUAL CANVAS (not just original image)
261
+ let l = Math.max(rendered.left, Math.min(left, imageRight - minSize));
262
+ let t = Math.max(rendered.top, Math.min(top, imageBottom - minSize));
263
+
264
+ // 2. Clamp Size to the VIRTUAL CANVAS
265
+ let w = Math.max(minSize, Math.min(width, imageRight - l));
266
+ let h = Math.max(minSize, Math.min(height, imageBottom - t));
267
+
268
+ if (l + w > imageRight) l = imageRight - w;
269
+ if (t + h > imageBottom) t = imageBottom - h;
270
+
271
+ return { left: l, top: t, width: w, height: h };
272
+ };
273
+
274
+ const updateCrop = (newBox: { top: number; left: number; width: number; height: number }) => {
275
+ setCropBox(getClampedBox(newBox.left, newBox.top, newBox.width, newBox.height));
276
+ };
277
+
278
+ const panMove = useRef(
279
+ PanResponder.create({
280
+ onStartShouldSetPanResponder: () => true,
281
+ onPanResponderGrant: () => {
282
+ initialCrop.top = cropBoxRef.current.top;
283
+ initialCrop.left = cropBoxRef.current.left;
284
+ initialCrop.width = cropBoxRef.current.width;
285
+ initialCrop.height = cropBoxRef.current.height;
286
+ },
287
+ onPanResponderMove: (_, gesture) => {
288
+ const rendered = renderedImageSizeRef.current;
289
+ let l = initialCrop.left + gesture.dx;
290
+ let t = initialCrop.top + gesture.dy;
291
+
292
+ l = Math.max(rendered.left, Math.min(l, rendered.left + rendered.width - initialCrop.width));
293
+ t = Math.max(rendered.top, Math.min(t, rendered.top + rendered.height - initialCrop.height));
294
+
295
+ setCropBox(prev => ({ ...prev, left: l, top: t }));
296
+ },
297
+ onPanResponderRelease: () => updateResolution()
298
+ })
299
+ ).current;
300
+
301
+ const createSideResponder = (side: string) =>
302
+ PanResponder.create({
303
+ onStartShouldSetPanResponder: () => true,
304
+ onPanResponderGrant: () => {
305
+ initialCrop.top = cropBoxRef.current.top;
306
+ initialCrop.left = cropBoxRef.current.left;
307
+ initialCrop.width = cropBoxRef.current.width;
308
+ initialCrop.height = cropBoxRef.current.height;
309
+ },
310
+ onPanResponderMove: (_, gesture) => {
311
+ let { top, left, width, height } = initialCrop;
312
+ if (side === 'left') {
313
+ left += gesture.dx;
314
+ width -= gesture.dx;
315
+ } else if (side === 'right') {
316
+ width += gesture.dx;
317
+ }
318
+ updateCrop({ top, left, width, height });
319
+ },
320
+ onPanResponderRelease: () => updateResolution()
321
+ });
322
+
323
+ const panLeft = useRef(createSideResponder('left')).current;
324
+ const panRight = useRef(createSideResponder('right')).current;
325
+
326
+ const createCornerResponder = (corner: string) =>
327
+ PanResponder.create({
328
+ onStartShouldSetPanResponder: () => true,
329
+ onPanResponderGrant: () => {
330
+ initialCrop.top = cropBoxRef.current.top;
331
+ initialCrop.left = cropBoxRef.current.left;
332
+ initialCrop.width = cropBoxRef.current.width;
333
+ initialCrop.height = cropBoxRef.current.height;
334
+ },
335
+ onPanResponderMove: (_, gesture) => {
336
+ let { top: t, left: l, width: w, height: h } = initialCrop;
337
+ const ratio = ratios.find(r => r.label === selectedRatio)?.ratio;
338
+
339
+ if (corner === 'TL') {
340
+ t += gesture.dy;
341
+ l += gesture.dx;
342
+ w -= gesture.dx;
343
+ h -= gesture.dy;
344
+ } else if (corner === 'TR') {
345
+ t += gesture.dy;
346
+ w += gesture.dx;
347
+ h -= gesture.dy;
348
+ } else if (corner === 'BL') {
349
+ l += gesture.dx;
350
+ w -= gesture.dx;
351
+ h += gesture.dy;
352
+ } else if (corner === 'BR') {
353
+ w += gesture.dx;
354
+ h += gesture.dy;
355
+ }
356
+
357
+ if (ratio && isFixedRatio) {
358
+ if (corner === 'BR' || corner === 'BL') h = w / ratio;
359
+ else w = h * ratio;
360
+ }
361
+
362
+ updateCrop({ top: t, left: l, width: w, height: h });
363
+ },
364
+ onPanResponderRelease: () => updateResolution()
365
+ });
366
+
367
+ const panTL = useRef(createCornerResponder('TL')).current;
368
+ const panTR = useRef(createCornerResponder('TR')).current;
369
+ const panBL = useRef(createCornerResponder('BL')).current;
370
+ const panBR = useRef(createCornerResponder('BR')).current;
371
+
372
+ const handleSave = async () => {
373
+ try {
374
+ setSaving(true);
375
+ if (!imageSize.width || !renderedImageSize.width) return;
376
+ const isRotated = (rotation % 180 === 90);
377
+ const pixelWidth = isRotated ? imageSize.height : imageSize.width;
378
+ const scale = pixelWidth / renderedImageSize.width;
379
+
380
+ const finalCrop = {
381
+ x: Math.round((cropBox.left - renderedImageSize.left) * scale),
382
+ y: Math.round((cropBox.top - renderedImageSize.top) * scale),
383
+ width: Math.round(cropBox.width * scale),
384
+ height: Math.round(cropBox.height * scale),
385
+ };
386
+
387
+ const options = {
388
+ rotateDegrees: straightenAngle + rotation,
389
+ flipX,
390
+ flipY,
391
+ crop: finalCrop,
392
+ brightness: 0, contrast: 1, saturation: 1, grayscale: false,
393
+ };
394
+
395
+ const outUri = item.type === 'image'
396
+ ? await editImage(item.uri, options)
397
+ : await trimVideo(item.uri, { startMs: 0, endMs: item.durationMs || 10000, ...options });
398
+
399
+ onSave(outUri, item.type === 'image' ? outUri : undefined, item.durationMs);
400
+ } catch (err: any) {
401
+ Alert.alert('Apply failed', err?.message ?? 'Could not process crop.');
402
+ } finally {
403
+ setSaving(false);
404
+ }
405
+ };
406
+
407
+ const handleReset = () => {
408
+ setStraightenAngle(0);
409
+ setRotation(0);
410
+ setFlipX(false);
411
+ setFlipY(false);
412
+ setSelectedRatio('Free');
413
+ setIsFixedRatio(false);
414
+ };
415
+
416
+ const sliderPan = useRef(
417
+ PanResponder.create({
418
+ onStartShouldSetPanResponder: () => true,
419
+ onPanResponderMove: (_, gesture) => {
420
+ const delta = gesture.dx * 0.1;
421
+ setStraightenAngle((prev) => Math.max(-45, Math.min(45, prev + delta)));
422
+ },
423
+ })
424
+ ).current;
425
+
426
+ const renderDial = () => {
427
+ const marks = [];
428
+ for (let i = -30; i <= 30; i++) {
429
+ const isMajor = i % 5 === 0;
430
+ const isCenter = i === 0;
431
+ marks.push(
432
+ <View
433
+ key={i}
434
+ style={[
435
+ styles.dialMark,
436
+ isMajor && styles.dialMarkLong,
437
+ isCenter && styles.dialMarkCenter,
438
+ Math.abs(i - straightenAngle / 2) < 0.5 && styles.dialMarkActive
439
+ ]}
440
+ />
441
+ );
442
+ }
443
+ return marks;
444
+ };
445
+
446
+ return (
447
+ <View style={styles.container}>
448
+ <SafeAreaView style={styles.safeArea} edges={['left', 'right', 'bottom']}>
449
+ <View style={styles.topBar}>
450
+ <Pressable onPress={onBack} style={styles.topBtn}>
451
+ <Text style={{ color: '#fff', fontSize: 16 }}>Cancel</Text>
452
+ </Pressable>
453
+
454
+ <View style={{ width: 60 }} />
455
+
456
+ <View style={{ width: 60 }} />
457
+ </View>
458
+
459
+ <View style={styles.previewContainer}>
460
+ <View
461
+ style={styles.mediaContainer}
462
+ onLayout={(e) => setMediaLayout(e.nativeEvent.layout)}
463
+ >
464
+ {renderedImageSize.width > 0 && (
465
+ <View style={{
466
+ position: 'absolute',
467
+ top: renderedImageSize.top,
468
+ left: renderedImageSize.left,
469
+ width: renderedImageSize.width,
470
+ height: renderedImageSize.height,
471
+ backgroundColor: '#000',
472
+ }}>
473
+ {item.type === 'video' ? (
474
+ <VideoPreview
475
+ uri={item.uri}
476
+ paused={false}
477
+ muted={true}
478
+ style={[
479
+ styles.media,
480
+ {
481
+ position: 'absolute',
482
+ top: (renderedImageSize as any).imgT,
483
+ left: (renderedImageSize as any).imgL,
484
+ width: (renderedImageSize as any).imgW,
485
+ height: (renderedImageSize as any).imgH,
486
+ transform: [
487
+ { rotate: `${rotation}deg` },
488
+ { rotate: `${straightenAngle}deg` },
489
+ { scaleX: flipX ? -1 : 1 },
490
+ { scaleY: flipY ? -1 : 1 }
491
+ ]
492
+ }
493
+ ]}
494
+ resizeMode="contain"
495
+ />
496
+ ) : (
497
+ <Image
498
+ source={{ uri: item.uri }}
499
+ style={[
500
+ styles.media,
501
+ {
502
+ position: 'absolute',
503
+ top: (renderedImageSize as any).imgT,
504
+ left: (renderedImageSize as any).imgL,
505
+ width: (renderedImageSize as any).imgW,
506
+ height: (renderedImageSize as any).imgH,
507
+ transform: [
508
+ { rotate: `${rotation}deg` },
509
+ { rotate: `${straightenAngle}deg` },
510
+ { scaleX: flipX ? -1 : 1 },
511
+ { scaleY: flipY ? -1 : 1 }
512
+ ]
513
+ }
514
+ ]}
515
+ resizeMode="contain"
516
+ />
517
+ )}
518
+ </View>
519
+ )}
520
+
521
+ <View style={[styles.shading, { top: renderedImageSize.top, left: renderedImageSize.left, width: renderedImageSize.width, height: cropBox.top - renderedImageSize.top }]} pointerEvents="none" />
522
+ <View style={[styles.shading, { top: cropBox.top + cropBox.height, left: renderedImageSize.left, width: renderedImageSize.width, height: renderedImageSize.top + renderedImageSize.height - (cropBox.top + cropBox.height) }]} pointerEvents="none" />
523
+ <View style={[styles.shading, { top: cropBox.top, left: renderedImageSize.left, width: cropBox.left - renderedImageSize.left, height: cropBox.height }]} pointerEvents="none" />
524
+ <View style={[styles.shading, { top: cropBox.top, left: cropBox.left + cropBox.width, width: renderedImageSize.left + renderedImageSize.width - (cropBox.left + cropBox.width), height: cropBox.height }]} pointerEvents="none" />
525
+
526
+ <View
527
+ style={[styles.cropBox, {
528
+ top: cropBox.top,
529
+ left: cropBox.left,
530
+ width: cropBox.width,
531
+ height: cropBox.height,
532
+ position: 'absolute'
533
+ }]}
534
+ >
535
+ <View style={[StyleSheet.absoluteFill, { backgroundColor: 'transparent' }]} {...panMove.panHandlers} />
536
+ <View style={[styles.gridV1, { pointerEvents: 'none' }]} />
537
+ <View style={[styles.gridV2, { pointerEvents: 'none' }]} />
538
+ <View style={[styles.gridH1, { pointerEvents: 'none' }]} />
539
+ <View style={[styles.gridH2, { pointerEvents: 'none' }]} />
540
+ <View style={[styles.corner, styles.cornerTL, { zIndex: 1000 }]} {...panTL.panHandlers}>
541
+ <View style={[styles.cornerVisual, { borderTopWidth: 4, borderLeftWidth: 4 }]} />
542
+ </View>
543
+ <View style={[styles.corner, styles.cornerTR, { zIndex: 1000 }]} {...panTR.panHandlers}>
544
+ <View style={[styles.cornerVisual, { borderTopWidth: 4, borderRightWidth: 4 }]} />
545
+ </View>
546
+ <View style={[styles.corner, styles.cornerBL, { zIndex: 1000 }]} {...panBL.panHandlers}>
547
+ <View style={[styles.cornerVisual, { borderBottomWidth: 4, borderLeftWidth: 4 }]} />
548
+ </View>
549
+ <View style={[styles.corner, styles.cornerBR, { zIndex: 1000 }]} {...panBR.panHandlers}>
550
+ <View style={[styles.cornerVisual, { borderBottomWidth: 4, borderRightWidth: 4 }]} />
551
+ </View>
552
+ <View style={[styles.sideHandle, styles.sideHandleLeft, { zIndex: 900 }]} {...panLeft.panHandlers} />
553
+ <View style={[styles.sideHandle, styles.sideHandleRight, { zIndex: 900 }]} {...panRight.panHandlers} />
554
+ </View>
555
+ </View>
556
+ </View>
557
+
558
+ <View style={styles.bottomPanel}>
559
+
560
+ <View style={styles.panelHeader}>
561
+ <Pressable style={styles.resetBtn} onPress={handleReset}>
562
+ <ResetIcon />
563
+ <Text style={styles.resetText}>Reset</Text>
564
+ </Pressable>
565
+ <View style={styles.panelTitleContainer}>
566
+ <Text style={styles.panelTitle}>Aspect Ratio</Text>
567
+ </View>
568
+ <Pressable style={[styles.fixedRatioBtn, isFixedRatio && styles.fixedRatioBtnActive]} onPress={() => setIsFixedRatio(!isFixedRatio)}>
569
+ <Text style={styles.fixedRatioText}>{isFixedRatio ? 'Locked' : 'Unlocked'}</Text>
570
+ </Pressable>
571
+ </View>
572
+
573
+ {/* Straighten Control */}
574
+ <View style={styles.straightenContainer}>
575
+ <View style={styles.sliderWrapper}>
576
+ <Pressable
577
+ style={[styles.flipBtn, (flipX || flipY) && { backgroundColor: '#333', borderRadius: 8 }]}
578
+ onPress={() => setFlipX(!flipX)}
579
+ onLongPress={() => setFlipY(!flipY)}
580
+ >
581
+ <FlipIcon />
582
+ </Pressable>
583
+ <View style={styles.dialContainer} {...sliderPan.panHandlers}>
584
+ <View style={styles.dialCenterLine} />
585
+ <View style={styles.dialMarksWrapper}>
586
+ {renderDial()}
587
+ </View>
588
+ </View>
589
+ <Pressable style={styles.rotateBtn} onPress={() => setRotation(r => (r + 90) % 360)}>
590
+ <RotateIcon />
591
+ </Pressable>
592
+ </View>
593
+ </View>
594
+
595
+ {/* Ratio Selector */}
596
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.ratioScroll} contentContainerStyle={styles.ratioContent}>
597
+ {ratios.map((r) => {
598
+ const isSelected = selectedRatio === r.label;
599
+ const boxRatio = r.ratio || 1;
600
+ // Limit box size for icon
601
+ const boxStyle = {
602
+ width: boxRatio > 1 ? 24 : 24 * boxRatio,
603
+ height: boxRatio > 1 ? 24 / boxRatio : 24,
604
+ borderWidth: 1.5,
605
+ borderColor: isSelected ? '#4A8CFF' : '#666',
606
+ borderRadius: 2,
607
+ };
608
+
609
+ return (
610
+ <Pressable key={r.label} style={styles.ratioItem} onPress={() => handleRatioSelect(r.label, r.ratio)}>
611
+ <View style={[styles.ratioIconBox, isSelected && styles.ratioIconBoxActive]}>
612
+ <View style={boxStyle} />
613
+ </View>
614
+ <Text style={[styles.ratioLabel, isSelected && styles.ratioLabelActive]}>{r.label}</Text>
615
+ </Pressable>
616
+ );
617
+ })}
618
+ </ScrollView>
619
+
620
+ {/* Footer Status with Save Button */}
621
+ <View style={styles.footerStatus}>
622
+ <View style={styles.footerLeft}>
623
+ <Text style={styles.resolutionText}>{resolution.w} × {resolution.h} px</Text>
624
+ </View>
625
+
626
+ <Pressable onPress={handleSave} style={styles.primarySaveBtn} disabled={saving}>
627
+ {saving ? <Text style={styles.saveText}>Applying...</Text> : <Text style={styles.saveText}>Apply Crop</Text>}
628
+ </Pressable>
629
+ </View>
630
+ </View>
631
+ </SafeAreaView>
632
+ </View>
633
+ );
634
+ }
635
+
636
+ const styles = StyleSheet.create({
637
+ container: {
638
+ flex: 1,
639
+ backgroundColor: '#000',
640
+ },
641
+ safeArea: {
642
+ flex: 1,
643
+ },
644
+ quickActions: {
645
+ flexDirection: 'row',
646
+ gap: 12,
647
+ },
648
+ quickActionBtn: {
649
+ paddingHorizontal: 12,
650
+ paddingVertical: 6,
651
+ borderRadius: 15,
652
+ backgroundColor: '#1a1a1a',
653
+ borderWidth: 1,
654
+ borderColor: '#333',
655
+ },
656
+ quickActionBtnActive: {
657
+ backgroundColor: '#fff',
658
+ borderColor: '#fff',
659
+ },
660
+ quickActionText: {
661
+ color: '#999',
662
+ fontSize: 10,
663
+ fontWeight: '800',
664
+ },
665
+ quickActionTextActive: {
666
+ color: '#000',
667
+ },
668
+ topBar: {
669
+ flexDirection: 'row',
670
+ alignItems: 'center',
671
+ justifyContent: 'space-between',
672
+ paddingHorizontal: 16,
673
+ height: 56,
674
+ },
675
+ topBtn: {
676
+ padding: 8,
677
+ },
678
+ saveText: {
679
+ color: '#fff',
680
+ fontSize: 16,
681
+ fontWeight: '700',
682
+ },
683
+ primarySaveBtn: {
684
+ backgroundColor: '#4A8CFF',
685
+ paddingHorizontal: 24,
686
+ paddingVertical: 12,
687
+ borderRadius: 12,
688
+ },
689
+ previewContainer: {
690
+ flex: 1,
691
+ backgroundColor: '#000',
692
+ justifyContent: 'center',
693
+ alignItems: 'center',
694
+ },
695
+ mediaContainer: {
696
+ flex: 1, // Full flexible space
697
+ width: SCREEN_WIDTH,
698
+ justifyContent: 'center',
699
+ alignItems: 'center',
700
+ },
701
+ media: {
702
+ width: '100%',
703
+ height: '100%',
704
+ },
705
+ cropBox: {
706
+ position: 'absolute',
707
+ borderWidth: 1,
708
+ borderColor: '#4A8CFF',
709
+ backgroundColor: 'transparent',
710
+ },
711
+ gridV1: {
712
+ position: 'absolute',
713
+ left: '33.3%',
714
+ top: 0,
715
+ bottom: 0,
716
+ width: 0.5,
717
+ backgroundColor: 'rgba(255,255,255,0.5)',
718
+ },
719
+ gridV2: {
720
+ position: 'absolute',
721
+ left: '66.6%',
722
+ top: 0,
723
+ bottom: 0,
724
+ width: 0.5,
725
+ backgroundColor: 'rgba(255,255,255,0.5)',
726
+ },
727
+ gridH1: {
728
+ position: 'absolute',
729
+ top: '33.3%',
730
+ left: 0,
731
+ right: 0,
732
+ height: 0.5,
733
+ backgroundColor: 'rgba(255,255,255,0.5)',
734
+ },
735
+ gridH2: {
736
+ position: 'absolute',
737
+ top: '66.6%',
738
+ left: 0,
739
+ right: 0,
740
+ height: 0.5,
741
+ backgroundColor: 'rgba(255,255,255,0.5)',
742
+ },
743
+ corner: {
744
+ position: 'absolute',
745
+ width: 40,
746
+ height: 40,
747
+ justifyContent: 'center',
748
+ alignItems: 'center',
749
+ zIndex: 100,
750
+ },
751
+ shading: {
752
+ position: 'absolute',
753
+ backgroundColor: 'rgba(0,0,0,0.6)',
754
+ },
755
+ cornerVisual: {
756
+ width: 20,
757
+ height: 20,
758
+ borderColor: '#4A8CFF',
759
+ },
760
+ cornerTL: {
761
+ top: -10,
762
+ left: -10,
763
+ },
764
+ cornerTR: {
765
+ top: -10,
766
+ right: -10,
767
+ },
768
+ cornerBL: {
769
+ bottom: -10,
770
+ left: -10,
771
+ },
772
+ cornerBR: {
773
+ bottom: -10,
774
+ right: -10,
775
+ },
776
+ sideHandle: {
777
+ position: 'absolute',
778
+ width: 10,
779
+ height: 30,
780
+ backgroundColor: '#4A8CFF',
781
+ borderRadius: 2,
782
+ zIndex: 90,
783
+ },
784
+ sideHandleLeft: {
785
+ left: -2,
786
+ top: '50%',
787
+ marginTop: -10,
788
+ },
789
+ sideHandleRight: {
790
+ right: -2,
791
+ top: '50%',
792
+ marginTop: -10,
793
+ },
794
+ bottomPanel: {
795
+ backgroundColor: '#111',
796
+ borderTopLeftRadius: 24,
797
+ borderTopRightRadius: 24,
798
+ paddingBottom: Platform.OS === 'ios' ? 20 : 10,
799
+ },
800
+ panelHeader: {
801
+ flexDirection: 'row',
802
+ alignItems: 'center',
803
+ justifyContent: 'space-between',
804
+ paddingHorizontal: 20,
805
+ paddingVertical: 12,
806
+ },
807
+ resetBtn: {
808
+ flexDirection: 'row',
809
+ alignItems: 'center',
810
+ gap: 6,
811
+ },
812
+ resetText: {
813
+ color: '#fff',
814
+ fontSize: 14,
815
+ fontWeight: '500',
816
+ },
817
+ panelTitleContainer: {
818
+ flexDirection: 'row',
819
+ alignItems: 'center',
820
+ gap: 4,
821
+ },
822
+ panelTitle: {
823
+ color: '#fff',
824
+ fontSize: 18,
825
+ fontWeight: '600',
826
+ },
827
+ straightenContainer: {
828
+ alignItems: 'center',
829
+ paddingVertical: 10,
830
+ },
831
+ straightenText: {
832
+ color: '#999',
833
+ fontSize: 12,
834
+ marginBottom: 10,
835
+ },
836
+ sliderWrapper: {
837
+ flexDirection: 'row',
838
+ alignItems: 'center',
839
+ width: '100%',
840
+ paddingHorizontal: 20,
841
+ gap: 12,
842
+ },
843
+ flipBtn: {
844
+ padding: 8,
845
+ },
846
+ rotateBtn: {
847
+ padding: 8,
848
+ },
849
+ dialContainer: {
850
+ flex: 1,
851
+ height: 40,
852
+ backgroundColor: '#222',
853
+ borderRadius: 12,
854
+ overflow: 'hidden',
855
+ justifyContent: 'center',
856
+ alignItems: 'center',
857
+ },
858
+ dialCenterLine: {
859
+ position: 'absolute',
860
+ top: 5,
861
+ bottom: 5,
862
+ width: 2,
863
+ backgroundColor: '#fff',
864
+ zIndex: 10,
865
+ },
866
+ dialMarksWrapper: {
867
+ flexDirection: 'row',
868
+ alignItems: 'center',
869
+ gap: 8,
870
+ },
871
+ dialMark: {
872
+ width: 1,
873
+ height: 8,
874
+ backgroundColor: '#444',
875
+ },
876
+ dialMarkLong: {
877
+ height: 14,
878
+ backgroundColor: '#666',
879
+ },
880
+ dialMarkCenter: {
881
+ backgroundColor: '#fff',
882
+ height: 18,
883
+ width: 2,
884
+ },
885
+ dialMarkActive: {
886
+ backgroundColor: '#fff',
887
+ },
888
+ ratioScroll: {
889
+ marginTop: 20,
890
+ },
891
+ ratioContent: {
892
+ paddingHorizontal: 16,
893
+ gap: 20,
894
+ },
895
+ ratioItem: {
896
+ alignItems: 'center',
897
+ gap: 6,
898
+ width: 50,
899
+ },
900
+ ratioIconBox: {
901
+ width: 40,
902
+ height: 40,
903
+ justifyContent: 'center',
904
+ alignItems: 'center',
905
+ borderRadius: 8,
906
+ backgroundColor: 'transparent',
907
+ },
908
+ ratioIconBoxActive: {
909
+ backgroundColor: '#333',
910
+ },
911
+ ratioIcon: {
912
+ fontSize: 20,
913
+ color: '#fff',
914
+ },
915
+ ratioLabel: {
916
+ color: '#666',
917
+ fontSize: 12,
918
+ },
919
+ ratioLabelActive: {
920
+ color: '#fff',
921
+ fontWeight: '600',
922
+ },
923
+ footerStatus: {
924
+ flexDirection: 'row',
925
+ alignItems: 'center',
926
+ justifyContent: 'space-between',
927
+ paddingHorizontal: 20,
928
+ paddingVertical: 16,
929
+ borderTopWidth: 0.5,
930
+ borderTopColor: '#222',
931
+ marginTop: 10,
932
+ },
933
+ footerLeft: {
934
+ flexDirection: 'row',
935
+ alignItems: 'center',
936
+ gap: 8,
937
+ },
938
+ footerIcon: {
939
+ fontSize: 18,
940
+ },
941
+ footerText: {
942
+ color: '#fff',
943
+ fontSize: 14,
944
+ fontWeight: '500',
945
+ },
946
+ footerCenter: {
947
+ flex: 1,
948
+ alignItems: 'center',
949
+ },
950
+ resolutionText: {
951
+ color: '#999',
952
+ fontSize: 12,
953
+ },
954
+ fixedRatioBtn: {
955
+ backgroundColor: '#222',
956
+ paddingHorizontal: 16,
957
+ paddingVertical: 8,
958
+ borderRadius: 20,
959
+ },
960
+ fixedRatioBtnActive: {
961
+ backgroundColor: '#333',
962
+ },
963
+ fixedRatioText: {
964
+ color: '#fff',
965
+ fontSize: 13,
966
+ fontWeight: '500',
967
+ },
968
+ });