'use client'; import { AnimatePresence, motion } from 'framer-motion'; import { nanoid } from 'nanoid'; import { useEffect, useRef, useState } from 'react'; import { FILTER_PRESETS } from '../../constants'; import { useFocusTrap } from '../../hooks/useFocusTrap'; import { editFilterCss } from '../../lib/edits'; import { summarizeEdits } from '../../lib/versions'; import { bakeVideo } from '../../lib/videoBake'; import { blobToWavBase64, wavBase64ToBlob } from '../../lib/audioCapture'; import { useAIProvider } from '../aiContext'; import { VoiceButton } from './VoiceButton'; import { normalizeSegments, outputDuration, sampleOverlay, sourceToOutputTime, } from '../../lib/videoTimeline'; import { Icon, type IconName } from '../../icons'; import { useGallery, useGalleryStoreApi } from '../../store/context'; import type { EditState, MediaId, MediaItem, VideoOverlay, VideoSegment } from '../../types'; import { Annotations, type AnnotationTool } from './Annotations'; import { addToAlbumPicker, confirmAction } from '../modals'; type Tab = 'trim' | 'crop' | 'overlay' | 'filters' | 'adjust' | 'markup' | 'audio' | 'export'; const TABS: Tab[] = ['trim', 'crop', 'overlay', 'filters', 'adjust', 'markup', 'audio', 'export']; const TAB_LABEL: Record = { trim: 'Trim & Split', crop: 'Crop & Rotate', overlay: 'Overlays & Text', filters: 'Filters', adjust: 'Adjust', markup: 'Markup', audio: 'Audio', export: 'Export', }; const ANN_TOOLS: Array<{ tool: AnnotationTool; icon: IconName; label: string }> = [ { tool: 'rect', icon: 'crop', label: 'Rectangle' }, { tool: 'ellipse', icon: 'info', label: 'Oval' }, { tool: 'arrow', icon: 'chevron-right', label: 'Arrow' }, { tool: 'double-arrow', icon: 'aspect', label: 'Measure' }, { tool: 'text', icon: 'tag', label: 'Text' }, { tool: 'freehand', icon: 'adjust', label: 'Draw' }, ]; const COLORS = ['#ff3b30', '#ffcc00', '#34c759', '#0a84ff', '#ffffff', '#000000']; const CROP_PRESETS: Array<{ label: string; ratio: number | null }> = [ { label: 'Original', ratio: null }, { label: '1:1', ratio: 1 }, { label: '16:9', ratio: 16 / 9 }, { label: '9:16', ratio: 9 / 16 }, { label: '4:3', ratio: 4 / 3 }, ]; const QUALITIES: Array<{ label: string; maxDim: number }> = [ { label: '480p', maxDim: 854 }, { label: '720p', maxDim: 1280 }, { label: '1080p', maxDim: 1920 }, ]; function blobToDataUrl(blob: Blob): Promise { return new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result as string); fr.onerror = rej; fr.readAsDataURL(blob); }); } const fmt = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}.${Math.floor((s % 1) * 10)}`; /** Centered crop rect for an aspect ratio within a WxH frame (fractions 0..1). */ function centeredCrop(ratio: number | null, aspect: number) { if (ratio == null) return { x: 0, y: 0, width: 1, height: 1 }; if (ratio > aspect) { const h = aspect / ratio; return { x: 0, y: (1 - h) / 2, width: 1, height: h }; } const w = ratio / aspect; return { x: (1 - w) / 2, y: 0, width: w, height: 1 }; } export function VideoEditor() { const api = useGalleryStoreApi(); const editorId = useGallery((s) => s.editorId); const media = useGallery((s) => s.media); const item = media.find((m) => m.id === editorId) ?? null; const [tab, setTab] = useState('trim'); const [edits, setEdits] = useState({ adjustments: {} }); const [annTool, setAnnTool] = useState('rect'); const [annColor, setAnnColor] = useState('#ff3b30'); const [duration, setDuration] = useState(0); const [playhead, setPlayhead] = useState(0); const [playing, setPlaying] = useState(false); const [selOverlay, setSelOverlay] = useState(null); const provider = useAIProvider(); const [denoiseBusy, setDenoiseBusy] = useState(false); const [denoiseErr, setDenoiseErr] = useState(null); const [previewH, setPreviewH] = useState(360); const [baking, setBaking] = useState(false); const [progress, setProgress] = useState(0); const [exportError, setExportError] = useState(null); const dialogRef = useRef(null); const videoRef = useRef(null); const annoWrapRef = useRef(null); const overlayFileRef = useRef(null); const watermarkFileRef = useRef(null); const musicFileRef = useRef(null); const cancelRef = useRef<() => void>(() => api.getState().closeEditor()); useFocusTrap(dialogRef, Boolean(item) && item?.kind === 'video', () => cancelRef.current()); // Reset when a different video opens. useEffect(() => { setEdits(item?.edits ? { ...item.edits } : { adjustments: {} }); setTab('trim'); setProgress(0); setPlayhead(0); setSelOverlay(null); setExportError(null); }, [item?.id]); // eslint-disable-line react-hooks/exhaustive-deps // Track the preview box height (drives text-overlay font sizing). useEffect(() => { const el = annoWrapRef.current; if (!el || typeof ResizeObserver === 'undefined') return; const ro = new ResizeObserver(() => setPreviewH(el.clientHeight || 360)); ro.observe(el); return () => ro.disconnect(); }, [item?.id]); // Keep preview playback within the first→last segment span; track the playhead. useEffect(() => { const v = videoRef.current; if (!v) return; const onTime = () => { setPlayhead(v.currentTime); const segs = edits.segments && edits.segments.length ? edits.segments : null; const lo = segs ? segs[0]!.start : (edits.trim?.start ?? 0); const hi = segs ? segs[segs.length - 1]!.end : (edits.trim?.end ?? (duration || v.duration)); if (v.currentTime >= hi) v.currentTime = lo; }; // The preview