@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,51 @@
1
+ import { NativeModule, requireNativeModule } from 'expo';
2
+
3
+ export type NativeOverlay = {
4
+ uri: string;
5
+ animated: boolean;
6
+ centerX: number;
7
+ centerY: number;
8
+ widthFraction: number;
9
+ heightFraction: number;
10
+ rotationDeg: number;
11
+ };
12
+
13
+ export type NativeAudio = {
14
+ uri: string;
15
+ trimStartMs: number;
16
+ trimEndMs: number;
17
+ volume: number;
18
+ loop: boolean;
19
+ };
20
+
21
+ export type NativeExportPayload = {
22
+ sourceUri: string;
23
+ startMs: number;
24
+ endMs: number;
25
+ imageDurationMs: number;
26
+ resolution: number;
27
+ bitrate: number;
28
+ colorMatrix: number[];
29
+ overlays: NativeOverlay[];
30
+ audio: NativeAudio | null;
31
+ originalVolume: number;
32
+ };
33
+
34
+ export type NativeExportResult = {
35
+ uri: string;
36
+ width: number;
37
+ height: number;
38
+ durationMs: number;
39
+ };
40
+
41
+ type VideoEditorModuleEvents = {
42
+ onExportProgress: (event: { progress: number }) => void;
43
+ };
44
+
45
+ declare class VideoEditorNativeModule extends NativeModule<VideoEditorModuleEvents> {
46
+ exportVideo(payload: NativeExportPayload): Promise<NativeExportResult>;
47
+ cancelExport(): void;
48
+ ping(): Promise<string>;
49
+ }
50
+
51
+ export default requireNativeModule<VideoEditorNativeModule>('ExpoVideoEditor');
@@ -0,0 +1,12 @@
1
+ import type { View } from 'react-native';
2
+
3
+ const refs = new Map<string, View>();
4
+
5
+ export function registerOverlayRef(id: string, view: View | null): void {
6
+ if (view) refs.set(id, view);
7
+ else refs.delete(id);
8
+ }
9
+
10
+ export function getOverlayRef(id: string): View | undefined {
11
+ return refs.get(id);
12
+ }
@@ -0,0 +1,30 @@
1
+ import type { MusicCatalog, MusicQueryOptions, MusicTrack } from './musicCatalog';
2
+
3
+ // Catálogo DEMO — faixas livres (SoundHelix) só para testar o fluxo/UX enquanto
4
+ // não há provedor licenciado contratado. Substituir por createFeedfmCatalog
5
+ // (ou outro provedor) setando EXPO_PUBLIC_MUSIC_TOKEN.
6
+ const DEMO_TRACKS: MusicTrack[] = [
7
+ { id: 'demo-1', title: 'Song 1', artist: 'SoundHelix', durationSec: 372, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
8
+ { id: 'demo-2', title: 'Song 2', artist: 'SoundHelix', durationSec: 425, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
9
+ { id: 'demo-3', title: 'Song 3', artist: 'SoundHelix', durationSec: 231, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' },
10
+ { id: 'demo-5', title: 'Song 5', artist: 'SoundHelix', durationSec: 302, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-5.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-5.mp3' },
11
+ { id: 'demo-8', title: 'Song 8', artist: 'SoundHelix', durationSec: 386, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-8.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-8.mp3' },
12
+ { id: 'demo-11', title: 'Song 11', artist: 'SoundHelix', durationSec: 336, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-11.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-11.mp3' },
13
+ { id: 'demo-15', title: 'Song 15', artist: 'SoundHelix', durationSec: 425, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-15.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-15.mp3' },
14
+ { id: 'demo-17', title: 'Song 17', artist: 'SoundHelix', durationSec: 356, previewUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-17.mp3', audioUrl: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-17.mp3' },
15
+ ];
16
+
17
+ export function createDemoCatalog(): MusicCatalog {
18
+ return {
19
+ async trending(_opts?: MusicQueryOptions) {
20
+ return DEMO_TRACKS;
21
+ },
22
+ async search(query: string, _opts?: MusicQueryOptions) {
23
+ const q = query.trim().toLowerCase();
24
+ if (!q) return DEMO_TRACKS;
25
+ return DEMO_TRACKS.filter(
26
+ (t) => t.title.toLowerCase().includes(q) || (t.artist ?? '').toLowerCase().includes(q)
27
+ );
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,199 @@
1
+ import { captureRef } from 'react-native-view-shot';
2
+
3
+ import { toEditorError } from '../errors';
4
+ import { getFilterMatrix, isIdentityFilter } from '../filters/presets';
5
+ import Native, { type NativeExportPayload, type NativeOverlay } from '../native/VideoEditorModule';
6
+ import { getOverlayRef } from '../overlays/captureRegistry';
7
+ import { useEditorStore } from '../state/store';
8
+ import type { EditorState, ExportOptions, ExportResult } from '../types';
9
+ import { downloadToCache } from './mediaCache';
10
+
11
+ async function buildOverlayPayload(state: EditorState): Promise<NativeOverlay[]> {
12
+ if (!state.meta || state.overlays.length === 0) return [];
13
+ const { width: pw, height: ph } = useEditorStore.getState().previewSize;
14
+ if (pw <= 0 || ph <= 0) return [];
15
+
16
+ const result: NativeOverlay[] = [];
17
+ for (const ov of state.overlays) {
18
+ const baseW = ov.baseWidth ?? 0;
19
+ const baseH = ov.baseHeight ?? 0;
20
+ if (baseW <= 0 || baseH <= 0) continue;
21
+
22
+ let uri: string;
23
+ let animated: boolean;
24
+ if (ov.type === 'text') {
25
+ const ref = getOverlayRef(ov.id);
26
+ if (!ref) continue;
27
+ try {
28
+ uri = await captureRef(ref, { format: 'png', quality: 1, result: 'tmpfile' });
29
+ } catch {
30
+ continue;
31
+ }
32
+ animated = false;
33
+ } else {
34
+ try {
35
+ uri = await downloadToCache(ov.exportUri);
36
+ } catch {
37
+ continue;
38
+ }
39
+ animated = true;
40
+ }
41
+
42
+ result.push({
43
+ uri,
44
+ animated,
45
+ centerX: ov.transform.x,
46
+ centerY: ov.transform.y,
47
+ widthFraction: (baseW * ov.transform.scale) / pw,
48
+ heightFraction: (baseH * ov.transform.scale) / ph,
49
+ rotationDeg: (ov.transform.rotation * 180) / Math.PI,
50
+ });
51
+ }
52
+ return result;
53
+ }
54
+
55
+ function resolutionToHeight(resolution: ExportOptions['resolution']): number {
56
+ if (resolution === 'source') return 0;
57
+ if (resolution === 720) return 720;
58
+ return 1080;
59
+ }
60
+
61
+ // Rasteriza os overlays (view-shot do texto, download do GIF) em arquivos de
62
+ // disco. Precisa das views montadas — chame ENQUANTO o editor estiver aberto.
63
+ export async function captureOverlays(state: EditorState): Promise<NativeOverlay[]> {
64
+ return buildOverlayPayload(state);
65
+ }
66
+
67
+ // Encode nativo. Não depende de views RN (usa os arquivos já capturados), então
68
+ // pode rodar em background depois do editor fechar.
69
+ export async function runVideoExport(
70
+ state: EditorState,
71
+ overlays: NativeOverlay[],
72
+ options: ExportOptions = {},
73
+ onProgress?: (progress: number) => void
74
+ ): Promise<ExportResult> {
75
+ let audio: NativeExportPayload['audio'] = null;
76
+ if (state.audio) {
77
+ let uri = state.audio.uri;
78
+ if (/^https?:/i.test(uri)) {
79
+ try {
80
+ uri = await downloadToCache(uri);
81
+ } catch (e) {
82
+ throw toEditorError(e, 'AUDIO_LOAD');
83
+ }
84
+ }
85
+ audio = {
86
+ uri,
87
+ trimStartMs: Math.round(state.audio.trimStart * 1000),
88
+ trimEndMs: Math.round(state.audio.trimEnd * 1000),
89
+ volume: state.audio.volume,
90
+ loop: state.audio.loop,
91
+ };
92
+ }
93
+
94
+ const payload: NativeExportPayload = {
95
+ sourceUri: state.source.uri,
96
+ startMs: Math.round(state.trim.start * 1000),
97
+ endMs: Math.round(state.trim.end * 1000),
98
+ imageDurationMs: 0,
99
+ resolution: resolutionToHeight(options.resolution),
100
+ bitrate: options.bitrate ?? 0,
101
+ colorMatrix: isIdentityFilter(state.filter) ? [] : getFilterMatrix(state.filter),
102
+ overlays,
103
+ audio,
104
+ originalVolume: state.originalVolume,
105
+ };
106
+
107
+ const sub = onProgress
108
+ ? Native.addListener('onExportProgress', (e) => onProgress(e.progress))
109
+ : null;
110
+
111
+ try {
112
+ const r = await Native.exportVideo(payload);
113
+ return { uri: r.uri, width: r.width, height: r.height, duration: r.durationMs / 1000 };
114
+ } catch (e) {
115
+ throw toEditorError(e);
116
+ } finally {
117
+ sub?.remove();
118
+ }
119
+ }
120
+
121
+ export async function exportVideo(
122
+ state: EditorState,
123
+ options: ExportOptions = {},
124
+ onProgress?: (progress: number) => void
125
+ ): Promise<ExportResult> {
126
+ const overlays = await captureOverlays(state);
127
+ return runVideoExport(state, overlays, options, onProgress);
128
+ }
129
+
130
+ export type ImageVideoAudio = {
131
+ uri: string;
132
+ trimStartMs?: number;
133
+ trimEndMs?: number;
134
+ volume?: number;
135
+ loop?: boolean;
136
+ };
137
+
138
+ export type ExportImageVideoParams = {
139
+ imageUri: string;
140
+ durationMs: number;
141
+ audio?: ImageVideoAudio | null;
142
+ resolution?: ExportOptions['resolution'];
143
+ };
144
+
145
+ export async function exportImageVideo(
146
+ params: ExportImageVideoParams,
147
+ onProgress?: (progress: number) => void
148
+ ): Promise<ExportResult> {
149
+ const durationMs = Math.max(1, Math.round(params.durationMs));
150
+
151
+ let audio: NativeExportPayload['audio'] = null;
152
+ if (params.audio?.uri) {
153
+ let uri = params.audio.uri;
154
+ if (/^https?:/i.test(uri)) {
155
+ try {
156
+ uri = await downloadToCache(uri);
157
+ } catch (e) {
158
+ throw toEditorError(e, 'AUDIO_LOAD');
159
+ }
160
+ }
161
+ audio = {
162
+ uri,
163
+ trimStartMs: Math.round(params.audio.trimStartMs ?? 0),
164
+ trimEndMs: Math.round(params.audio.trimEndMs ?? 0),
165
+ volume: params.audio.volume ?? 1,
166
+ loop: params.audio.loop ?? true,
167
+ };
168
+ }
169
+
170
+ const payload: NativeExportPayload = {
171
+ sourceUri: params.imageUri,
172
+ startMs: 0,
173
+ endMs: durationMs,
174
+ imageDurationMs: durationMs,
175
+ resolution: resolutionToHeight(params.resolution),
176
+ bitrate: 0,
177
+ colorMatrix: [],
178
+ overlays: [],
179
+ audio,
180
+ originalVolume: 1,
181
+ };
182
+
183
+ const sub = onProgress
184
+ ? Native.addListener('onExportProgress', (e) => onProgress(e.progress))
185
+ : null;
186
+
187
+ try {
188
+ const r = await Native.exportVideo(payload);
189
+ return { uri: r.uri, width: r.width, height: r.height, duration: r.durationMs / 1000 };
190
+ } catch (e) {
191
+ throw toEditorError(e);
192
+ } finally {
193
+ sub?.remove();
194
+ }
195
+ }
196
+
197
+ export function cancelExport(): void {
198
+ Native.cancelExport();
199
+ }
@@ -0,0 +1,44 @@
1
+ import type { MusicCatalog, MusicQueryOptions, MusicTrack } from './musicCatalog';
2
+
3
+ export type FeedfmConfig = {
4
+ token: string;
5
+ secret?: string;
6
+ baseUrl?: string;
7
+ };
8
+
9
+ /**
10
+ * Adapter Feed.fm (Feed Clips) — ESQUELETO.
11
+ *
12
+ * A parte que exige credenciais contratadas ainda NÃO está implementada de
13
+ * propósito: sem token/secret real do Feed.fm não há como validar os endpoints.
14
+ * Depois de contratar (feed.fm → Feed Clips / developer access):
15
+ *
16
+ * 1. Preencher `trending` e `search` chamando a Clips API com auth (token +
17
+ * secret, geralmente Basic Auth ou signed request).
18
+ * 2. Mapear a resposta para `MusicTrack` (id, title, artist, previewUrl e
19
+ * audioUrl — a signed URL do clipe que será baixada e mixada no vídeo).
20
+ *
21
+ * Docs: https://www.feed.fm/ (Feed Clips)
22
+ */
23
+ export function createFeedfmCatalog(config: FeedfmConfig): MusicCatalog {
24
+ if (!config.token) {
25
+ throw new Error('Feed.fm: token ausente (EXPO_PUBLIC_MUSIC_TOKEN).');
26
+ }
27
+
28
+ const notImplemented = (): Promise<MusicTrack[]> => {
29
+ return Promise.reject(
30
+ new Error(
31
+ 'Feed.fm Clips: adapter não implementado. Preencher endpoints/auth em feedfmCatalog.ts após contratar (ver PR).'
32
+ )
33
+ );
34
+ };
35
+
36
+ return {
37
+ trending(_opts?: MusicQueryOptions) {
38
+ return notImplemented();
39
+ },
40
+ search(_query: string, _opts?: MusicQueryOptions) {
41
+ return notImplemented();
42
+ },
43
+ };
44
+ }
@@ -0,0 +1,69 @@
1
+ import { EditorError } from '../errors';
2
+
3
+ const BASE = 'https://api.giphy.com/v1';
4
+
5
+ export type GiphyKind = 'gifs' | 'stickers';
6
+
7
+ export type GiphyItem = {
8
+ id: string;
9
+ previewUrl: string;
10
+ originalUrl: string;
11
+ width: number;
12
+ height: number;
13
+ };
14
+
15
+ type GiphyRendition = { url: string; width: string; height: string };
16
+ type GiphyGif = { id: string; images: Record<string, GiphyRendition> };
17
+
18
+ function mapItem(g: GiphyGif): GiphyItem {
19
+ const preview = g.images.fixed_width ?? g.images.downsized ?? g.images.original;
20
+ const original = g.images.original ?? preview;
21
+ return {
22
+ id: g.id,
23
+ previewUrl: preview.url,
24
+ originalUrl: original.url,
25
+ width: Number(original.width) || 1,
26
+ height: Number(original.height) || 1,
27
+ };
28
+ }
29
+
30
+ async function fetchGifs(path: string, params: Record<string, string>): Promise<GiphyItem[]> {
31
+ const url = `${BASE}${path}?${new URLSearchParams(params).toString()}`;
32
+ let res: Response;
33
+ try {
34
+ res = await fetch(url);
35
+ } catch (e) {
36
+ throw new EditorError('GIPHY_NETWORK', 'Giphy request failed', e);
37
+ }
38
+ if (!res.ok) throw new EditorError('GIPHY_NETWORK', `Giphy responded ${res.status}`);
39
+ const json = (await res.json()) as { data: GiphyGif[] };
40
+ return json.data.map(mapItem);
41
+ }
42
+
43
+ export function trendingGifs(
44
+ apiKey: string,
45
+ opts: { offset?: number; limit?: number; kind?: GiphyKind } = {}
46
+ ): Promise<GiphyItem[]> {
47
+ const { offset = 0, limit = 24, kind = 'gifs' } = opts;
48
+ return fetchGifs(`/${kind}/trending`, {
49
+ api_key: apiKey,
50
+ offset: String(offset),
51
+ limit: String(limit),
52
+ rating: 'pg-13',
53
+ });
54
+ }
55
+
56
+ export function searchGifs(
57
+ apiKey: string,
58
+ query: string,
59
+ opts: { offset?: number; limit?: number; kind?: GiphyKind } = {}
60
+ ): Promise<GiphyItem[]> {
61
+ const { offset = 0, limit = 24, kind = 'gifs' } = opts;
62
+ return fetchGifs(`/${kind}/search`, {
63
+ api_key: apiKey,
64
+ q: query,
65
+ offset: String(offset),
66
+ limit: String(limit),
67
+ rating: 'pg-13',
68
+ });
69
+ }
@@ -0,0 +1,18 @@
1
+ import { File, Paths } from 'expo-file-system';
2
+
3
+ function cacheName(url: string): string {
4
+ let h = 2166136261;
5
+ for (let i = 0; i < url.length; i++) {
6
+ h ^= url.charCodeAt(i);
7
+ h = Math.imul(h, 16777619);
8
+ }
9
+ const ext = url.split('?')[0].match(/\.[a-z0-9]{2,4}$/i)?.[0] ?? '';
10
+ return `evdl-${(h >>> 0).toString(36)}${ext}`;
11
+ }
12
+
13
+ export async function downloadToCache(url: string): Promise<string> {
14
+ const dest = new File(Paths.cache, cacheName(url));
15
+ if (dest.exists) return dest.uri;
16
+ const output = await File.downloadFileAsync(url, dest);
17
+ return output.uri;
18
+ }
@@ -0,0 +1,23 @@
1
+ export type MusicTrack = {
2
+ id: string;
3
+ title: string;
4
+ artist?: string;
5
+ durationSec?: number;
6
+ artworkUrl?: string;
7
+ /** URL de prévia (streamável) para o usuário ouvir antes de escolher. */
8
+ previewUrl: string;
9
+ /** URL do arquivo de áudio a ser baixado e sincronizado no vídeo exportado. */
10
+ audioUrl: string;
11
+ };
12
+
13
+ export type MusicQueryOptions = { limit?: number };
14
+
15
+ /**
16
+ * Contrato agnóstico de provedor de música licenciada. Qualquer fonte (Feed.fm,
17
+ * Songclip, backend próprio) implementa esta interface e o editor consome sem
18
+ * saber de qual provedor veio.
19
+ */
20
+ export interface MusicCatalog {
21
+ trending(opts?: MusicQueryOptions): Promise<MusicTrack[]>;
22
+ search(query: string, opts?: MusicQueryOptions): Promise<MusicTrack[]>;
23
+ }
@@ -0,0 +1,200 @@
1
+ import { create } from 'zustand';
2
+
3
+ import {
4
+ IDENTITY_TRANSFORM,
5
+ type AudioSource,
6
+ type AudioTrack,
7
+ type EditorState,
8
+ type Overlay,
9
+ type StickerOverlay,
10
+ type TextOverlay,
11
+ type VideoMeta,
12
+ type VideoSource,
13
+ } from '../types';
14
+
15
+ let overlayCounter = 0;
16
+ const nextOverlayId = () => `ov_${Date.now().toString(36)}_${overlayCounter++}`;
17
+
18
+ export type ActivePanel = 'none' | 'trim' | 'filter' | 'text' | 'giphy' | 'audio';
19
+
20
+ export type EditorStore = {
21
+ source: VideoSource;
22
+ meta: VideoMeta | null;
23
+ trim: { start: number; end: number };
24
+ filter: string;
25
+ overlays: Overlay[];
26
+ audio: AudioTrack | null;
27
+ originalVolume: number;
28
+
29
+ playhead: number;
30
+ paused: boolean;
31
+ activePanel: ActivePanel;
32
+ exporting: boolean;
33
+ exportProgress: number;
34
+ seekTarget: number;
35
+ seekNonce: number;
36
+ previewSize: { width: number; height: number };
37
+ selectedOverlayId: string | null;
38
+ giphyApiKey: string | null;
39
+ audioPicker: (() => Promise<AudioSource | null>) | null;
40
+
41
+ init: (source: VideoSource) => void;
42
+ setMeta: (meta: VideoMeta) => void;
43
+ setTrim: (start: number, end: number) => void;
44
+ setFilter: (key: string) => void;
45
+ setPlayhead: (t: number) => void;
46
+ requestSeek: (t: number) => void;
47
+ setPaused: (paused: boolean) => void;
48
+ togglePlay: () => void;
49
+ setActivePanel: (panel: ActivePanel) => void;
50
+ setExporting: (exporting: boolean) => void;
51
+ setExportProgress: (progress: number) => void;
52
+ setPreviewSize: (width: number, height: number) => void;
53
+ addTextOverlay: () => string;
54
+ addGifOverlay: (item: { previewUri: string; exportUri: string; aspectRatio: number }) => string;
55
+ updateOverlay: (id: string, patch: Partial<Overlay>) => void;
56
+ removeOverlay: (id: string) => void;
57
+ selectOverlay: (id: string | null) => void;
58
+ setGiphyApiKey: (key: string | null) => void;
59
+ setAudioPicker: (picker: (() => Promise<AudioSource | null>) | null) => void;
60
+ setAudioFromSource: (source: AudioSource) => void;
61
+ updateAudio: (patch: Partial<AudioTrack>) => void;
62
+ clearAudio: () => void;
63
+ setOriginalVolume: (v: number) => void;
64
+ getEditorState: () => EditorState;
65
+ };
66
+
67
+ const initialEdit = {
68
+ meta: null as VideoMeta | null,
69
+ trim: { start: 0, end: 0 },
70
+ filter: 'normal',
71
+ overlays: [] as Overlay[],
72
+ audio: null as AudioTrack | null,
73
+ originalVolume: 1,
74
+ };
75
+
76
+ export const useEditorStore = create<EditorStore>((set, get) => ({
77
+ source: { uri: '' },
78
+ ...initialEdit,
79
+
80
+ playhead: 0,
81
+ paused: false,
82
+ activePanel: 'none',
83
+ exporting: false,
84
+ exportProgress: 0,
85
+ seekTarget: 0,
86
+ seekNonce: 0,
87
+ previewSize: { width: 0, height: 0 },
88
+ selectedOverlayId: null,
89
+ giphyApiKey: null,
90
+ audioPicker: null,
91
+
92
+ init: (source) =>
93
+ set({
94
+ source,
95
+ ...initialEdit,
96
+ playhead: 0,
97
+ paused: false,
98
+ activePanel: 'none',
99
+ exporting: false,
100
+ exportProgress: 0,
101
+ seekTarget: 0,
102
+ seekNonce: 0,
103
+ selectedOverlayId: null,
104
+ }),
105
+
106
+ setMeta: (meta) =>
107
+ set((s) => ({
108
+ meta,
109
+ trim: s.trim.end > 0 ? s.trim : { start: 0, end: meta.duration },
110
+ })),
111
+
112
+ setTrim: (start, end) => set({ trim: { start, end } }),
113
+ setFilter: (key) => set({ filter: key }),
114
+ setPlayhead: (t) => set({ playhead: t }),
115
+ requestSeek: (t) => set((s) => ({ seekTarget: t, seekNonce: s.seekNonce + 1, playhead: t })),
116
+ setPaused: (paused) => set({ paused }),
117
+ togglePlay: () => set((s) => ({ paused: !s.paused })),
118
+ setActivePanel: (activePanel) => set({ activePanel }),
119
+ setExporting: (exporting) => set({ exporting }),
120
+ setExportProgress: (exportProgress) => set({ exportProgress }),
121
+ setPreviewSize: (width, height) => set({ previewSize: { width, height } }),
122
+
123
+ addTextOverlay: () => {
124
+ const id = nextOverlayId();
125
+ const overlay: TextOverlay = {
126
+ id,
127
+ type: 'text',
128
+ text: '',
129
+ fontSize: 32,
130
+ color: '#ffffff',
131
+ align: 'center',
132
+ transform: { ...IDENTITY_TRANSFORM },
133
+ };
134
+ set((s) => ({
135
+ overlays: [...s.overlays, overlay],
136
+ selectedOverlayId: id,
137
+ activePanel: 'text',
138
+ }));
139
+ return id;
140
+ },
141
+ addGifOverlay: (item) => {
142
+ const id = nextOverlayId();
143
+ const overlay: StickerOverlay = {
144
+ id,
145
+ type: 'gif',
146
+ previewUri: item.previewUri,
147
+ exportUri: item.exportUri,
148
+ aspectRatio: item.aspectRatio > 0 ? item.aspectRatio : 1,
149
+ transform: { ...IDENTITY_TRANSFORM },
150
+ };
151
+ set((s) => ({
152
+ overlays: [...s.overlays, overlay],
153
+ selectedOverlayId: id,
154
+ activePanel: 'none',
155
+ }));
156
+ return id;
157
+ },
158
+ updateOverlay: (id, patch) =>
159
+ set((s) => ({
160
+ overlays: s.overlays.map((o) => (o.id === id ? ({ ...o, ...patch } as Overlay) : o)),
161
+ })),
162
+ removeOverlay: (id) =>
163
+ set((s) => ({
164
+ overlays: s.overlays.filter((o) => o.id !== id),
165
+ selectedOverlayId: s.selectedOverlayId === id ? null : s.selectedOverlayId,
166
+ })),
167
+ selectOverlay: (selectedOverlayId) => set({ selectedOverlayId }),
168
+ setGiphyApiKey: (giphyApiKey) => set({ giphyApiKey }),
169
+
170
+ setAudioPicker: (audioPicker) => set({ audioPicker }),
171
+ setAudioFromSource: (source) =>
172
+ set({
173
+ audio: {
174
+ uri: source.uri,
175
+ title: source.title,
176
+ duration: source.duration && source.duration > 0 ? source.duration : 0,
177
+ trimStart: 0,
178
+ trimEnd: source.duration && source.duration > 0 ? source.duration : 0,
179
+ volume: 1,
180
+ loop: true,
181
+ },
182
+ activePanel: 'audio',
183
+ }),
184
+ updateAudio: (patch) => set((s) => ({ audio: s.audio ? { ...s.audio, ...patch } : s.audio })),
185
+ clearAudio: () => set({ audio: null, activePanel: 'none' }),
186
+ setOriginalVolume: (originalVolume) => set({ originalVolume }),
187
+
188
+ getEditorState: () => {
189
+ const s = get();
190
+ return {
191
+ source: s.source,
192
+ meta: s.meta,
193
+ trim: s.trim,
194
+ filter: s.filter,
195
+ overlays: s.overlays,
196
+ audio: s.audio,
197
+ originalVolume: s.originalVolume,
198
+ };
199
+ },
200
+ }));
package/src/theme.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ export type EditorTheme = {
4
+ background: string;
5
+ surface: string;
6
+ accent: string;
7
+ text: string;
8
+ textMuted: string;
9
+ danger: string;
10
+ };
11
+
12
+ export const defaultTheme: EditorTheme = {
13
+ background: '#0b0b0f',
14
+ surface: '#1c1c26',
15
+ accent: '#7c5cff',
16
+ text: '#ffffff',
17
+ textMuted: '#8a8a99',
18
+ danger: '#ff5c7c',
19
+ };
20
+
21
+ export function mergeTheme(override?: Partial<EditorTheme>): EditorTheme {
22
+ return override ? { ...defaultTheme, ...override } : defaultTheme;
23
+ }
24
+
25
+ export const EditorThemeContext = createContext<EditorTheme>(defaultTheme);
26
+ export const useTheme = (): EditorTheme => useContext(EditorThemeContext);