@igorpache/expo-video-editor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierrc +8 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/android/build.gradle +27 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
- package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
- package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
- package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
- package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
- package/eslint.config.cjs +16 -0
- package/expo-module.config.json +9 -0
- package/ios/AudioMixBuilder.swift +66 -0
- package/ios/ColorMatrixFilter.swift +24 -0
- package/ios/ExpoVideoEditor.podspec +23 -0
- package/ios/ExpoVideoEditorModule.swift +55 -0
- package/ios/ExpoVideoEditorView.swift +144 -0
- package/ios/ExportRunner.swift +135 -0
- package/ios/FrameComposer.swift +94 -0
- package/ios/ImageVideoWriter.swift +144 -0
- package/ios/Records.swift +41 -0
- package/package.json +36 -0
- package/src/components/EditorRoot.tsx +180 -0
- package/src/components/ExportOverlay.tsx +44 -0
- package/src/components/PreviewSurface.tsx +49 -0
- package/src/components/VideoEditor.tsx +51 -0
- package/src/components/audio/AudioPanel.tsx +69 -0
- package/src/components/audio/AudioScrubber.tsx +177 -0
- package/src/components/audio/Slider.tsx +60 -0
- package/src/components/filters/FilterCarousel.tsx +44 -0
- package/src/components/giphy/GiphyGrid.tsx +48 -0
- package/src/components/giphy/GiphyPicker.tsx +112 -0
- package/src/components/overlays/GifOverlayView.tsx +53 -0
- package/src/components/overlays/OverlayLayer.tsx +24 -0
- package/src/components/overlays/TextEditorSheet.tsx +135 -0
- package/src/components/overlays/TextOverlayView.tsx +61 -0
- package/src/components/timeline/ThumbnailStrip.tsx +63 -0
- package/src/components/timeline/TrimTimeline.tsx +152 -0
- package/src/errors.ts +33 -0
- package/src/filters/presets.ts +84 -0
- package/src/hooks/useAudioPreview.ts +94 -0
- package/src/hooks/useExport.ts +41 -0
- package/src/hooks/useOverlayGestures.ts +90 -0
- package/src/index.ts +29 -0
- package/src/native/EditorVideoView.tsx +27 -0
- package/src/native/VideoEditorModule.ts +51 -0
- package/src/overlays/captureRegistry.ts +12 -0
- package/src/services/demoCatalog.ts +30 -0
- package/src/services/exportService.ts +199 -0
- package/src/services/feedfmCatalog.ts +44 -0
- package/src/services/giphyClient.ts +69 -0
- package/src/services/mediaCache.ts +18 -0
- package/src/services/musicCatalog.ts +23 -0
- package/src/state/store.ts +200 -0
- package/src/theme.ts +26 -0
- package/src/types.ts +104 -0
- package/tsconfig.json +28 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { StyleSheet, Text, View } from 'react-native';
|
|
2
|
+
import { GestureDetector } from 'react-native-gesture-handler';
|
|
3
|
+
import Animated from 'react-native-reanimated';
|
|
4
|
+
|
|
5
|
+
import { useOverlayGestures } from '../../hooks/useOverlayGestures';
|
|
6
|
+
import { registerOverlayRef } from '../../overlays/captureRegistry';
|
|
7
|
+
import { useEditorStore } from '../../state/store';
|
|
8
|
+
import type { TextOverlay } from '../../types';
|
|
9
|
+
|
|
10
|
+
export function TextOverlayView({ overlay }: { overlay: TextOverlay }) {
|
|
11
|
+
const { gesture, animatedStyle } = useOverlayGestures(overlay);
|
|
12
|
+
const updateOverlay = useEditorStore((s) => s.updateOverlay);
|
|
13
|
+
const setActivePanel = useEditorStore((s) => s.setActivePanel);
|
|
14
|
+
const selectOverlay = useEditorStore((s) => s.selectOverlay);
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<GestureDetector gesture={gesture}>
|
|
18
|
+
<Animated.View style={[styles.wrapper, animatedStyle]}>
|
|
19
|
+
<View
|
|
20
|
+
ref={(v) => registerOverlayRef(overlay.id, v)}
|
|
21
|
+
collapsable={false}
|
|
22
|
+
onLayout={(e) => {
|
|
23
|
+
const { width, height } = e.nativeEvent.layout;
|
|
24
|
+
if (
|
|
25
|
+
Math.abs((overlay.baseWidth ?? 0) - width) > 0.5 ||
|
|
26
|
+
Math.abs((overlay.baseHeight ?? 0) - height) > 0.5
|
|
27
|
+
) {
|
|
28
|
+
updateOverlay(overlay.id, { baseWidth: width, baseHeight: height });
|
|
29
|
+
}
|
|
30
|
+
}}
|
|
31
|
+
onTouchEnd={() => {
|
|
32
|
+
selectOverlay(overlay.id);
|
|
33
|
+
setActivePanel('text');
|
|
34
|
+
}}
|
|
35
|
+
style={[
|
|
36
|
+
styles.content,
|
|
37
|
+
overlay.backgroundColor ? { backgroundColor: overlay.backgroundColor } : null,
|
|
38
|
+
]}>
|
|
39
|
+
<Text
|
|
40
|
+
style={{
|
|
41
|
+
color: overlay.color,
|
|
42
|
+
fontSize: overlay.fontSize,
|
|
43
|
+
fontFamily: overlay.fontFamily,
|
|
44
|
+
textAlign: overlay.align,
|
|
45
|
+
}}>
|
|
46
|
+
{overlay.text || ' '}
|
|
47
|
+
</Text>
|
|
48
|
+
</View>
|
|
49
|
+
</Animated.View>
|
|
50
|
+
</GestureDetector>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const styles = StyleSheet.create({
|
|
55
|
+
wrapper: { position: 'absolute', top: 0, left: 0 },
|
|
56
|
+
content: {
|
|
57
|
+
paddingHorizontal: 8,
|
|
58
|
+
paddingVertical: 4,
|
|
59
|
+
borderRadius: 6,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import * as VideoThumbnails from 'expo-video-thumbnails';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Image, StyleSheet, View } from 'react-native';
|
|
4
|
+
|
|
5
|
+
type Props = { uri: string; duration: number; count?: number };
|
|
6
|
+
|
|
7
|
+
export function ThumbnailStrip({ uri, duration, count = 12 }: Props) {
|
|
8
|
+
const [thumbs, setThumbs] = useState<string[]>([]);
|
|
9
|
+
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
if (!uri || duration <= 0) {
|
|
13
|
+
setThumbs([]);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
(async () => {
|
|
17
|
+
const out: string[] = [];
|
|
18
|
+
for (let i = 0; i < count; i++) {
|
|
19
|
+
const t = (duration * (i + 0.5)) / count;
|
|
20
|
+
try {
|
|
21
|
+
const { uri: thumb } = await VideoThumbnails.getThumbnailAsync(uri, {
|
|
22
|
+
time: Math.round(t * 1000),
|
|
23
|
+
quality: 0.4,
|
|
24
|
+
});
|
|
25
|
+
if (cancelled) return;
|
|
26
|
+
out.push(thumb);
|
|
27
|
+
setThumbs([...out]);
|
|
28
|
+
} catch {
|
|
29
|
+
if (cancelled) return;
|
|
30
|
+
out.push('');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
return () => {
|
|
35
|
+
cancelled = true;
|
|
36
|
+
};
|
|
37
|
+
}, [uri, duration, count]);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<View style={styles.row} pointerEvents="none">
|
|
41
|
+
{Array.from({ length: count }).map((_, i) => (
|
|
42
|
+
<View key={i} style={styles.cell}>
|
|
43
|
+
{thumbs[i] ? <Image source={{ uri: thumbs[i] }} style={styles.img} /> : null}
|
|
44
|
+
</View>
|
|
45
|
+
))}
|
|
46
|
+
</View>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const styles = StyleSheet.create({
|
|
51
|
+
row: {
|
|
52
|
+
position: 'absolute',
|
|
53
|
+
top: 0,
|
|
54
|
+
left: 0,
|
|
55
|
+
right: 0,
|
|
56
|
+
bottom: 0,
|
|
57
|
+
flexDirection: 'row',
|
|
58
|
+
overflow: 'hidden',
|
|
59
|
+
borderRadius: 8,
|
|
60
|
+
},
|
|
61
|
+
cell: { flex: 1, backgroundColor: '#000' },
|
|
62
|
+
img: { width: '100%', height: '100%' },
|
|
63
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
|
4
|
+
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
|
|
5
|
+
|
|
6
|
+
import { ThumbnailStrip } from './ThumbnailStrip';
|
|
7
|
+
import { useEditorStore } from '../../state/store';
|
|
8
|
+
import { useTheme } from '../../theme';
|
|
9
|
+
|
|
10
|
+
const HANDLE_W = 22;
|
|
11
|
+
const TRACK_H = 60;
|
|
12
|
+
const MIN_GAP_S = 0.5;
|
|
13
|
+
|
|
14
|
+
export function TrimTimeline() {
|
|
15
|
+
const uri = useEditorStore((s) => s.source.uri);
|
|
16
|
+
const duration = useEditorStore((s) => s.meta?.duration ?? 0);
|
|
17
|
+
const trimStart = useEditorStore((s) => s.trim.start);
|
|
18
|
+
const trimEnd = useEditorStore((s) => s.trim.end);
|
|
19
|
+
const setTrim = useEditorStore((s) => s.setTrim);
|
|
20
|
+
const requestSeek = useEditorStore((s) => s.requestSeek);
|
|
21
|
+
const theme = useTheme();
|
|
22
|
+
|
|
23
|
+
const [w, setW] = useState(0);
|
|
24
|
+
const startX = useSharedValue(0);
|
|
25
|
+
const endX = useSharedValue(0);
|
|
26
|
+
const baseStart = useSharedValue(0);
|
|
27
|
+
const baseEnd = useSharedValue(0);
|
|
28
|
+
const draggingRef = useRef(false);
|
|
29
|
+
|
|
30
|
+
const pxPerSec = duration > 0 && w > 0 ? w / duration : 0;
|
|
31
|
+
const minGapPx = MIN_GAP_S * pxPerSec;
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (draggingRef.current || pxPerSec === 0) return;
|
|
35
|
+
startX.value = trimStart * pxPerSec;
|
|
36
|
+
endX.value = trimEnd * pxPerSec;
|
|
37
|
+
}, [trimStart, trimEnd, pxPerSec, startX, endX]);
|
|
38
|
+
|
|
39
|
+
const setDragging = (v: boolean) => {
|
|
40
|
+
draggingRef.current = v;
|
|
41
|
+
};
|
|
42
|
+
const commit = () => {
|
|
43
|
+
if (pxPerSec > 0) setTrim(startX.value / pxPerSec, endX.value / pxPerSec);
|
|
44
|
+
};
|
|
45
|
+
const seekToPx = (px: number) => {
|
|
46
|
+
if (pxPerSec > 0) requestSeek(px / pxPerSec);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const startPan = Gesture.Pan()
|
|
50
|
+
.onStart(() => {
|
|
51
|
+
baseStart.value = startX.value;
|
|
52
|
+
runOnJS(setDragging)(true);
|
|
53
|
+
})
|
|
54
|
+
.onUpdate((e) => {
|
|
55
|
+
let x = baseStart.value + e.translationX;
|
|
56
|
+
x = Math.max(0, Math.min(x, endX.value - minGapPx));
|
|
57
|
+
startX.value = x;
|
|
58
|
+
runOnJS(seekToPx)(x);
|
|
59
|
+
})
|
|
60
|
+
.onEnd(() => {
|
|
61
|
+
runOnJS(commit)();
|
|
62
|
+
runOnJS(setDragging)(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const endPan = Gesture.Pan()
|
|
66
|
+
.onStart(() => {
|
|
67
|
+
baseEnd.value = endX.value;
|
|
68
|
+
runOnJS(setDragging)(true);
|
|
69
|
+
})
|
|
70
|
+
.onUpdate((e) => {
|
|
71
|
+
let x = baseEnd.value + e.translationX;
|
|
72
|
+
x = Math.min(w, Math.max(x, startX.value + minGapPx));
|
|
73
|
+
endX.value = x;
|
|
74
|
+
runOnJS(seekToPx)(x);
|
|
75
|
+
})
|
|
76
|
+
.onEnd(() => {
|
|
77
|
+
runOnJS(commit)();
|
|
78
|
+
runOnJS(setDragging)(false);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const selStyle = useAnimatedStyle(() => ({
|
|
82
|
+
left: startX.value,
|
|
83
|
+
width: Math.max(0, endX.value - startX.value),
|
|
84
|
+
}));
|
|
85
|
+
const leftHandleStyle = useAnimatedStyle(() => ({ left: startX.value - HANDLE_W }));
|
|
86
|
+
const rightHandleStyle = useAnimatedStyle(() => ({ left: endX.value }));
|
|
87
|
+
const leftDimStyle = useAnimatedStyle(() => ({ left: 0, width: startX.value }));
|
|
88
|
+
const rightDimStyle = useAnimatedStyle(() => ({
|
|
89
|
+
left: endX.value,
|
|
90
|
+
width: Math.max(0, w - endX.value),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<View
|
|
95
|
+
style={[styles.track, { height: TRACK_H }]}
|
|
96
|
+
onLayout={(e) => setW(e.nativeEvent.layout.width)}>
|
|
97
|
+
<ThumbnailStrip uri={uri} duration={duration} />
|
|
98
|
+
|
|
99
|
+
<Animated.View style={[styles.dim, leftDimStyle]} pointerEvents="none" />
|
|
100
|
+
<Animated.View style={[styles.dim, rightDimStyle]} pointerEvents="none" />
|
|
101
|
+
|
|
102
|
+
<Animated.View
|
|
103
|
+
style={[styles.selection, { borderColor: theme.accent }, selStyle]}
|
|
104
|
+
pointerEvents="none"
|
|
105
|
+
/>
|
|
106
|
+
|
|
107
|
+
<Playhead duration={duration} width={w} />
|
|
108
|
+
|
|
109
|
+
<GestureDetector gesture={startPan}>
|
|
110
|
+
<Animated.View style={[styles.handle, { backgroundColor: theme.accent }, leftHandleStyle]}>
|
|
111
|
+
<View style={styles.grip} />
|
|
112
|
+
</Animated.View>
|
|
113
|
+
</GestureDetector>
|
|
114
|
+
<GestureDetector gesture={endPan}>
|
|
115
|
+
<Animated.View style={[styles.handle, { backgroundColor: theme.accent }, rightHandleStyle]}>
|
|
116
|
+
<View style={styles.grip} />
|
|
117
|
+
</Animated.View>
|
|
118
|
+
</GestureDetector>
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function Playhead({ duration, width }: { duration: number; width: number }) {
|
|
124
|
+
const playhead = useEditorStore((s) => s.playhead);
|
|
125
|
+
if (duration <= 0 || width <= 0) return null;
|
|
126
|
+
const left = Math.max(0, Math.min(width, (playhead / duration) * width));
|
|
127
|
+
return <View style={[styles.playhead, { left }]} pointerEvents="none" />;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const styles = StyleSheet.create({
|
|
131
|
+
track: { width: '100%', justifyContent: 'center' },
|
|
132
|
+
dim: {
|
|
133
|
+
position: 'absolute',
|
|
134
|
+
top: 0,
|
|
135
|
+
bottom: 0,
|
|
136
|
+
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
137
|
+
borderRadius: 8,
|
|
138
|
+
},
|
|
139
|
+
selection: { position: 'absolute', top: 0, bottom: 0, borderWidth: 2, borderRadius: 8 },
|
|
140
|
+
handle: {
|
|
141
|
+
position: 'absolute',
|
|
142
|
+
top: 0,
|
|
143
|
+
bottom: 0,
|
|
144
|
+
width: HANDLE_W,
|
|
145
|
+
alignItems: 'center',
|
|
146
|
+
justifyContent: 'center',
|
|
147
|
+
borderTopLeftRadius: 8,
|
|
148
|
+
borderBottomLeftRadius: 8,
|
|
149
|
+
},
|
|
150
|
+
grip: { width: 3, height: 20, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.9)' },
|
|
151
|
+
playhead: { position: 'absolute', top: -2, bottom: -2, width: 2, backgroundColor: '#fff' },
|
|
152
|
+
});
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type EditorErrorCode =
|
|
2
|
+
'EXPORT_FAILED' | 'CANCELLED' | 'SOURCE_LOAD' | 'AUDIO_LOAD' | 'GIPHY_NETWORK' | 'UNSUPPORTED';
|
|
3
|
+
|
|
4
|
+
export class EditorError extends Error {
|
|
5
|
+
readonly code: EditorErrorCode;
|
|
6
|
+
readonly cause?: unknown;
|
|
7
|
+
|
|
8
|
+
constructor(code: EditorErrorCode, message: string, cause?: unknown) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = 'EditorError';
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.cause = cause;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function toEditorError(
|
|
17
|
+
e: unknown,
|
|
18
|
+
fallback: EditorErrorCode = 'EXPORT_FAILED'
|
|
19
|
+
): EditorError {
|
|
20
|
+
if (e instanceof EditorError) return e;
|
|
21
|
+
const code = (e as { code?: string })?.code;
|
|
22
|
+
const message = (e as { message?: string })?.message ?? String(e);
|
|
23
|
+
const known: EditorErrorCode[] = [
|
|
24
|
+
'EXPORT_FAILED',
|
|
25
|
+
'CANCELLED',
|
|
26
|
+
'SOURCE_LOAD',
|
|
27
|
+
'AUDIO_LOAD',
|
|
28
|
+
'GIPHY_NETWORK',
|
|
29
|
+
'UNSUPPORTED',
|
|
30
|
+
];
|
|
31
|
+
const matched = known.find((k) => k === code) ?? fallback;
|
|
32
|
+
return new EditorError(matched, message, e);
|
|
33
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ColorMatrix } from '../types';
|
|
2
|
+
|
|
3
|
+
export const IDENTITY_MATRIX: ColorMatrix = [
|
|
4
|
+
1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0,
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export type FilterPreset = { key: string; label: string; matrix: ColorMatrix };
|
|
8
|
+
|
|
9
|
+
export const FILTER_PRESETS: FilterPreset[] = [
|
|
10
|
+
{ key: 'normal', label: 'Normal', matrix: IDENTITY_MATRIX },
|
|
11
|
+
{
|
|
12
|
+
key: 'bw',
|
|
13
|
+
label: 'B&W',
|
|
14
|
+
matrix: [
|
|
15
|
+
0.299, 0.587, 0.114, 0, 0, 0.299, 0.587, 0.114, 0, 0, 0.299, 0.587, 0.114, 0, 0, 0, 0, 0, 1,
|
|
16
|
+
0,
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
key: 'vintage',
|
|
21
|
+
label: 'Vintage',
|
|
22
|
+
matrix: [
|
|
23
|
+
0.393, 0.769, 0.189, 0, 0, 0.349, 0.686, 0.168, 0, 0, 0.272, 0.534, 0.131, 0, 0, 0, 0, 0, 1,
|
|
24
|
+
0,
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
key: 'warm',
|
|
29
|
+
label: 'Warm',
|
|
30
|
+
matrix: [1.1, 0, 0, 0, 0.02, 0, 1.05, 0, 0, 0, 0, 0, 0.9, 0, 0, 0, 0, 0, 1, 0],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: 'cool',
|
|
34
|
+
label: 'Cool',
|
|
35
|
+
matrix: [0.9, 0, 0, 0, 0, 0, 1.0, 0, 0, 0, 0, 0, 1.1, 0, 0.02, 0, 0, 0, 1, 0],
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: 'retro',
|
|
39
|
+
label: 'Retro',
|
|
40
|
+
matrix: [0.9, 0.05, 0, 0, 0.07, 0, 0.85, 0.05, 0, 0.07, 0.05, 0, 0.8, 0, 0.09, 0, 0, 0, 1, 0],
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: 'neon',
|
|
44
|
+
label: 'Neon',
|
|
45
|
+
matrix: [
|
|
46
|
+
1.3505, -0.2935, -0.057, 0, 0, -0.1495, 1.2065, -0.057, 0, 0, -0.1495, -0.2935, 1.443, 0, 0,
|
|
47
|
+
0, 0, 0, 1, 0,
|
|
48
|
+
],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: 'sunset',
|
|
52
|
+
label: 'Sunset',
|
|
53
|
+
matrix: [1.15, 0.05, 0, 0, 0.03, 0.02, 1.0, 0, 0, 0.01, 0, 0, 0.85, 0, 0, 0, 0, 0, 1, 0],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
key: 'film',
|
|
57
|
+
label: 'Film',
|
|
58
|
+
matrix: [
|
|
59
|
+
1.0, 0.03, 0.02, 0, 0.02, 0.02, 1.0, 0.02, 0, 0.02, 0.02, 0.05, 0.95, 0, 0.02, 0, 0, 0, 1, 0,
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: 'fade',
|
|
64
|
+
label: 'Fade',
|
|
65
|
+
matrix: [0.8, 0, 0, 0, 0.1, 0, 0.8, 0, 0, 0.1, 0, 0, 0.8, 0, 0.12, 0, 0, 0, 1, 0],
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
key: 'norway',
|
|
69
|
+
label: 'Norway',
|
|
70
|
+
matrix: [1.05, 0, 0, 0, 0, 0, 1.0, 0.03, 0, 0, 0, 0.05, 1.05, 0, 0.02, 0, 0, 0, 1, 0],
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const BY_KEY: Record<string, FilterPreset> = Object.fromEntries(
|
|
75
|
+
FILTER_PRESETS.map((p) => [p.key, p])
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
export function getFilterMatrix(key: string): ColorMatrix {
|
|
79
|
+
return (BY_KEY[key] ?? BY_KEY.normal).matrix;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isIdentityFilter(key: string): boolean {
|
|
83
|
+
return key === 'normal';
|
|
84
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { setAudioModeAsync, useAudioPlayer } from 'expo-audio';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
|
|
4
|
+
import { useEditorStore } from '../state/store';
|
|
5
|
+
|
|
6
|
+
export function useAudioPreview() {
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
setAudioModeAsync({ playsInSilentMode: true }).catch(() => {});
|
|
9
|
+
}, []);
|
|
10
|
+
|
|
11
|
+
const audioUri = useEditorStore((s) => s.audio?.uri ?? null);
|
|
12
|
+
const volume = useEditorStore((s) => s.audio?.volume ?? 1);
|
|
13
|
+
const loop = useEditorStore((s) => s.audio?.loop ?? false);
|
|
14
|
+
const audioTrimStart = useEditorStore((s) => s.audio?.trimStart ?? 0);
|
|
15
|
+
const audioTrimEnd = useEditorStore((s) => s.audio?.trimEnd ?? 0);
|
|
16
|
+
const videoTrimStart = useEditorStore((s) => s.trim.start);
|
|
17
|
+
const paused = useEditorStore((s) => s.paused);
|
|
18
|
+
const playhead = useEditorStore((s) => s.playhead);
|
|
19
|
+
const seekNonce = useEditorStore((s) => s.seekNonce);
|
|
20
|
+
const activePanel = useEditorStore((s) => s.activePanel);
|
|
21
|
+
|
|
22
|
+
const player = useAudioPlayer(audioUri ? { uri: audioUri } : null);
|
|
23
|
+
const active = !!audioUri && activePanel !== 'audio';
|
|
24
|
+
|
|
25
|
+
const activeRef = useRef(active);
|
|
26
|
+
const pausedRef = useRef(paused);
|
|
27
|
+
const loopRef = useRef(loop);
|
|
28
|
+
const trimStartRef = useRef(audioTrimStart);
|
|
29
|
+
const trimEndRef = useRef(audioTrimEnd);
|
|
30
|
+
activeRef.current = active;
|
|
31
|
+
pausedRef.current = paused;
|
|
32
|
+
loopRef.current = loop;
|
|
33
|
+
trimStartRef.current = audioTrimStart;
|
|
34
|
+
trimEndRef.current = audioTrimEnd;
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
try {
|
|
38
|
+
player.volume = volume;
|
|
39
|
+
} catch (e) {
|
|
40
|
+
console.warn('[useAudioPreview] set volume falhou', e);
|
|
41
|
+
}
|
|
42
|
+
}, [player, volume]);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!active) return;
|
|
46
|
+
try {
|
|
47
|
+
player.seekTo(audioTrimStart + Math.max(0, playhead - videoTrimStart)).catch((e) =>
|
|
48
|
+
console.warn('[useAudioPreview] seekTo do sync falhou', e)
|
|
49
|
+
);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.warn('[useAudioPreview] seekTo falhou', e);
|
|
52
|
+
}
|
|
53
|
+
}, [seekNonce]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let started = false;
|
|
57
|
+
let lastLoopAt = 0;
|
|
58
|
+
const id = setInterval(() => {
|
|
59
|
+
try {
|
|
60
|
+
if (!activeRef.current) {
|
|
61
|
+
started = false;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!started) {
|
|
65
|
+
if (player.duration > 0) {
|
|
66
|
+
started = true;
|
|
67
|
+
if (!pausedRef.current) player.play();
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (pausedRef.current) {
|
|
72
|
+
if (player.playing) player.pause();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!player.playing) player.play();
|
|
76
|
+
|
|
77
|
+
const end = trimEndRef.current;
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (loopRef.current && end > 0 && player.currentTime >= end && now - lastLoopAt > 300) {
|
|
80
|
+
lastLoopAt = now;
|
|
81
|
+
player.seekTo(trimStartRef.current).catch(() => {});
|
|
82
|
+
player.play();
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (String(e).includes('already released')) {
|
|
86
|
+
clearInterval(id);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.warn('[useAudioPreview] loop de playback falhou', e);
|
|
90
|
+
}
|
|
91
|
+
}, 150);
|
|
92
|
+
return () => clearInterval(id);
|
|
93
|
+
}, [player]);
|
|
94
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
|
|
3
|
+
import { cancelExport, exportVideo } from '../services/exportService';
|
|
4
|
+
import { useEditorStore } from '../state/store';
|
|
5
|
+
import type { ExportOptions, ExportResult } from '../types';
|
|
6
|
+
|
|
7
|
+
export type ExportCallbacks = {
|
|
8
|
+
onExportStart?: () => void;
|
|
9
|
+
onExportProgress?: (progress: number) => void;
|
|
10
|
+
onExportComplete?: (result: ExportResult) => void;
|
|
11
|
+
onExportError?: (error: unknown) => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useExport(options: ExportOptions = {}, callbacks: ExportCallbacks = {}) {
|
|
15
|
+
const setExporting = useEditorStore((s) => s.setExporting);
|
|
16
|
+
const setExportProgress = useEditorStore((s) => s.setExportProgress);
|
|
17
|
+
const getEditorState = useEditorStore((s) => s.getEditorState);
|
|
18
|
+
|
|
19
|
+
const run = useCallback(async (): Promise<ExportResult | undefined> => {
|
|
20
|
+
setExporting(true);
|
|
21
|
+
setExportProgress(0);
|
|
22
|
+
callbacks.onExportStart?.();
|
|
23
|
+
try {
|
|
24
|
+
const result = await exportVideo(getEditorState(), options, (p) => {
|
|
25
|
+
setExportProgress(p);
|
|
26
|
+
callbacks.onExportProgress?.(p);
|
|
27
|
+
});
|
|
28
|
+
callbacks.onExportComplete?.(result);
|
|
29
|
+
return result;
|
|
30
|
+
} catch (e) {
|
|
31
|
+
callbacks.onExportError?.(e);
|
|
32
|
+
return undefined;
|
|
33
|
+
} finally {
|
|
34
|
+
setExporting(false);
|
|
35
|
+
}
|
|
36
|
+
}, [options, callbacks, getEditorState, setExporting, setExportProgress]);
|
|
37
|
+
|
|
38
|
+
const cancel = useCallback(() => cancelExport(), []);
|
|
39
|
+
|
|
40
|
+
return { run, cancel };
|
|
41
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { Gesture } from 'react-native-gesture-handler';
|
|
3
|
+
import { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated';
|
|
4
|
+
|
|
5
|
+
import { useEditorStore } from '../state/store';
|
|
6
|
+
import type { Overlay } from '../types';
|
|
7
|
+
|
|
8
|
+
export function useOverlayGestures(overlay: Overlay) {
|
|
9
|
+
const previewW = useEditorStore((s) => s.previewSize.width);
|
|
10
|
+
const previewH = useEditorStore((s) => s.previewSize.height);
|
|
11
|
+
const updateOverlay = useEditorStore((s) => s.updateOverlay);
|
|
12
|
+
const selectOverlay = useEditorStore((s) => s.selectOverlay);
|
|
13
|
+
|
|
14
|
+
const baseW = overlay.baseWidth ?? 0;
|
|
15
|
+
const baseH = overlay.baseHeight ?? 0;
|
|
16
|
+
|
|
17
|
+
const cx = useSharedValue(overlay.transform.x * previewW);
|
|
18
|
+
const cy = useSharedValue(overlay.transform.y * previewH);
|
|
19
|
+
const scale = useSharedValue(overlay.transform.scale);
|
|
20
|
+
const rot = useSharedValue(overlay.transform.rotation);
|
|
21
|
+
|
|
22
|
+
const startCx = useSharedValue(0);
|
|
23
|
+
const startCy = useSharedValue(0);
|
|
24
|
+
const startScale = useSharedValue(1);
|
|
25
|
+
const startRot = useSharedValue(0);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
cx.value = overlay.transform.x * previewW;
|
|
29
|
+
cy.value = overlay.transform.y * previewH;
|
|
30
|
+
scale.value = overlay.transform.scale;
|
|
31
|
+
rot.value = overlay.transform.rotation;
|
|
32
|
+
}, [overlay.transform, previewW, previewH, cx, cy, scale, rot]);
|
|
33
|
+
|
|
34
|
+
const commit = () => {
|
|
35
|
+
if (previewW > 0 && previewH > 0) {
|
|
36
|
+
updateOverlay(overlay.id, {
|
|
37
|
+
transform: {
|
|
38
|
+
x: cx.value / previewW,
|
|
39
|
+
y: cy.value / previewH,
|
|
40
|
+
scale: scale.value,
|
|
41
|
+
rotation: rot.value,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const select = () => selectOverlay(overlay.id);
|
|
47
|
+
|
|
48
|
+
const pan = Gesture.Pan()
|
|
49
|
+
.onStart(() => {
|
|
50
|
+
startCx.value = cx.value;
|
|
51
|
+
startCy.value = cy.value;
|
|
52
|
+
runOnJS(select)();
|
|
53
|
+
})
|
|
54
|
+
.onUpdate((e) => {
|
|
55
|
+
cx.value = startCx.value + e.translationX;
|
|
56
|
+
cy.value = startCy.value + e.translationY;
|
|
57
|
+
})
|
|
58
|
+
.onEnd(() => runOnJS(commit)());
|
|
59
|
+
|
|
60
|
+
const pinch = Gesture.Pinch()
|
|
61
|
+
.onStart(() => {
|
|
62
|
+
startScale.value = scale.value;
|
|
63
|
+
})
|
|
64
|
+
.onUpdate((e) => {
|
|
65
|
+
scale.value = Math.max(0.2, startScale.value * e.scale);
|
|
66
|
+
})
|
|
67
|
+
.onEnd(() => runOnJS(commit)());
|
|
68
|
+
|
|
69
|
+
const rotation = Gesture.Rotation()
|
|
70
|
+
.onStart(() => {
|
|
71
|
+
startRot.value = rot.value;
|
|
72
|
+
})
|
|
73
|
+
.onUpdate((e) => {
|
|
74
|
+
rot.value = startRot.value + e.rotation;
|
|
75
|
+
})
|
|
76
|
+
.onEnd(() => runOnJS(commit)());
|
|
77
|
+
|
|
78
|
+
const gesture = Gesture.Simultaneous(pan, pinch, rotation);
|
|
79
|
+
|
|
80
|
+
const animatedStyle = useAnimatedStyle(() => ({
|
|
81
|
+
transform: [
|
|
82
|
+
{ translateX: cx.value - baseW / 2 },
|
|
83
|
+
{ translateY: cy.value - baseH / 2 },
|
|
84
|
+
{ scale: scale.value },
|
|
85
|
+
{ rotate: `${rot.value}rad` },
|
|
86
|
+
],
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
return { gesture, animatedStyle };
|
|
90
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export { VideoEditor } from './components/VideoEditor';
|
|
2
|
+
export { useEditorStore as useVideoEditor } from './state/store';
|
|
3
|
+
export {
|
|
4
|
+
exportVideo,
|
|
5
|
+
exportImageVideo,
|
|
6
|
+
captureOverlays,
|
|
7
|
+
runVideoExport,
|
|
8
|
+
cancelExport,
|
|
9
|
+
type ExportImageVideoParams,
|
|
10
|
+
type ImageVideoAudio,
|
|
11
|
+
} from './services/exportService';
|
|
12
|
+
export type { NativeOverlay } from './native/VideoEditorModule';
|
|
13
|
+
export { FILTER_PRESETS, getFilterMatrix, isIdentityFilter, type FilterPreset } from './filters/presets';
|
|
14
|
+
export {
|
|
15
|
+
trendingGifs,
|
|
16
|
+
searchGifs,
|
|
17
|
+
type GiphyItem,
|
|
18
|
+
type GiphyKind,
|
|
19
|
+
} from './services/giphyClient';
|
|
20
|
+
export {
|
|
21
|
+
type MusicCatalog,
|
|
22
|
+
type MusicTrack,
|
|
23
|
+
type MusicQueryOptions,
|
|
24
|
+
} from './services/musicCatalog';
|
|
25
|
+
export { createFeedfmCatalog, type FeedfmConfig } from './services/feedfmCatalog';
|
|
26
|
+
export { createDemoCatalog } from './services/demoCatalog';
|
|
27
|
+
export { EditorError, type EditorErrorCode } from './errors';
|
|
28
|
+
export { defaultTheme, type EditorTheme } from './theme';
|
|
29
|
+
export * from './types';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { requireNativeView } from 'expo';
|
|
2
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
export type NativeLoadEvent = {
|
|
5
|
+
durationMs: number;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
rotationDeg: number;
|
|
9
|
+
};
|
|
10
|
+
export type NativeProgressEvent = { positionMs: number };
|
|
11
|
+
|
|
12
|
+
export type EditorVideoNativeProps = {
|
|
13
|
+
source: { uri: string };
|
|
14
|
+
paused: boolean;
|
|
15
|
+
loopStartMs: number;
|
|
16
|
+
loopEndMs: number;
|
|
17
|
+
seek: { seq: number; positionMs: number };
|
|
18
|
+
colorMatrix: number[];
|
|
19
|
+
onLoad?: (event: { nativeEvent: NativeLoadEvent }) => void;
|
|
20
|
+
onProgress?: (event: { nativeEvent: NativeProgressEvent }) => void;
|
|
21
|
+
onEnded?: (event: { nativeEvent: Record<string, never> }) => void;
|
|
22
|
+
style?: StyleProp<ViewStyle>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const EditorVideoView = requireNativeView<EditorVideoNativeProps>('ExpoVideoEditor');
|
|
26
|
+
|
|
27
|
+
export default EditorVideoView;
|