@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.
Files changed (58) hide show
  1. package/.prettierrc +8 -0
  2. package/LICENSE +21 -0
  3. package/README.md +117 -0
  4. package/android/build.gradle +27 -0
  5. package/android/src/main/AndroidManifest.xml +2 -0
  6. package/android/src/main/java/expo/modules/videoeditor/ColorMatrixEffect.kt +25 -0
  7. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorModule.kt +61 -0
  8. package/android/src/main/java/expo/modules/videoeditor/ExpoVideoEditorView.kt +109 -0
  9. package/android/src/main/java/expo/modules/videoeditor/ExportRunner.kt +302 -0
  10. package/android/src/main/java/expo/modules/videoeditor/GifOverlay.kt +27 -0
  11. package/android/src/main/java/expo/modules/videoeditor/Records.kt +44 -0
  12. package/eslint.config.cjs +16 -0
  13. package/expo-module.config.json +9 -0
  14. package/ios/AudioMixBuilder.swift +66 -0
  15. package/ios/ColorMatrixFilter.swift +24 -0
  16. package/ios/ExpoVideoEditor.podspec +23 -0
  17. package/ios/ExpoVideoEditorModule.swift +55 -0
  18. package/ios/ExpoVideoEditorView.swift +144 -0
  19. package/ios/ExportRunner.swift +135 -0
  20. package/ios/FrameComposer.swift +94 -0
  21. package/ios/ImageVideoWriter.swift +144 -0
  22. package/ios/Records.swift +41 -0
  23. package/package.json +36 -0
  24. package/src/components/EditorRoot.tsx +180 -0
  25. package/src/components/ExportOverlay.tsx +44 -0
  26. package/src/components/PreviewSurface.tsx +49 -0
  27. package/src/components/VideoEditor.tsx +51 -0
  28. package/src/components/audio/AudioPanel.tsx +69 -0
  29. package/src/components/audio/AudioScrubber.tsx +177 -0
  30. package/src/components/audio/Slider.tsx +60 -0
  31. package/src/components/filters/FilterCarousel.tsx +44 -0
  32. package/src/components/giphy/GiphyGrid.tsx +48 -0
  33. package/src/components/giphy/GiphyPicker.tsx +112 -0
  34. package/src/components/overlays/GifOverlayView.tsx +53 -0
  35. package/src/components/overlays/OverlayLayer.tsx +24 -0
  36. package/src/components/overlays/TextEditorSheet.tsx +135 -0
  37. package/src/components/overlays/TextOverlayView.tsx +61 -0
  38. package/src/components/timeline/ThumbnailStrip.tsx +63 -0
  39. package/src/components/timeline/TrimTimeline.tsx +152 -0
  40. package/src/errors.ts +33 -0
  41. package/src/filters/presets.ts +84 -0
  42. package/src/hooks/useAudioPreview.ts +94 -0
  43. package/src/hooks/useExport.ts +41 -0
  44. package/src/hooks/useOverlayGestures.ts +90 -0
  45. package/src/index.ts +29 -0
  46. package/src/native/EditorVideoView.tsx +27 -0
  47. package/src/native/VideoEditorModule.ts +51 -0
  48. package/src/overlays/captureRegistry.ts +12 -0
  49. package/src/services/demoCatalog.ts +30 -0
  50. package/src/services/exportService.ts +199 -0
  51. package/src/services/feedfmCatalog.ts +44 -0
  52. package/src/services/giphyClient.ts +69 -0
  53. package/src/services/mediaCache.ts +18 -0
  54. package/src/services/musicCatalog.ts +23 -0
  55. package/src/state/store.ts +200 -0
  56. package/src/theme.ts +26 -0
  57. package/src/types.ts +104 -0
  58. package/tsconfig.json +28 -0
