@technotoil/image-video-editor 0.1.0 → 0.1.2

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 (63) hide show
  1. package/README.md +17 -3
  2. package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +2 -6
  3. package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +75 -35
  4. package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +51 -35
  5. package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +98 -117
  6. package/ios/RNMediaEditor.m +38 -7
  7. package/ios/RNMediaLibrary.m +19 -15
  8. package/ios/RNVideoPreviewManager.m +2 -0
  9. package/lib/commonjs/assets/frames/film_vintage.png +0 -0
  10. package/lib/commonjs/assets/frames/floral_gold.png +0 -0
  11. package/lib/commonjs/assets/frames/minimal_double.png +0 -0
  12. package/lib/commonjs/assets/frames/polaroid_white.png +0 -0
  13. package/lib/commonjs/assets/frames/watercolor_floral.png +0 -0
  14. package/lib/commonjs/components/VideoEditor.js +235 -0
  15. package/lib/commonjs/components/VideoEditor.js.map +1 -0
  16. package/lib/commonjs/index.js +14 -0
  17. package/lib/commonjs/index.js.map +1 -0
  18. package/lib/commonjs/native/CameraView.js +109 -0
  19. package/lib/commonjs/native/CameraView.js.map +1 -0
  20. package/lib/commonjs/native/FrameGrabber.js +17 -0
  21. package/lib/commonjs/native/FrameGrabber.js.map +1 -0
  22. package/lib/commonjs/native/MediaEditor.js +24 -0
  23. package/lib/commonjs/native/MediaEditor.js.map +1 -0
  24. package/lib/commonjs/native/MediaLibrary.js +45 -0
  25. package/lib/commonjs/native/MediaLibrary.js.map +1 -0
  26. package/lib/commonjs/native/MediaPicker.js +17 -0
  27. package/lib/commonjs/native/MediaPicker.js.map +1 -0
  28. package/lib/commonjs/native/MediaPlayer.js +17 -0
  29. package/lib/commonjs/native/MediaPlayer.js.map +1 -0
  30. package/lib/commonjs/native/VideoPreview.js +17 -0
  31. package/lib/commonjs/native/VideoPreview.js.map +1 -0
  32. package/lib/commonjs/package.json +1 -0
  33. package/lib/commonjs/screens/CropScreen.js +1233 -0
  34. package/lib/commonjs/screens/CropScreen.js.map +1 -0
  35. package/lib/commonjs/screens/EditorScreen.js +6043 -0
  36. package/lib/commonjs/screens/EditorScreen.js.map +1 -0
  37. package/lib/commonjs/screens/ExportScreen.js +294 -0
  38. package/lib/commonjs/screens/ExportScreen.js.map +1 -0
  39. package/lib/commonjs/screens/GalleryScreen.js +510 -0
  40. package/lib/commonjs/screens/GalleryScreen.js.map +1 -0
  41. package/lib/commonjs/screens/PickScreen.js +1353 -0
  42. package/lib/commonjs/screens/PickScreen.js.map +1 -0
  43. package/lib/commonjs/types.js +2 -0
  44. package/lib/commonjs/types.js.map +1 -0
  45. package/lib/module/components/VideoEditor.js +104 -31
  46. package/lib/module/components/VideoEditor.js.map +1 -1
  47. package/lib/module/screens/CropScreen.js +26 -9
  48. package/lib/module/screens/CropScreen.js.map +1 -1
  49. package/lib/module/screens/EditorScreen.js +371 -86
  50. package/lib/module/screens/EditorScreen.js.map +1 -1
  51. package/lib/module/screens/PickScreen.js +245 -93
  52. package/lib/module/screens/PickScreen.js.map +1 -1
  53. package/lib/typescript/src/components/VideoEditor.d.ts +18 -2
  54. package/lib/typescript/src/screens/CropScreen.d.ts +3 -1
  55. package/lib/typescript/src/screens/EditorScreen.d.ts +2 -1
  56. package/lib/typescript/src/screens/PickScreen.d.ts +6 -1
  57. package/lib/typescript/src/types.d.ts +1 -0
  58. package/package.json +17 -8
  59. package/src/components/VideoEditor.tsx +82 -11
  60. package/src/screens/CropScreen.tsx +54 -33
  61. package/src/screens/EditorScreen.tsx +366 -106
  62. package/src/screens/PickScreen.tsx +231 -76
  63. package/src/types.ts +1 -0
