@umituz/react-native-video-editor 1.1.53 → 1.1.55
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/package.json +1 -1
- package/src/infrastructure/services/layer-operations/layer-duplicate.service.ts +4 -2
- package/src/infrastructure/services/scene-operations.service.ts +5 -8
- package/src/infrastructure/utils/data-clone.utils.ts +141 -0
- package/src/infrastructure/utils/position-calculations.utils.ts +182 -0
- package/src/infrastructure/utils/srt.utils.ts +4 -20
- package/src/infrastructure/utils/time-calculations.utils.ts +107 -0
- package/src/infrastructure/utils/video-calculations.utils.ts +169 -0
- package/src/presentation/components/DraggableLayer.tsx +36 -16
- package/src/presentation/components/EditorPreviewArea.tsx +92 -57
- package/src/presentation/components/EditorTimeline.tsx +105 -47
- package/src/presentation/components/EditorToolPanel.tsx +141 -115
- package/src/presentation/hooks/useEditorHistory.ts +20 -9
- package/src/presentation/hooks/useEditorPlayback.ts +62 -25
- package/src/presentation/hooks/useExportForm.ts +10 -12
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Single Responsibility: Display editor tool buttons
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React from "react";
|
|
6
|
+
import React, { useMemo, useCallback } from "react";
|
|
7
7
|
import {
|
|
8
8
|
View,
|
|
9
9
|
ScrollView,
|
|
@@ -24,7 +24,70 @@ interface EditorToolPanelProps {
|
|
|
24
24
|
onSpeed?: () => void;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
interface ToolButtonProps {
|
|
28
|
+
icon: string;
|
|
29
|
+
label: string;
|
|
30
|
+
onPress: () => void;
|
|
31
|
+
backgroundColor: string;
|
|
32
|
+
textColor: string;
|
|
33
|
+
showBadge?: boolean;
|
|
34
|
+
badgeColor?: string;
|
|
35
|
+
badgeBorderColor?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Memoized ToolButton component to prevent unnecessary re-renders
|
|
40
|
+
*/
|
|
41
|
+
const ToolButton = React.memo<ToolButtonProps>(({
|
|
42
|
+
icon,
|
|
43
|
+
label,
|
|
44
|
+
onPress,
|
|
45
|
+
backgroundColor,
|
|
46
|
+
textColor,
|
|
47
|
+
showBadge,
|
|
48
|
+
badgeColor,
|
|
49
|
+
badgeBorderColor,
|
|
50
|
+
}) => {
|
|
51
|
+
const buttonStyle = useMemo(() => [
|
|
52
|
+
styles.toolButton,
|
|
53
|
+
{ backgroundColor }
|
|
54
|
+
], [backgroundColor]);
|
|
55
|
+
|
|
56
|
+
const textStyle = useMemo(() => ({
|
|
57
|
+
color: textColor,
|
|
58
|
+
marginTop: 4
|
|
59
|
+
}), [textColor]);
|
|
60
|
+
|
|
61
|
+
const badgeStyle = useMemo(() => [
|
|
62
|
+
styles.audioBadge,
|
|
63
|
+
{
|
|
64
|
+
backgroundColor: badgeColor,
|
|
65
|
+
borderColor: badgeBorderColor,
|
|
66
|
+
}
|
|
67
|
+
], [badgeColor, badgeBorderColor]);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<TouchableOpacity
|
|
71
|
+
style={buttonStyle}
|
|
72
|
+
onPress={onPress}
|
|
73
|
+
>
|
|
74
|
+
<AtomicIcon name={icon as any} size="md" color="primary" />
|
|
75
|
+
<AtomicText
|
|
76
|
+
type="labelSmall"
|
|
77
|
+
style={textStyle}
|
|
78
|
+
>
|
|
79
|
+
{label}
|
|
80
|
+
</AtomicText>
|
|
81
|
+
{showBadge && badgeColor && (
|
|
82
|
+
<View style={badgeStyle} />
|
|
83
|
+
)}
|
|
84
|
+
</TouchableOpacity>
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
ToolButton.displayName = 'ToolButton';
|
|
89
|
+
|
|
90
|
+
export const EditorToolPanel: React.FC<EditorToolPanelProps> = React.memo(({
|
|
28
91
|
onAddText,
|
|
29
92
|
onAddImage,
|
|
30
93
|
onAddShape,
|
|
@@ -36,136 +99,99 @@ export const EditorToolPanel: React.FC<EditorToolPanelProps> = ({
|
|
|
36
99
|
const { t } = useLocalization();
|
|
37
100
|
const tokens = useAppDesignTokens();
|
|
38
101
|
|
|
102
|
+
// Memoize styles
|
|
103
|
+
const containerStyle = useMemo(() => [
|
|
104
|
+
styles.toolPanel,
|
|
105
|
+
{ backgroundColor: tokens.colors.surface }
|
|
106
|
+
], [tokens.colors.surface]);
|
|
107
|
+
|
|
108
|
+
const titleStyle = useMemo(() => ({
|
|
109
|
+
color: tokens.colors.textPrimary,
|
|
110
|
+
fontWeight: "600" as const,
|
|
111
|
+
marginBottom: 12,
|
|
112
|
+
}), [tokens.colors.textPrimary]);
|
|
113
|
+
|
|
114
|
+
const backgroundColor = tokens.colors.backgroundPrimary;
|
|
115
|
+
const textColor = tokens.colors.textPrimary;
|
|
116
|
+
|
|
117
|
+
// Stable callbacks
|
|
118
|
+
const handleAddText = useCallback(() => onAddText(), [onAddText]);
|
|
119
|
+
const handleAddImage = useCallback(() => onAddImage(), [onAddImage]);
|
|
120
|
+
const handleAddShape = useCallback(() => onAddShape(), [onAddShape]);
|
|
121
|
+
const handleAudio = useCallback(() => onAudio(), [onAudio]);
|
|
122
|
+
const handleFilters = useCallback(() => onFilters?.(), [onFilters]);
|
|
123
|
+
const handleSpeed = useCallback(() => onSpeed?.(), [onSpeed]);
|
|
124
|
+
|
|
39
125
|
return (
|
|
40
|
-
<View
|
|
41
|
-
style={[styles.toolPanel, { backgroundColor: tokens.colors.surface }]}
|
|
42
|
-
>
|
|
126
|
+
<View style={containerStyle}>
|
|
43
127
|
<AtomicText
|
|
44
128
|
type="bodyMedium"
|
|
45
|
-
style={
|
|
46
|
-
color: tokens.colors.textPrimary,
|
|
47
|
-
fontWeight: "600",
|
|
48
|
-
marginBottom: 12,
|
|
49
|
-
}}
|
|
129
|
+
style={titleStyle}
|
|
50
130
|
>
|
|
51
131
|
{t("editor.tools.title")}
|
|
52
132
|
</AtomicText>
|
|
53
133
|
|
|
54
134
|
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
styles.toolButton,
|
|
90
|
-
{ backgroundColor: tokens.colors.backgroundPrimary },
|
|
91
|
-
]}
|
|
92
|
-
onPress={onAddShape}
|
|
93
|
-
>
|
|
94
|
-
<AtomicIcon name="square-outline" size="md" color="primary" />
|
|
95
|
-
<AtomicText
|
|
96
|
-
type="labelSmall"
|
|
97
|
-
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
98
|
-
>
|
|
99
|
-
{t("editor.tools.shape")}
|
|
100
|
-
</AtomicText>
|
|
101
|
-
</TouchableOpacity>
|
|
102
|
-
|
|
103
|
-
<TouchableOpacity
|
|
104
|
-
style={[
|
|
105
|
-
styles.toolButton,
|
|
106
|
-
{ backgroundColor: tokens.colors.backgroundPrimary },
|
|
107
|
-
]}
|
|
108
|
-
onPress={onAudio}
|
|
109
|
-
>
|
|
110
|
-
<AtomicIcon name="musical-notes-outline" size="md" color="primary" />
|
|
111
|
-
<AtomicText
|
|
112
|
-
type="labelSmall"
|
|
113
|
-
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
114
|
-
>
|
|
115
|
-
{t("editor.tools.audio")}
|
|
116
|
-
</AtomicText>
|
|
117
|
-
{hasAudio && (
|
|
118
|
-
<View
|
|
119
|
-
style={[
|
|
120
|
-
styles.audioBadge,
|
|
121
|
-
{
|
|
122
|
-
backgroundColor: tokens.colors.success,
|
|
123
|
-
borderColor: tokens.colors.surface,
|
|
124
|
-
},
|
|
125
|
-
]}
|
|
126
|
-
/>
|
|
127
|
-
)}
|
|
128
|
-
</TouchableOpacity>
|
|
135
|
+
<ToolButton
|
|
136
|
+
icon="text-outline"
|
|
137
|
+
label={t("editor.tools.text")}
|
|
138
|
+
onPress={handleAddText}
|
|
139
|
+
backgroundColor={backgroundColor}
|
|
140
|
+
textColor={textColor}
|
|
141
|
+
/>
|
|
142
|
+
|
|
143
|
+
<ToolButton
|
|
144
|
+
icon="image-outline"
|
|
145
|
+
label={t("editor.tools.image")}
|
|
146
|
+
onPress={handleAddImage}
|
|
147
|
+
backgroundColor={backgroundColor}
|
|
148
|
+
textColor={textColor}
|
|
149
|
+
/>
|
|
150
|
+
|
|
151
|
+
<ToolButton
|
|
152
|
+
icon="square-outline"
|
|
153
|
+
label={t("editor.tools.shape")}
|
|
154
|
+
onPress={handleAddShape}
|
|
155
|
+
backgroundColor={backgroundColor}
|
|
156
|
+
textColor={textColor}
|
|
157
|
+
/>
|
|
158
|
+
|
|
159
|
+
<ToolButton
|
|
160
|
+
icon="musical-notes-outline"
|
|
161
|
+
label={t("editor.tools.audio")}
|
|
162
|
+
onPress={handleAudio}
|
|
163
|
+
backgroundColor={backgroundColor}
|
|
164
|
+
textColor={textColor}
|
|
165
|
+
showBadge={hasAudio}
|
|
166
|
+
badgeColor={tokens.colors.success}
|
|
167
|
+
badgeBorderColor={tokens.colors.surface}
|
|
168
|
+
/>
|
|
129
169
|
|
|
130
170
|
{onFilters && (
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
<AtomicIcon name="sparkles" size="md" color="primary" />
|
|
139
|
-
<AtomicText
|
|
140
|
-
type="labelSmall"
|
|
141
|
-
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
142
|
-
>
|
|
143
|
-
{t("editor.tools.filters") || "Filters"}
|
|
144
|
-
</AtomicText>
|
|
145
|
-
</TouchableOpacity>
|
|
171
|
+
<ToolButton
|
|
172
|
+
icon="sparkles"
|
|
173
|
+
label={t("editor.tools.filters") || "Filters"}
|
|
174
|
+
onPress={handleFilters}
|
|
175
|
+
backgroundColor={backgroundColor}
|
|
176
|
+
textColor={textColor}
|
|
177
|
+
/>
|
|
146
178
|
)}
|
|
147
179
|
|
|
148
180
|
{onSpeed && (
|
|
149
|
-
<
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
<AtomicIcon name="flash" size="md" color="primary" />
|
|
157
|
-
<AtomicText
|
|
158
|
-
type="labelSmall"
|
|
159
|
-
style={{ color: tokens.colors.textPrimary, marginTop: 4 }}
|
|
160
|
-
>
|
|
161
|
-
{t("editor.tools.speed") || "Speed"}
|
|
162
|
-
</AtomicText>
|
|
163
|
-
</TouchableOpacity>
|
|
181
|
+
<ToolButton
|
|
182
|
+
icon="flash"
|
|
183
|
+
label={t("editor.tools.speed") || "Speed"}
|
|
184
|
+
onPress={handleSpeed}
|
|
185
|
+
backgroundColor={backgroundColor}
|
|
186
|
+
textColor={textColor}
|
|
187
|
+
/>
|
|
164
188
|
)}
|
|
165
189
|
</ScrollView>
|
|
166
190
|
</View>
|
|
167
191
|
);
|
|
168
|
-
};
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
EditorToolPanel.displayName = 'EditorToolPanel';
|
|
169
195
|
|
|
170
196
|
const styles = StyleSheet.create({
|
|
171
197
|
toolPanel: {
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* Single Responsibility: History operations for editor
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { useCallback, useState } from "react";
|
|
6
|
+
import { useCallback, useState, useRef } from "react";
|
|
7
7
|
import type { VideoProject } from "../../domain/entities/video-project.types";
|
|
8
|
+
import { cloneVideoProject } from "../../infrastructure/utils/data-clone.utils";
|
|
8
9
|
|
|
9
10
|
interface UseEditorHistoryParams {
|
|
10
11
|
project: VideoProject | undefined;
|
|
@@ -27,20 +28,30 @@ export function useEditorHistory({
|
|
|
27
28
|
}: UseEditorHistoryParams): UseEditorHistoryReturn {
|
|
28
29
|
const [history, setHistory] = useState<VideoProject[]>([]);
|
|
29
30
|
const [future, setFuture] = useState<VideoProject[]>([]);
|
|
31
|
+
const lastProjectRef = useRef<string | null>(null);
|
|
30
32
|
|
|
31
33
|
const updateWithHistory = useCallback(
|
|
32
34
|
(updates: Partial<VideoProject>, _action: string) => {
|
|
33
|
-
if (project)
|
|
34
|
-
// Deep clone the project to avoid reference issues
|
|
35
|
-
const clonedProject = JSON.parse(JSON.stringify(project)) as VideoProject;
|
|
36
|
-
setHistory((prev) => {
|
|
37
|
-
const next = [...prev, clonedProject];
|
|
38
|
-
return next.length > MAX_HISTORY_SIZE ? next.slice(-MAX_HISTORY_SIZE) : next;
|
|
39
|
-
});
|
|
40
|
-
setFuture([]);
|
|
35
|
+
if (!project) return;
|
|
41
36
|
|
|
37
|
+
// Prevent duplicate history entries for same project state
|
|
38
|
+
const projectHash = JSON.stringify({ id: project.id, updatedAt: project.updatedAt });
|
|
39
|
+
if (lastProjectRef.current === projectHash) {
|
|
42
40
|
onUpdateProject(updates);
|
|
41
|
+
return;
|
|
43
42
|
}
|
|
43
|
+
lastProjectRef.current = projectHash;
|
|
44
|
+
|
|
45
|
+
// Optimized deep clone
|
|
46
|
+
const clonedProject = cloneVideoProject(project);
|
|
47
|
+
|
|
48
|
+
setHistory((prev) => {
|
|
49
|
+
const next = [...prev, clonedProject];
|
|
50
|
+
return next.length > MAX_HISTORY_SIZE ? next.slice(-MAX_HISTORY_SIZE) : next;
|
|
51
|
+
});
|
|
52
|
+
setFuture([]);
|
|
53
|
+
|
|
54
|
+
onUpdateProject(updates);
|
|
44
55
|
},
|
|
45
56
|
[project, onUpdateProject],
|
|
46
57
|
);
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* useEditorPlayback Hook
|
|
3
3
|
* Single Responsibility: Playback control for editor
|
|
4
|
+
* Optimized for performance with stable animation loop
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import { useState, useEffect, useCallback } from "react";
|
|
7
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
7
8
|
import type { Scene } from "../../domain/entities/video-project.types";
|
|
9
|
+
import {
|
|
10
|
+
calculateDelta,
|
|
11
|
+
addDeltaTime,
|
|
12
|
+
isTimeAtEnd,
|
|
13
|
+
} from "../../infrastructure/utils/time-calculations.utils";
|
|
8
14
|
|
|
9
15
|
interface UseEditorPlaybackParams {
|
|
10
16
|
currentScene: Scene | undefined;
|
|
@@ -24,56 +30,87 @@ export function useEditorPlayback({
|
|
|
24
30
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
25
31
|
const [currentTime, setCurrentTime] = useState(0);
|
|
26
32
|
|
|
27
|
-
//
|
|
33
|
+
// Use refs to avoid re-creating animation loop on every render
|
|
34
|
+
const animationFrameRef = useRef<number | null>(null);
|
|
35
|
+
const lastTimestampRef = useRef<number>(0);
|
|
36
|
+
const isPlayingRef = useRef(isPlaying);
|
|
37
|
+
const currentSceneRef = useRef(currentScene);
|
|
38
|
+
const durationRef = useRef(0);
|
|
39
|
+
|
|
40
|
+
// Keep refs in sync
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
isPlayingRef.current = isPlaying;
|
|
43
|
+
}, [isPlaying]);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
currentSceneRef.current = currentScene;
|
|
47
|
+
if (currentScene) {
|
|
48
|
+
durationRef.current = currentScene.duration;
|
|
49
|
+
}
|
|
50
|
+
}, [currentScene]);
|
|
51
|
+
|
|
52
|
+
// Video playback animation loop - optimized with refs
|
|
28
53
|
useEffect(() => {
|
|
29
54
|
if (!currentScene) return;
|
|
30
|
-
let animationFrameId: number;
|
|
31
|
-
let lastTimestamp = 0;
|
|
32
55
|
|
|
33
56
|
const animate = (timestamp: number) => {
|
|
34
|
-
|
|
57
|
+
// Use refs for checks to avoid closure staleness
|
|
58
|
+
if (!isPlayingRef.current || !currentSceneRef.current) {
|
|
59
|
+
animationFrameRef.current = null;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
35
62
|
|
|
36
|
-
if (
|
|
37
|
-
|
|
63
|
+
if (lastTimestampRef.current === 0) {
|
|
64
|
+
lastTimestampRef.current = timestamp;
|
|
38
65
|
}
|
|
39
|
-
const deltaTime = timestamp - lastTimestamp;
|
|
40
|
-
lastTimestamp = timestamp;
|
|
41
66
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const sceneDuration = currentScene.duration;
|
|
67
|
+
const deltaTime = calculateDelta(timestamp, lastTimestampRef.current);
|
|
68
|
+
lastTimestampRef.current = timestamp;
|
|
45
69
|
|
|
46
|
-
|
|
47
|
-
|
|
70
|
+
setCurrentTime((prevTime) => {
|
|
71
|
+
const newTime = addDeltaTime(prevTime, deltaTime);
|
|
72
|
+
const sceneDuration = durationRef.current;
|
|
73
|
+
|
|
74
|
+
if (isTimeAtEnd(newTime, sceneDuration)) {
|
|
75
|
+
// Defer state update to avoid in-render setState
|
|
76
|
+
Promise.resolve().then(() => {
|
|
77
|
+
setIsPlaying(false);
|
|
78
|
+
});
|
|
48
79
|
return sceneDuration;
|
|
49
80
|
}
|
|
50
81
|
|
|
51
82
|
return newTime;
|
|
52
83
|
});
|
|
53
84
|
|
|
54
|
-
|
|
85
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
55
86
|
};
|
|
56
87
|
|
|
57
88
|
if (isPlaying) {
|
|
58
|
-
|
|
59
|
-
|
|
89
|
+
lastTimestampRef.current = 0;
|
|
90
|
+
animationFrameRef.current = requestAnimationFrame(animate);
|
|
60
91
|
}
|
|
61
92
|
|
|
62
93
|
return () => {
|
|
63
|
-
if (
|
|
64
|
-
cancelAnimationFrame(
|
|
94
|
+
if (animationFrameRef.current !== null) {
|
|
95
|
+
cancelAnimationFrame(animationFrameRef.current);
|
|
96
|
+
animationFrameRef.current = null;
|
|
65
97
|
}
|
|
66
98
|
};
|
|
67
|
-
}, [isPlaying, currentScene
|
|
99
|
+
}, [isPlaying, currentScene]); // Only re-create loop when these change
|
|
68
100
|
|
|
69
101
|
const playPause = useCallback(() => {
|
|
70
102
|
if (!currentScene) return;
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
setCurrentTime((prevTime) => {
|
|
105
|
+
// Reset to 0 if at end
|
|
106
|
+
if (prevTime >= currentScene.duration) {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
return prevTime;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
setIsPlaying((prev) => !prev);
|
|
113
|
+
}, [currentScene]);
|
|
77
114
|
|
|
78
115
|
const reset = useCallback(() => {
|
|
79
116
|
setCurrentTime(0);
|
|
@@ -11,10 +11,9 @@ import type {
|
|
|
11
11
|
Format,
|
|
12
12
|
} from "../../infrastructure/constants/export.constants";
|
|
13
13
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "../../infrastructure/constants/export.constants";
|
|
14
|
+
calculateEstimatedFileSize,
|
|
15
|
+
calculateVideoProjectDuration,
|
|
16
|
+
} from "../../infrastructure/utils/video-calculations.utils";
|
|
18
17
|
|
|
19
18
|
interface ExportFormState {
|
|
20
19
|
resolution: Resolution;
|
|
@@ -46,16 +45,15 @@ export function useExportForm(project: VideoProject): UseExportFormReturn {
|
|
|
46
45
|
});
|
|
47
46
|
|
|
48
47
|
const projectDuration = useMemo(() => {
|
|
49
|
-
return (
|
|
50
|
-
|
|
51
|
-
);
|
|
52
|
-
}, [project.scenes]);
|
|
48
|
+
return calculateVideoProjectDuration(project);
|
|
49
|
+
}, [project]);
|
|
53
50
|
|
|
54
51
|
const estimatedSize = useMemo(() => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
52
|
+
return calculateEstimatedFileSize(
|
|
53
|
+
projectDuration,
|
|
54
|
+
formState.resolution,
|
|
55
|
+
formState.quality,
|
|
56
|
+
).toFixed(1);
|
|
59
57
|
}, [projectDuration, formState.resolution, formState.quality]);
|
|
60
58
|
|
|
61
59
|
const setResolution = useCallback((resolution: Resolution) => {
|