@@ -0,0 +1,144 @@
1
+ import AVFoundation
2
+ import CoreVideo
3
+ import UIKit
4
+
5
+ enum ImageVideoWriter {
6
+ enum WriterError: Error {
7
+ case imageLoad
8
+ case setup
9
+ case pixelBuffer
10
+ case write
11
+ }
12
+
13
+ static func makeSilentVideo(imageUri: String, durationMs: Int, maxSide: Int) async throws -> URL {
14
+ guard let image = loadImage(imageUri), let cgImage = image.cgImage else {
15
+ throw WriterError.imageLoad
16
+ }
17
+
18
+ let (width, height) = outputSize(cgImage.width, cgImage.height, maxSide: maxSide)
19
+ let outURL = FileManager.default.temporaryDirectory
20
+ .appendingPathComponent("vieditor-still-\(UUID().uuidString).mp4")
21
+
22
+ guard let writer = try? AVAssetWriter(outputURL: outURL, fileType: .mp4) else {
23
+ throw WriterError.setup
24
+ }
25
+
26
+ let settings: [String: Any] = [
27
+ AVVideoCodecKey: AVVideoCodecType.h264,
28
+ AVVideoWidthKey: width,
29
+ AVVideoHeightKey: height,
30
+ ]
31
+ let input = AVAssetWriterInput(mediaType: .video, outputSettings: settings)
32
+ input.expectsMediaDataInRealTime = false
33
+
34
+ let attrs: [String: Any] = [
35
+ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32ARGB,
36
+ kCVPixelBufferWidthKey as String: width,
37
+ kCVPixelBufferHeightKey as String: height,
38
+ ]
39
+ let adaptor = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: input, sourcePixelBufferAttributes: attrs)
40
+
41
+ guard writer.canAdd(input) else { throw WriterError.setup }
42
+ writer.add(input)
43
+
44
+ guard writer.startWriting() else { throw WriterError.write }
45
+ writer.startSession(atSourceTime: .zero)
46
+
47
+ guard let buffer = makePixelBuffer(cgImage: cgImage, width: width, height: height, pool: adaptor.pixelBufferPool) else {
48
+ writer.cancelWriting()
49
+ throw WriterError.pixelBuffer
50
+ }
51
+
52
+ let durationSeconds = max(0.1, Double(durationMs) / 1000.0)
53
+ let timescale: CMTimeScale = 600
54
+ let endTime = CMTime(seconds: durationSeconds, preferredTimescale: timescale)
55
+
56
+ // Append the same still frame at start and end so the encoded track spans
57
+ // the full duration (a single sample at .zero yields a ~0s track). The input
58
+ // is non-realtime, so it is ready to accept both samples right away.
59
+ while !input.isReadyForMoreMediaData { usleep(2000) }
60
+ adaptor.append(buffer, withPresentationTime: .zero)
61
+ while !input.isReadyForMoreMediaData { usleep(2000) }
62
+ adaptor.append(buffer, withPresentationTime: endTime)
63
+
64
+ input.markAsFinished()
65
+ writer.endSession(atSourceTime: endTime)
66
+
67
+ await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
68
+ writer.finishWriting { continuation.resume() }
69
+ }
70
+
71
+ if writer.status != .completed {
72
+ throw WriterError.write
73
+ }
74
+ return outURL
75
+ }
76
+
77
+ private static func loadImage(_ uri: String) -> UIImage? {
78
+ if uri.hasPrefix("/") { return UIImage(contentsOfFile: uri) }
79
+ if let url = URL(string: uri) {
80
+ if url.isFileURL { return UIImage(contentsOfFile: url.path) }
81
+ if let data = try? Data(contentsOf: url) { return UIImage(data: data) }
82
+ }
83
+ return UIImage(contentsOfFile: uri)
84
+ }
85
+
86
+ private static func outputSize(_ w: Int, _ h: Int, maxSide: Int) -> (Int, Int) {
87
+ var outW = w
88
+ var outH = h
89
+ let cap = maxSide > 0 ? maxSide : 1920
90
+ let longer = max(w, h)
91
+ if longer > cap {
92
+ let scale = Double(cap) / Double(longer)
93
+ outW = Int((Double(w) * scale).rounded())
94
+ outH = Int((Double(h) * scale).rounded())
95
+ }
96
+ if outW % 2 != 0 { outW -= 1 }
97
+ if outH % 2 != 0 { outH -= 1 }
98
+ return (max(2, outW), max(2, outH))
99
+ }
100
+
101
+ private static func makePixelBuffer(cgImage: CGImage, width: Int, height: Int, pool: CVPixelBufferPool?) -> CVPixelBuffer? {
102
+ var pixelBuffer: CVPixelBuffer?
103
+ if let pool = pool {
104
+ CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixelBuffer)
105
+ }
106
+ if pixelBuffer == nil {
107
+ let attrs: [String: Any] = [
108
+ kCVPixelBufferCGImageCompatibilityKey as String: true,
109
+ kCVPixelBufferCGBitmapContextCompatibilityKey as String: true,
110
+ ]
111
+ CVPixelBufferCreate(kCFAllocatorDefault, width, height, kCVPixelFormatType_32ARGB, attrs as CFDictionary, &pixelBuffer)
112
+ }
113
+ guard let buffer = pixelBuffer else { return nil }
114
+
115
+ CVPixelBufferLockBaseAddress(buffer, [])
116
+ defer { CVPixelBufferUnlockBaseAddress(buffer, []) }
117
+
118
+ guard let context = CGContext(
119
+ data: CVPixelBufferGetBaseAddress(buffer),
120
+ width: width,
121
+ height: height,
122
+ bitsPerComponent: 8,
123
+ bytesPerRow: CVPixelBufferGetBytesPerRow(buffer),
124
+ space: CGColorSpaceCreateDeviceRGB(),
125
+ bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
126
+ ) else {
127
+ return nil
128
+ }
129
+
130
+ context.setFillColor(UIColor.black.cgColor)
131
+ context.fill(CGRect(x: 0, y: 0, width: width, height: height))
132
+
133
+ let imgW = CGFloat(cgImage.width)
134
+ let imgH = CGFloat(cgImage.height)
135
+ let scale = max(CGFloat(width) / imgW, CGFloat(height) / imgH)
136
+ let drawW = imgW * scale
137
+ let drawH = imgH * scale
138
+ let x = (CGFloat(width) - drawW) / 2
139
+ let y = (CGFloat(height) - drawH) / 2
140
+ context.draw(cgImage, in: CGRect(x: x, y: y, width: drawW, height: drawH))
141
+
142
+ return buffer
143
+ }
144
+ }
@@ -0,0 +1,41 @@
1
+ import ExpoModulesCore
2
+
3
+ struct VideoSourceRecord: Record {
4
+ @Field var uri: String = ""
5
+ }
6
+
7
+ struct SeekRecord: Record {
8
+ @Field var seq: Int = 0
9
+ @Field var positionMs: Int = 0
10
+ }
11
+
12
+ struct OverlayRecord: Record {
13
+ @Field var uri: String = ""
14
+ @Field var animated: Bool = false
15
+ @Field var centerX: Double = 0.5
16
+ @Field var centerY: Double = 0.5
17
+ @Field var widthFraction: Double = 0.0
18
+ @Field var heightFraction: Double = 0.0
19
+ @Field var rotationDeg: Double = 0.0
20
+ }
21
+
22
+ struct AudioRecord: Record {
23
+ @Field var uri: String = ""
24
+ @Field var trimStartMs: Int = 0
25
+ @Field var trimEndMs: Int = 0
26
+ @Field var volume: Double = 1
27
+ @Field var loop: Bool = true
28
+ }
29
+
30
+ struct ExportPayload: Record {
31
+ @Field var sourceUri: String = ""
32
+ @Field var startMs: Int = 0
33
+ @Field var endMs: Int = 0
34
+ @Field var imageDurationMs: Int = 0
35
+ @Field var resolution: Int = 0
36
+ @Field var bitrate: Int = 0
37
+ @Field var colorMatrix: [Double] = []
38
+ @Field var overlays: [OverlayRecord] = []
39
+ @Field var audio: AudioRecord?
40
+ @Field var originalVolume: Double = 1
41
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@igorpache/expo-video-editor",
3
+ "version": "0.1.0",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "High-performance Stories-style video editor for Expo/React Native (native pipeline, agnostic audio, Giphy)",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "keywords": [
11
+ "react-native",
12
+ "expo",
13
+ "expo-video-editor",
14
+ "ExpoVideoEditor"
15
+ ],
16
+ "repository": "https://github.com/GleidsonDaniel/expo-video-editor",
17
+ "author": "Gleidson Daniel <gleidson10daniel@gmail.com> (https://github.com/GleidsonDaniel)",
18
+ "license": "MIT",
19
+ "dependencies": {
20
+ "zustand": "^5.0.2"
21
+ },
22
+ "peerDependencies": {
23
+ "expo": "*",
24
+ "react": "*",
25
+ "react-native": "*",
26
+ "react-native-gesture-handler": ">=2.0.0",
27
+ "react-native-reanimated": ">=3.0.0",
28
+ "react-native-safe-area-context": "*",
29
+ "lucide-react-native": "*",
30
+ "react-native-view-shot": "*",
31
+ "expo-video-thumbnails": "*",
32
+ "expo-image": "*",
33
+ "expo-audio": "*",
34
+ "expo-file-system": "*"
35
+ }
36
+ }
@@ -0,0 +1,180 @@
1
+ import { Platform, Pressable, StatusBar as RNStatusBar, StyleSheet, Text, View } from 'react-native';
2
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
3
+ import { Music, Sparkles, Sticker, Type, X } from 'lucide-react-native';
4
+
5
+ import { useAudioPreview } from '../hooks/useAudioPreview';
6
+ import { useExport, type ExportCallbacks } from '../hooks/useExport';
7
+ import { type ActivePanel, useEditorStore } from '../state/store';
8
+ import { type EditorTheme, useTheme } from '../theme';
9
+ import type { EditorState, ExportOptions } from '../types';
10
+ import { ExportOverlay } from './ExportOverlay';
11
+ import { PreviewSurface } from './PreviewSurface';
12
+ import { FilterCarousel } from './filters/FilterCarousel';
13
+ import { GiphyPicker } from './giphy/GiphyPicker';
14
+ import { TextEditorSheet } from './overlays/TextEditorSheet';
15
+ import { TrimTimeline } from './timeline/TrimTimeline';
16
+
17
+ type Props = {
18
+ exportOptions: ExportOptions;
19
+ callbacks: ExportCallbacks;
20
+ onRequestExport?: (state: EditorState, options: ExportOptions) => void;
21
+ onCancel?: () => void;
22
+ };
23
+
24
+ export function EditorRoot({ exportOptions, callbacks, onRequestExport, onCancel }: Props) {
25
+ const theme = useTheme();
26
+ const insets = useSafeAreaInsets();
27
+ const paused = useEditorStore((s) => s.paused);
28
+ const togglePlay = useEditorStore((s) => s.togglePlay);
29
+ const hasMeta = useEditorStore((s) => s.meta != null);
30
+ const activePanel = useEditorStore((s) => s.activePanel);
31
+ const setActivePanel = useEditorStore((s) => s.setActivePanel);
32
+ const addTextOverlay = useEditorStore((s) => s.addTextOverlay);
33
+ const giphyApiKey = useEditorStore((s) => s.giphyApiKey);
34
+ const audio = useEditorStore((s) => s.audio);
35
+ const audioPicker = useEditorStore((s) => s.audioPicker);
36
+ const setAudioFromSource = useEditorStore((s) => s.setAudioFromSource);
37
+ const getEditorState = useEditorStore((s) => s.getEditorState);
38
+ const { run, cancel } = useExport(exportOptions, callbacks);
39
+
40
+ const onProceed = () => {
41
+ if (onRequestExport) onRequestExport(getEditorState(), exportOptions);
42
+ else run();
43
+ };
44
+
45
+ const topPad =
46
+ Platform.OS === 'android'
47
+ ? Math.max(insets.top, RNStatusBar.currentHeight ?? 0)
48
+ : insets.top;
49
+
50
+ useAudioPreview();
51
+
52
+ const toggle = (panel: ActivePanel) => setActivePanel(activePanel === panel ? 'none' : panel);
53
+
54
+ const onMusic = async () => {
55
+ if (!audioPicker) return;
56
+ const source = await audioPicker();
57
+ if (source) setAudioFromSource(source);
58
+ };
59
+
60
+ return (
61
+ <View style={[styles.root, { backgroundColor: theme.background }]}>
62
+ <View style={[styles.header, { paddingTop: topPad + 20 }]}>
63
+ <Pressable onPress={onCancel} hitSlop={12} style={styles.headerBtn}>
64
+ <X size={22} color="#fff" />
65
+ </Pressable>
66
+ <View style={styles.tools}>
67
+ <IconTool active={activePanel === 'filter'} onPress={() => toggle('filter')} theme={theme}>
68
+ <Sparkles size={20} color="#fff" />
69
+ </IconTool>
70
+ <IconTool active={false} onPress={() => addTextOverlay()} theme={theme}>
71
+ <Type size={20} color="#fff" />
72
+ </IconTool>
73
+ {giphyApiKey ? (
74
+ <IconTool active={activePanel === 'giphy'} onPress={() => setActivePanel('giphy')} theme={theme}>
75
+ <Sticker size={20} color="#fff" />
76
+ </IconTool>
77
+ ) : null}
78
+ {audioPicker || audio ? (
79
+ <IconTool active={activePanel === 'audio'} onPress={onMusic} theme={theme}>
80
+ <Music size={20} color="#fff" />
81
+ </IconTool>
82
+ ) : null}
83
+ </View>
84
+ </View>
85
+
86
+ <View style={styles.preview}>
87
+ <PreviewSurface />
88
+ <Pressable style={styles.tapLayer} onPress={togglePlay}>
89
+ {paused ? <Text style={styles.playIcon}>▶</Text> : null}
90
+ </Pressable>
91
+ {activePanel === 'filter' ? (
92
+ <View style={styles.filterOverlay}>
93
+ <FilterCarousel />
94
+ </View>
95
+ ) : null}
96
+ </View>
97
+
98
+ <View style={[styles.bottom, { paddingBottom: insets.bottom + 16 }]}>
99
+ {hasMeta ? <TrimTimeline /> : null}
100
+ <Pressable style={[styles.proceedBtn, { backgroundColor: theme.accent }]} onPress={onProceed}>
101
+ <Text style={styles.proceedText}>Prosseguir</Text>
102
+ </Pressable>
103
+ </View>
104
+
105
+ <TextEditorSheet />
106
+ <GiphyPicker />
107
+
108
+ <ExportOverlay
109
+ onCancel={() => {
110
+ cancel();
111
+ onCancel?.();
112
+ }}
113
+ />
114
+ </View>
115
+ );
116
+ }
117
+
118
+ function IconTool({
119
+ children,
120
+ active,
121
+ onPress,
122
+ theme,
123
+ }: {
124
+ children: React.ReactNode;
125
+ active: boolean;
126
+ onPress: () => void;
127
+ theme: EditorTheme;
128
+ }) {
129
+ return (
130
+ <Pressable
131
+ onPress={onPress}
132
+ hitSlop={8}
133
+ style={[styles.iconBtn, { backgroundColor: active ? theme.accent : 'rgba(0,0,0,0.35)' }]}>
134
+ {children}
135
+ </Pressable>
136
+ );
137
+ }
138
+
139
+ const styles = StyleSheet.create({
140
+ root: { flex: 1 },
141
+ header: {
142
+ flexDirection: 'row',
143
+ alignItems: 'center',
144
+ justifyContent: 'space-between',
145
+ paddingHorizontal: 16,
146
+ paddingBottom: 8,
147
+ zIndex: 2,
148
+ },
149
+ headerBtn: {
150
+ width: 40,
151
+ height: 40,
152
+ borderRadius: 20,
153
+ alignItems: 'center',
154
+ justifyContent: 'center',
155
+ backgroundColor: 'rgba(0,0,0,0.35)',
156
+ },
157
+ tools: { flexDirection: 'row', alignItems: 'center', gap: 4 },
158
+ iconBtn: {
159
+ width: 40,
160
+ height: 40,
161
+ borderRadius: 20,
162
+ alignItems: 'center',
163
+ justifyContent: 'center',
164
+ },
165
+ preview: { flex: 1 },
166
+ tapLayer: {
167
+ position: 'absolute',
168
+ top: 0,
169
+ left: 0,
170
+ right: 0,
171
+ bottom: 0,
172
+ alignItems: 'center',
173
+ justifyContent: 'center',
174
+ },
175
+ playIcon: { color: 'rgba(255,255,255,0.9)', fontSize: 56 },
176
+ filterOverlay: { position: 'absolute', left: 0, right: 0, bottom: 12 },
177
+ bottom: { paddingHorizontal: 16, paddingTop: 12, gap: 14 },
178
+ proceedBtn: { borderRadius: 28, paddingVertical: 14, alignItems: 'center' },
179
+ proceedText: { color: '#fff', fontSize: 16, fontWeight: '700' },
180
+ });
@@ -0,0 +1,44 @@
1
+ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native';
2
+
3
+ import { useEditorStore } from '../state/store';
4
+ import { useTheme } from '../theme';
5
+
6
+ export function ExportOverlay({ onCancel }: { onCancel: () => void }) {
7
+ const exporting = useEditorStore((s) => s.exporting);
8
+ const progress = useEditorStore((s) => s.exportProgress);
9
+ const theme = useTheme();
10
+
11
+ if (!exporting) return null;
12
+ const pct = Math.round(progress * 100);
13
+
14
+ return (
15
+ <View style={styles.overlay}>
16
+ <ActivityIndicator color={theme.accent} size="large" />
17
+ <Text style={[styles.pct, { color: theme.text }]}>{pct}%</Text>
18
+ <View style={styles.barBg}>
19
+ <View style={[styles.barFill, { width: `${pct}%`, backgroundColor: theme.accent }]} />
20
+ </View>
21
+ <Pressable onPress={onCancel} hitSlop={12}>
22
+ <Text style={[styles.cancel, { color: theme.textMuted }]}>Cancel</Text>
23
+ </Pressable>
24
+ </View>
25
+ );
26
+ }
27
+
28
+ const styles = StyleSheet.create({
29
+ overlay: {
30
+ position: 'absolute',
31
+ top: 0,
32
+ left: 0,
33
+ right: 0,
34
+ bottom: 0,
35
+ backgroundColor: 'rgba(0,0,0,0.85)',
36
+ alignItems: 'center',
37
+ justifyContent: 'center',
38
+ gap: 16,
39
+ },
40
+ pct: { fontSize: 28, fontWeight: '700' },
41
+ barBg: { width: '60%', height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.15)' },
42
+ barFill: { height: 4, borderRadius: 2 },
43
+ cancel: { fontSize: 15, marginTop: 8 },
44
+ });
@@ -0,0 +1,49 @@
1
+ import { StyleSheet, View } from 'react-native';
2
+
3
+ import { getFilterMatrix, isIdentityFilter } from '../filters/presets';
4
+ import EditorVideoView from '../native/EditorVideoView';
5
+ import { useEditorStore } from '../state/store';
6
+ import { OverlayLayer } from './overlays/OverlayLayer';
7
+
8
+ export function PreviewSurface() {
9
+ const source = useEditorStore((s) => s.source);
10
+ const paused = useEditorStore((s) => s.paused);
11
+ const trimStart = useEditorStore((s) => s.trim.start);
12
+ const trimEnd = useEditorStore((s) => s.trim.end);
13
+ const filter = useEditorStore((s) => s.filter);
14
+ const seekNonce = useEditorStore((s) => s.seekNonce);
15
+ const seekTarget = useEditorStore((s) => s.seekTarget);
16
+ const setMeta = useEditorStore((s) => s.setMeta);
17
+ const setPlayhead = useEditorStore((s) => s.setPlayhead);
18
+ const setPreviewSize = useEditorStore((s) => s.setPreviewSize);
19
+
20
+ const colorMatrix = isIdentityFilter(filter) ? [] : getFilterMatrix(filter);
21
+
22
+ if (!source.uri) return <View style={styles.container} />;
23
+
24
+ return (
25
+ <View
26
+ style={styles.container}
27
+ onLayout={(e) => setPreviewSize(e.nativeEvent.layout.width, e.nativeEvent.layout.height)}>
28
+ <EditorVideoView
29
+ style={StyleSheet.absoluteFill}
30
+ source={source}
31
+ paused={paused}
32
+ loopStartMs={Math.round(trimStart * 1000)}
33
+ loopEndMs={Math.round(trimEnd * 1000)}
34
+ seek={{ seq: seekNonce, positionMs: Math.round(seekTarget * 1000) }}
35
+ colorMatrix={colorMatrix}
36
+ onLoad={(e) => {
37
+ const { durationMs, width, height, rotationDeg } = e.nativeEvent;
38
+ setMeta({ duration: durationMs / 1000, width, height, rotation: rotationDeg });
39
+ }}
40
+ onProgress={(e) => setPlayhead(e.nativeEvent.positionMs / 1000)}
41
+ />
42
+ <OverlayLayer />
43
+ </View>
44
+ );
45
+ }
46
+
47
+ const styles = StyleSheet.create({
48
+ container: { flex: 1, backgroundColor: '#000', overflow: 'hidden' },
49
+ });
@@ -0,0 +1,51 @@
1
+ import { useEffect, useMemo } from 'react';
2
+ import { StyleSheet, View } from 'react-native';
3
+
4
+ import { useEditorStore } from '../state/store';
5
+ import { EditorThemeContext, mergeTheme } from '../theme';
6
+ import type { VideoEditorProps } from '../types';
7
+ import { EditorRoot } from './EditorRoot';
8
+
9
+ export function VideoEditor(props: VideoEditorProps) {
10
+ const init = useEditorStore((s) => s.init);
11
+ const setGiphyApiKey = useEditorStore((s) => s.setGiphyApiKey);
12
+ const setAudioPicker = useEditorStore((s) => s.setAudioPicker);
13
+ const theme = useMemo(() => mergeTheme(props.theme), [props.theme]);
14
+
15
+ useEffect(() => {
16
+ init(props.source);
17
+ }, [props.source.uri, init]);
18
+
19
+ useEffect(() => {
20
+ setGiphyApiKey(props.giphy?.apiKey ?? null);
21
+ }, [props.giphy?.apiKey, setGiphyApiKey]);
22
+
23
+ useEffect(() => {
24
+ setAudioPicker(props.audio?.onPickAudio ?? null);
25
+ }, [props.audio?.onPickAudio, setAudioPicker]);
26
+
27
+ const callbacks = useMemo(
28
+ () => ({
29
+ onExportStart: props.onExportStart,
30
+ onExportProgress: props.onExportProgress,
31
+ onExportComplete: props.onExportComplete,
32
+ onExportError: props.onExportError,
33
+ }),
34
+ [props.onExportStart, props.onExportProgress, props.onExportComplete, props.onExportError]
35
+ );
36
+
37
+ return (
38
+ <View style={[styles.root, props.style]}>
39
+ <EditorThemeContext.Provider value={theme}>
40
+ <EditorRoot
41
+ exportOptions={props.export ?? {}}
42
+ callbacks={callbacks}
43
+ onRequestExport={props.onRequestExport}
44
+ onCancel={props.onCancel}
45
+ />
46
+ </EditorThemeContext.Provider>
47
+ </View>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({ root: { flex: 1 } });
@@ -0,0 +1,69 @@
1
+ import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
2
+
3
+ import { AudioScrubber } from './AudioScrubber';
4
+ import { Slider } from './Slider';
5
+ import { useEditorStore } from '../../state/store';
6
+ import { useTheme } from '../../theme';
7
+
8
+ export function AudioPanel() {
9
+ const activePanel = useEditorStore((s) => s.activePanel);
10
+ const setActivePanel = useEditorStore((s) => s.setActivePanel);
11
+ const audio = useEditorStore((s) => s.audio);
12
+ const originalVolume = useEditorStore((s) => s.originalVolume);
13
+ const updateAudio = useEditorStore((s) => s.updateAudio);
14
+ const clearAudio = useEditorStore((s) => s.clearAudio);
15
+ const setOriginalVolume = useEditorStore((s) => s.setOriginalVolume);
16
+ const theme = useTheme();
17
+
18
+ if (activePanel !== 'audio' || !audio) return null;
19
+ const close = () => setActivePanel('none');
20
+
21
+ return (
22
+ <Modal transparent visible animationType="slide" onRequestClose={close}>
23
+ <View style={styles.backdrop}>
24
+ <View style={[styles.sheet, { backgroundColor: theme.background }]}>
25
+ <View style={styles.topbar}>
26
+ <Pressable hitSlop={12} onPress={clearAudio}>
27
+ <Text style={[styles.action, { color: theme.danger }]}>Remove</Text>
28
+ </Pressable>
29
+ <Text style={[styles.title, { color: theme.text }]} numberOfLines={1}>
30
+ {audio.title ?? 'Audio'}
31
+ </Text>
32
+ <Pressable hitSlop={12} onPress={close}>
33
+ <Text style={[styles.action, { color: theme.accent }]}>Done</Text>
34
+ </Pressable>
35
+ </View>
36
+
37
+ <Text style={[styles.label, { color: theme.textMuted }]}>Selection</Text>
38
+ <AudioScrubber />
39
+
40
+ <Text style={[styles.label, { color: theme.textMuted }]}>Music volume</Text>
41
+ <Slider value={audio.volume} onChange={(v) => updateAudio({ volume: v })} />
42
+
43
+ <Text style={[styles.label, { color: theme.textMuted }]}>Original video volume</Text>
44
+ <Slider value={originalVolume} onChange={setOriginalVolume} />
45
+ </View>
46
+ </View>
47
+ </Modal>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ backdrop: { flex: 1, justifyContent: 'flex-end', backgroundColor: 'rgba(0,0,0,0.5)' },
53
+ sheet: {
54
+ padding: 20,
55
+ paddingBottom: 40,
56
+ borderTopLeftRadius: 20,
57
+ borderTopRightRadius: 20,
58
+ gap: 10,
59
+ },
60
+ topbar: {
61
+ flexDirection: 'row',
62
+ alignItems: 'center',
63
+ justifyContent: 'space-between',
64
+ marginBottom: 8,
65
+ },
66
+ title: { fontSize: 15, fontWeight: '600', flex: 1, textAlign: 'center', marginHorizontal: 12 },
67
+ action: { fontSize: 15, fontWeight: '700' },
68
+ label: { fontSize: 12, marginTop: 8 },
69
+ });