@@ -2,12 +2,28 @@ import React from 'react';
2
2
  import type { MediaItem, MusicTrack } from '../types';
3
3
  export interface VideoEditorProps {
4
4
  onClose?: () => void;
5
- onFinishExport?: (editedMedia: Record<string, MediaItem>, paths: string[], editedArray: MediaItem[], cameraMode?: string) => void;
5
+ onFinishExport?: (editedMedia: Record<string, MediaItem>, paths: string[], editedArray: MediaItem[], cameraMode?: string, globalMusic?: MusicTrack) => void;
6
6
  headerTitle?: string;
7
7
  customCancelIcon?: React.ReactNode;
8
8
  onCancelPress?: () => void;
9
9
  cameraModes?: string[];
10
10
  defaultCameraMode?: string;
11
11
  musicList?: MusicTrack[];
12
+ /** Maximum number of media items user can select. Default: 1, Max allowed: 5 */
13
+ maxSelection?: number;
14
+ /**
15
+ * Enforce a fixed aspect ratio for image/video preview.
16
+ * '1:1' = Square, '4:3' = Standard, '4:5' = Instagram Portrait,
17
+ * '16:9' = Landscape, '9:16' = Portrait, 'free' = No restriction (default)
18
+ */
19
+ aspectRatio?: '1:1' | '4:3' | '4:5' | '16:9' | '9:16' | 'free';
20
+ /**
21
+ * Maximum video duration allowed (in milliseconds).
22
+ */
23
+ maxVideoDurationMs?: number;
24
+ /** Filter the media type that can be picked. Default: 'any' */
25
+ mediaType?: 'photo' | 'video' | 'any';
26
+ /** Control which tabs are shown in the picker. Default: ['GALLERY', 'PHOTO', 'VIDEO'] */
27
+ mediaTabs?: ('GALLERY' | 'PHOTO' | 'VIDEO')[];
12
28
  }
13
- export default function VideoEditor({ onClose, onFinishExport, headerTitle, customCancelIcon, onCancelPress, cameraModes, defaultCameraMode, musicList, }: VideoEditorProps): React.JSX.Element;
29
+ export default function VideoEditor({ onClose, onFinishExport, headerTitle, customCancelIcon, onCancelPress, cameraModes, defaultCameraMode, musicList, maxSelection, aspectRatio, maxVideoDurationMs, mediaType, mediaTabs, }: VideoEditorProps): React.JSX.Element;
@@ -4,6 +4,8 @@ interface CropScreenProps {
4
4
  item: MediaItem;
5
5
  onBack: () => void;
6
6
  onSave: (uri: string, thumb?: string, duration?: number) => void;
7
+ aspectRatio?: '1:1' | '4:3' | '4:5' | '16:9' | '9:16' | 'free';
8
+ maxVideoDurationMs?: number;
7
9
  }
8
- export declare function CropScreen({ item, onBack, onSave }: CropScreenProps): React.JSX.Element;
10
+ export declare function CropScreen({ item, onBack, onSave, aspectRatio, maxVideoDurationMs }: CropScreenProps): React.JSX.Element;
9
11
  export {};
@@ -1,10 +1,11 @@
1
1
  import React from 'react';
2
2
  import type { MediaItem, MusicTrack } from '../types';
