@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.
- package/README.md +17 -3
- package/android/src/main/java/com/technotoil/image_videoeditor/FrameGrabberModule.kt +2 -6
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaEditorModule.kt +75 -35
- package/android/src/main/java/com/technotoil/image_videoeditor/MediaLibraryModule.kt +51 -35
- package/android/src/main/java/com/technotoil/image_videoeditor/RNVideoPreviewManager.kt +98 -117
- package/ios/RNMediaEditor.m +38 -7
- package/ios/RNMediaLibrary.m +19 -15
- package/ios/RNVideoPreviewManager.m +2 -0
- package/lib/commonjs/assets/frames/film_vintage.png +0 -0
- package/lib/commonjs/assets/frames/floral_gold.png +0 -0
- package/lib/commonjs/assets/frames/minimal_double.png +0 -0
- package/lib/commonjs/assets/frames/polaroid_white.png +0 -0
- package/lib/commonjs/assets/frames/watercolor_floral.png +0 -0
- package/lib/commonjs/components/VideoEditor.js +235 -0
- package/lib/commonjs/components/VideoEditor.js.map +1 -0
- package/lib/commonjs/index.js +14 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/native/CameraView.js +109 -0
- package/lib/commonjs/native/CameraView.js.map +1 -0
- package/lib/commonjs/native/FrameGrabber.js +17 -0
- package/lib/commonjs/native/FrameGrabber.js.map +1 -0
- package/lib/commonjs/native/MediaEditor.js +24 -0
- package/lib/commonjs/native/MediaEditor.js.map +1 -0
- package/lib/commonjs/native/MediaLibrary.js +45 -0
- package/lib/commonjs/native/MediaLibrary.js.map +1 -0
- package/lib/commonjs/native/MediaPicker.js +17 -0
- package/lib/commonjs/native/MediaPicker.js.map +1 -0
- package/lib/commonjs/native/MediaPlayer.js +17 -0
- package/lib/commonjs/native/MediaPlayer.js.map +1 -0
- package/lib/commonjs/native/VideoPreview.js +17 -0
- package/lib/commonjs/native/VideoPreview.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/screens/CropScreen.js +1233 -0
- package/lib/commonjs/screens/CropScreen.js.map +1 -0
- package/lib/commonjs/screens/EditorScreen.js +6043 -0
- package/lib/commonjs/screens/EditorScreen.js.map +1 -0
- package/lib/commonjs/screens/ExportScreen.js +294 -0
- package/lib/commonjs/screens/ExportScreen.js.map +1 -0
- package/lib/commonjs/screens/GalleryScreen.js +510 -0
- package/lib/commonjs/screens/GalleryScreen.js.map +1 -0
- package/lib/commonjs/screens/PickScreen.js +1353 -0
- package/lib/commonjs/screens/PickScreen.js.map +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/components/VideoEditor.js +104 -31
- package/lib/module/components/VideoEditor.js.map +1 -1
- package/lib/module/screens/CropScreen.js +26 -9
- package/lib/module/screens/CropScreen.js.map +1 -1
- package/lib/module/screens/EditorScreen.js +371 -86
- package/lib/module/screens/EditorScreen.js.map +1 -1
- package/lib/module/screens/PickScreen.js +245 -93
- package/lib/module/screens/PickScreen.js.map +1 -1
- package/lib/typescript/src/components/VideoEditor.d.ts +18 -2
- package/lib/typescript/src/screens/CropScreen.d.ts +3 -1
- package/lib/typescript/src/screens/EditorScreen.d.ts +2 -1
- package/lib/typescript/src/screens/PickScreen.d.ts +6 -1
- package/lib/typescript/src/types.d.ts +1 -0
- package/package.json +17 -8
- package/src/components/VideoEditor.tsx +82 -11
- package/src/screens/CropScreen.tsx +54 -33
- package/src/screens/EditorScreen.tsx +366 -106
- package/src/screens/PickScreen.tsx +231 -76
- 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;
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@technotoil/image-video-editor",
|
|
3
|
-
"version": "0.1.
|
|
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": "
|
|
6
|
-
"
|
|
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
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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>(
|
|
107
|
-
const [isFixedRatio, setIsFixedRatio] = useState(
|
|
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
|
-
|
|
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(
|
|
413
|
-
setIsFixedRatio(
|
|
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
|
-
|
|
569
|
-
<
|
|
570
|
-
|
|
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
|
-
|
|
597
|
-
{
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
<
|
|
612
|
-
<View style={
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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}>
|