3
- export declare function EditorScreen({ items, initialIndex, onBack, onSaved, onOpenCrop, musicList, }: {
3
+ export declare function EditorScreen({ items, initialIndex, onBack, onSaved, onOpenCrop, musicList, maxVideoDurationMs, }: {
4
4
  items: MediaItem[];
5
5
  initialIndex?: number;
6
6
  onBack: () => void;
7
7
  onSaved: (updatedItems: MediaItem[]) => void;
8
8
  onOpenCrop: (item: MediaItem) => void;
9
9
  musicList?: MusicTrack[];
10
+ maxVideoDurationMs?: number;
10
11
  }): React.JSX.Element;
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import type { MediaItem } from '../types';
3
- export declare function PickScreen({ items, onPicked, onNext, headerTitle, customCancelIcon, onCancelPress, cameraModes, onCameraModeChange, defaultCameraMode, }: {
3
+ export declare function PickScreen({ isActive, items, onPicked, onNext, headerTitle, customCancelIcon, onCancelPress, cameraModes, onCameraModeChange, defaultCameraMode, maxSelection, aspectRatio, mediaType, mediaTabs, }: {
4
4
  items: MediaItem[];
5
5
  onPicked: (items: MediaItem[]) => void;
6
6
  onNext: (picked: MediaItem[]) => void;
@@ -10,4 +10,9 @@ export declare function PickScreen({ items, onPicked, onNext, headerTitle, custo
10
10
  cameraModes?: string[];
11
11
  onCameraModeChange?: (mode: string) => void;
12
12
  defaultCameraMode?: string;
13
+ maxSelection?: number;
14
+ aspectRatio?: '1:1' | '4:3' | '4:5' | '16:9' | '9:16' | 'free';
15
+ mediaType?: 'photo' | 'video' | 'any';
16
+ mediaTabs?: ('GALLERY' | 'PHOTO' | 'VIDEO')[];
17
+ isActive?: boolean;
13
18
  }): React.JSX.Element;
@@ -52,6 +52,7 @@ export type VideoTrimOptions = ImageEditOptions & {
52
52
  mute?: boolean;
53
53
  isImage?: boolean;
54
54
  musicUri?: string;
55
+ musicOffsetMs?: number;
55
56
  };
56
57
  export type FrameCaptureOptions = {
57
58
  timeMs: number;
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "@technotoil/image-video-editor",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A high-performance React Native image and video editor featuring video trimming, filters, photo overlay frames, and camera/gallery integration.",
5
- "main": "./lib/module/index.js",
6
- "types": "./lib/typescript/src/index.d.ts",
5
+ "main": "lib/commonjs/index.js",
6
+ "module": "lib/module/index.js",
7
+ "types": "lib/typescript/src/index.d.ts",
7
8
  "exports": {
8
9
  ".": {
9
- "source": "./src/index.tsx",
10
- "types": "./lib/typescript/src/index.d.ts",
11
- "default": "./lib/module/index.js"
12
- },
13
- "./package.json": "./package.json"
10
+ "import": {
11
+ "types": "./lib/typescript/src/index.d.ts",
12
+ "default": "./lib/module/index.js"
13
+ },
14
+ "require": {
15
+ "types": "./lib/typescript/src/index.d.ts",
16
+ "default": "./lib/commonjs/index.js"
17
+ }
18
+ }
14
19
  },
15
20
  "files": [
16
21
  "src",
@@ -90,6 +95,7 @@
90
95
  "source": "src",
91
96
  "output": "lib",
92
97
  "targets": [
98
+ "commonjs",
93
99
  [
94
100
  "module",
95
101
  {
@@ -97,5 +103,8 @@
97
103
  }
98
104
  ]
99
105
  ]
106
+ },
107
+ "dependencies": {
108
+ "react-native-fs": "^2.20.0"
100
109
  }
101
110
  }
@@ -1,5 +1,5 @@
1
1
  import React, { useState } from 'react';
2
- import { Alert, StatusBar, useColorScheme } from 'react-native';
2
+ import { Alert, StatusBar, useColorScheme, View, Text, ActivityIndicator } from 'react-native';
3
3
  import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
4
4
  import { PickScreen } from '../screens/PickScreen';
5
5
  import { CropScreen } from '../screens/CropScreen';
@@ -7,6 +7,9 @@ import { EditorScreen } from '../screens/EditorScreen';
7
7
  import { ExportScreen } from '../screens/ExportScreen';
8
8
  import { exportAsset } from '../native/MediaLibrary';
9
9
  import type { MediaItem, MusicTrack } from '../types';
10
+ import Ionicons from 'react-native-vector-icons/Ionicons';
11
+
12
+ Ionicons.loadFont().catch(() => {});
10
13
 
11
14
  export interface VideoEditorProps {
12
15
  onClose?: () => void;
@@ -14,7 +17,8 @@ export interface VideoEditorProps {
14
17
  editedMedia: Record<string, MediaItem>,
15
18
  paths: string[],
16
19
  editedArray: MediaItem[],
17
- cameraMode?: string
20
+ cameraMode?: string,
21
+ globalMusic?: MusicTrack
18
22
  ) => void;
19
23
  headerTitle?: string;
20
24
  customCancelIcon?: React.ReactNode;
@@ -22,6 +26,22 @@ export interface VideoEditorProps {
22
26
  cameraModes?: string[];
23
27
  defaultCameraMode?: string;
24
28
  musicList?: MusicTrack[];
29
+ /** Maximum number of media items user can select. Default: 1, Max allowed: 5 */
30
+ maxSelection?: number;
31
+ /**
32
+ * Enforce a fixed aspect ratio for image/video preview.
33
+ * '1:1' = Square, '4:3' = Standard, '4:5' = Instagram Portrait,
34
+ * '16:9' = Landscape, '9:16' = Portrait, 'free' = No restriction (default)
35
+ */
36
+ aspectRatio?: '1:1' | '4:3' | '4:5' | '16:9' | '9:16' | 'free';
37
+ /**
38
+ * Maximum video duration allowed (in milliseconds).
39
+ */
40
+ maxVideoDurationMs?: number;
41
+ /** Filter the media type that can be picked. Default: 'any' */
42
+ mediaType?: 'photo' | 'video' | 'any';
43
+ /** Control which tabs are shown in the picker. Default: ['GALLERY', 'PHOTO', 'VIDEO'] */
44
+ mediaTabs?: ('GALLERY' | 'PHOTO' | 'VIDEO')[];
25
45
  }
26
46
 
27
47
  export default function VideoEditor({
@@ -33,7 +53,13 @@ export default function VideoEditor({
33
53
  cameraModes,
34
54
  defaultCameraMode,
35
55
  musicList,
56
+ maxSelection = 1,
57
+ aspectRatio = 'free',
58
+ maxVideoDurationMs,
59
+ mediaType = 'any',
60
+ mediaTabs = ['GALLERY', 'PHOTO', 'VIDEO'],
36
61
  }: VideoEditorProps) {
62
+ const clampedMax = Math.min(5, Math.max(1, maxSelection));
37
63
  const isDarkMode = useColorScheme() === 'dark';
38
64
  const [screen, setScreen] = useState<'pick' | 'editor' | 'crop' | 'export'>('pick');
39
65
  const [items, setItems] = useState<MediaItem[]>([]);
@@ -41,19 +67,31 @@ export default function VideoEditor({
41
67
  const [editedMedia, setEditedMedia] = useState<Record<string, MediaItem>>({});
42
68
  const [originals, setOriginals] = useState<Record<string, MediaItem>>({});
43
69
  const [selectedCameraMode, setSelectedCameraMode] = useState<string>(defaultCameraMode || 'STORY');
70
+ const [processing, setProcessing] = useState(false);
71
+ const [exportCache, setExportCache] = useState<Record<string, string>>({});
44
72
 
45
73
  const ensureExported = async (item: MediaItem, ignoreEdits = false): Promise<MediaItem> => {
74
+ console.log(`[ensureExported] Start for item: ${item.id}, uri: ${item.uri}`);
46
75
  if (!ignoreEdits && editedMedia[item.id]) {
76
+ console.log(`[ensureExported] Found in editedMedia! Returning early.`);
47
77
  return editedMedia[item.id];
48
78
  }
79
+ if (exportCache[item.id]) {
80
+ console.log(`[ensureExported] Found in exportCache! Returning cached URI: ${exportCache[item.id]}`);
81
+ return { ...item, uri: exportCache[item.id] };
82
+ }
49
83
  if (!item.uri.startsWith('ph://') && !item.uri.startsWith('content://')) {
84
+ console.log(`[ensureExported] URI is already local file, skipping native export.`);
50
85
  return item;
51
86
  }
52
87
  try {
88
+ console.log(`[ensureExported] Calling native exportAsset...`);
53
89
  const fileUri = await exportAsset(item.id);
90
+ console.log(`[ensureExported] Native export success! New URI: ${fileUri}`);
91
+ setExportCache(prev => ({ ...prev, [item.id]: fileUri }));
54
92
  return { ...item, uri: fileUri };
55
93
  } catch (err: any) {
56
- console.error('ASSET EXPORT FROM LIBRARY FAILED:', err?.message ?? err);
94
+ console.error('[ensureExported] ASSET EXPORT FROM LIBRARY FAILED:', err?.message ?? err);
57
95
  return item;
58
96
  }
59
97
  };
@@ -66,17 +104,22 @@ export default function VideoEditor({
66
104
  backgroundColor="transparent"
67
105
  translucent={true}
68
106
  />
69
- {screen === 'pick' && (
107
+ <View style={{ flex: 1, display: screen === 'pick' ? 'flex' : 'none' }}>
70
108
  <PickScreen
109
+ isActive={screen === 'pick'}
71
110
  items={items}
72
111
  headerTitle={headerTitle}
73
112
  customCancelIcon={customCancelIcon}
74
113
  onCancelPress={onCancelPress || onClose}
75
114
  cameraModes={cameraModes}
76
115
  defaultCameraMode={defaultCameraMode}
116
+ maxSelection={clampedMax}
117
+ aspectRatio={aspectRatio}
77
118
  onCameraModeChange={(mode) => {
78
119
  setSelectedCameraMode(mode);
79
120
  }}
121
+ mediaType={mediaType}
122
+ mediaTabs={mediaTabs}
80
123
  onPicked={(picked: MediaItem[]) => {
81
124
  // Save originals for "Fresh Start" editing
82
125
  const newOriginals = { ...originals };
@@ -88,22 +131,48 @@ export default function VideoEditor({
88
131
  setItems(picked);
89
132
  }}
90
133
  onNext={async (picked) => {
134
+ console.log(`[onNext] Triggered with ${picked?.length} items`);
135
+ if (processing) {
136
+ console.log(`[onNext] Aborting, already processing!`);
137
+ return;
138
+ }
91
139
  if (!picked || picked.length === 0) {
140
+ console.log(`[onNext] Aborting, picked is empty!`);
92
141
  return;
93
142
  }
94
- const resolvedItems = await Promise.all(
95
- picked.map(item => ensureExported(item, false))
96
- );
97
- setItems(resolvedItems);
98
- setCurrent(resolvedItems[0]);
99
- setScreen('editor');
143
+ console.log(`[onNext] Setting processing=true`);
144
+ setProcessing(true);
145
+
146
+ try {
147
+ console.log(`[onNext] Starting Promise.all for ${picked.length} items`);
148
+ const resolvedItems = await Promise.all(
149
+ picked.map(item => ensureExported(item, false))
150
+ );
151
+
152
+ console.log(`[onNext] Promise.all completed! Updating state...`);
153
+ setItems(resolvedItems);
154
+ setCurrent(resolvedItems[0]);
155
+ setScreen('editor');
156
+ console.log(`[onNext] Screen set to editor`);
157
+ } catch (e) {
158
+ console.error(`[onNext] Promise.all threw an error!`, e);
159
+ } finally {
160
+ console.log(`[onNext] Finally block - setting processing=false`);
161
+ setProcessing(false);
162
+ }
100
163
  }}
101
164
  />
102
- )}
165
+ {processing && (
166
+ <View style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.5)', justifyContent: 'center', alignItems: 'center' }}>
167
+ <ActivityIndicator size="large" color="#ffffff" />
168
+ </View>
169
+ )}
170
+ </View>
103
171
  {screen === 'editor' && current && (
104
172
  <EditorScreen
105
173
  items={items}
106
174
  initialIndex={Math.max(0, items.findIndex(it => it.id === current.id))}
175
+ maxVideoDurationMs={maxVideoDurationMs}
107
176
  onBack={() => {
108
177
  setEditedMedia({});
109
178
  const restoredItems = items.map(item => originals[item.id] || item);
@@ -143,6 +212,8 @@ export default function VideoEditor({
143
212
  {screen === 'crop' && current && (
144
213
  <CropScreen
145
214
  item={current}
215
+ aspectRatio={aspectRatio}
216
+ maxVideoDurationMs={maxVideoDurationMs}
146
217
  onBack={() => setScreen('editor')}
147
218
  onSave={(uri, thumbnailUri, durationMs) => {
148
219
  const updated = {
@@ -99,12 +99,21 @@ interface CropScreenProps {
99
99
  item: MediaItem;
100
100
  onBack: () => void;
101
101
  onSave: (uri: string, thumb?: string, duration?: number) => void;
102
+ aspectRatio?: '1:1' | '4:3' | '4:5' | '16:9' | '9:16' | 'free';
103
+ maxVideoDurationMs?: number;
102
104
  }
103
- export function CropScreen({ item, onBack, onSave }: CropScreenProps) {
105
+ export function CropScreen({ item, onBack, onSave, aspectRatio = 'free', maxVideoDurationMs }: CropScreenProps) {
106
+ const isRatioLocked = aspectRatio !== 'free';
107
+ const getInitialRatioLabel = () => {
108
+ if (!aspectRatio || aspectRatio === 'free') return 'Free';
109
+ if (aspectRatio === '1:1') return 'Square';
110
+ return aspectRatio; // '4:3', '4:5', '16:9', '9:16'
111
+ };
112
+
104
113
  const [straightenAngle, setStraightenAngle] = useState(0);
105
114
  const [rotation, setRotation] = useState(0);
106
- const [selectedRatio, setSelectedRatio] = useState<string>('Free');
107
- const [isFixedRatio, setIsFixedRatio] = useState(false);
115
+ const [selectedRatio, setSelectedRatio] = useState<string>(getInitialRatioLabel());
116
+ const [isFixedRatio, setIsFixedRatio] = useState(isRatioLocked);
108
117
  const [loading, setLoading] = useState(true);
109
118
  const [saving, setSaving] = useState(false);
110
119
 
@@ -394,9 +403,13 @@ export function CropScreen({ item, onBack, onSave }: CropScreenProps) {
394
403
 
395
404
  const outUri = item.type === 'image'
396
405
  ? await editImage(item.uri, options)
397
- : await trimVideo(item.uri, { startMs: 0, endMs: item.durationMs || 10000, ...options });
406
+ : await trimVideo(item.uri, { startMs: 0, endMs: maxVideoDurationMs ? Math.min(item.durationMs || 10000, maxVideoDurationMs) : (item.durationMs || 10000), ...options });
398
407
 
399
- onSave(outUri, item.type === 'image' ? outUri : undefined, item.durationMs);
408
+ const clampedDuration = item.type === 'video' && maxVideoDurationMs
409
+ ? Math.min(item.durationMs || 10000, maxVideoDurationMs)
410
+ : item.durationMs;
411
+
412
+ onSave(outUri, item.type === 'image' ? outUri : undefined, clampedDuration);
400
413
  } catch (err: any) {
401
414
  Alert.alert('Apply failed', err?.message ?? 'Could not process crop.');
402
415
  } finally {
@@ -409,8 +422,8 @@ export function CropScreen({ item, onBack, onSave }: CropScreenProps) {
409
422
  setRotation(0);
410
423
  setFlipX(false);
411
424
  setFlipY(false);
412
- setSelectedRatio('Free');
413
- setIsFixedRatio(false);
425
+ setSelectedRatio(getInitialRatioLabel());
426
+ setIsFixedRatio(isRatioLocked);
414
427
  };
415
428
 
416
429
  const sliderPan = useRef(
@@ -565,9 +578,15 @@ export function CropScreen({ item, onBack, onSave }: CropScreenProps) {
565
578
  <View style={styles.panelTitleContainer}>
566
579
  <Text style={styles.panelTitle}>Aspect Ratio</Text>
567
580
  </View>
568
- <Pressable style={[styles.fixedRatioBtn, isFixedRatio && styles.fixedRatioBtnActive]} onPress={() => setIsFixedRatio(!isFixedRatio)}>
569
- <Text style={styles.fixedRatioText}>{isFixedRatio ? 'Locked' : 'Unlocked'}</Text>
570
- </Pressable>
581
+ {!isRatioLocked ? (
582
+ <Pressable style={[styles.fixedRatioBtn, isFixedRatio && styles.fixedRatioBtnActive]} onPress={() => setIsFixedRatio(!isFixedRatio)}>
583
+ <Text style={styles.fixedRatioText}>{isFixedRatio ? 'Locked' : 'Unlocked'}</Text>
584
+ </Pressable>
585
+ ) : (
586
+ <View style={[styles.fixedRatioBtn, styles.fixedRatioBtnActive, { opacity: 0.7 }]}>
587
+ <Text style={styles.fixedRatioText}>Locked</Text>
588
+ </View>
589
+ )}
571
590
  </View>
572
591
 
573
592
  {/* Straighten Control */}
@@ -593,29 +612,31 @@ export function CropScreen({ item, onBack, onSave }: CropScreenProps) {
593
612
  </View>
594
613
 
595
614
  {/* 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>
615
+ {!isRatioLocked && (
616
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.ratioScroll} contentContainerStyle={styles.ratioContent}>
617
+ {ratios.map((r) => {
618
+ const isSelected = selectedRatio === r.label;
619
+ const boxRatio = r.ratio || 1;
620
+ // Limit box size for icon
621
+ const boxStyle = {
622
+ width: boxRatio > 1 ? 24 : 24 * boxRatio,
623
+ height: boxRatio > 1 ? 24 / boxRatio : 24,
624
+ borderWidth: 1.5,
625
+ borderColor: isSelected ? '#4A8CFF' : '#666',
626
+ borderRadius: 2,
627
+ };
628
+
629
+ return (
630
+ <Pressable key={r.label} style={styles.ratioItem} onPress={() => handleRatioSelect(r.label, r.ratio)}>
631
+ <View style={[styles.ratioIconBox, isSelected && styles.ratioIconBoxActive]}>
632
+ <View style={boxStyle} />
633
+ </View>
634
+ <Text style={[styles.ratioLabel, isSelected && styles.ratioLabelActive]}>{r.label}</Text>
635
+ </Pressable>
636
+ );
637
+ })}
638
+ </ScrollView>
639
+ )}
619
640
 
620
641
  {/* Footer Status with Save Button */}
621
642
  <View style={styles.footerStatus}>