import { useState, useRef, useEffect, useCallback } from 'react'; import { useQuery, useMutation } from '@tanstack/react-query'; import { X, Save, Film, Play, Pause, Scissors, Gauge, Music, Upload, Trash2, Volume2, VolumeX, Loader2, } from 'lucide-react'; import { Button } from './Button'; import { api } from '../api/client'; import { useToast } from '../contexts/ToastContext'; import { formatMediaTime } from '../utils/date'; interface TimelapseEditorModalProps { archiveId: number; timelapseSrc: string; onClose: () => void; onSave?: () => void; } const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 4]; export function TimelapseEditorModal({ archiveId, timelapseSrc, onClose, onSave, }: TimelapseEditorModalProps) { const { showToast } = useToast(); const videoRef = useRef(null); const audioRef = useRef(null); // Video state const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); // Editor state const [trimStart, setTrimStart] = useState(0); const [trimEnd, setTrimEnd] = useState(0); const [speed, setSpeed] = useState(1); const [audioFile, setAudioFile] = useState(null); const [audioUrl, setAudioUrl] = useState(null); const [audioVolume, setAudioVolume] = useState(0.8); const [audioMuted, setAudioMuted] = useState(false); // Fetch video info const { data: videoInfo, isLoading: isLoadingInfo } = useQuery({ queryKey: ['timelapse-info', archiveId], queryFn: () => api.getTimelapseInfo(archiveId), }); // Fetch thumbnails const { data: thumbnailData } = useQuery({ queryKey: ['timelapse-thumbnails', archiveId], queryFn: () => api.getTimelapseThumbnails(archiveId, 15), }); // Process mutation const processMutation = useMutation({ mutationFn: () => api.processTimelapse( archiveId, { trimStart, trimEnd, speed, saveMode: 'replace', }, audioFile || undefined ), onSuccess: (data) => { showToast(data.message, 'success'); onSave?.(); onClose(); }, onError: (error: Error) => { showToast(error.message || 'Processing failed', 'error'); }, }); // Initialize trimEnd when duration is available useEffect(() => { if (videoInfo?.duration && trimEnd === 0) { setTrimEnd(videoInfo.duration); } }, [videoInfo?.duration, trimEnd]); // Close on Escape useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose]); // Video event handlers useEffect(() => { const video = videoRef.current; if (!video) return; const handleTimeUpdate = () => { const time = video.currentTime; setCurrentTime(time); // Loop within trim region if (time >= trimEnd) { video.currentTime = trimStart; } }; const handleDurationChange = () => { setDuration(video.duration); if (trimEnd === 0) { setTrimEnd(video.duration); } }; const handlePlay = () => setIsPlaying(true); const handlePause = () => setIsPlaying(false); video.addEventListener('timeupdate', handleTimeUpdate); video.addEventListener('durationchange', handleDurationChange); video.addEventListener('play', handlePlay); video.addEventListener('pause', handlePause); return () => { video.removeEventListener('timeupdate', handleTimeUpdate); video.removeEventListener('durationchange', handleDurationChange); video.removeEventListener('play', handlePlay); video.removeEventListener('pause', handlePause); }; }, [trimStart, trimEnd]); // Sync audio with video useEffect(() => { const audio = audioRef.current; const video = videoRef.current; if (!audio || !video || !audioUrl) return; audio.currentTime = video.currentTime; audio.playbackRate = video.playbackRate; if (isPlaying && !audioMuted) { audio.play().catch(() => {}); } else { audio.pause(); } }, [isPlaying, audioUrl, audioMuted]); // Update audio volume useEffect(() => { if (audioRef.current) { audioRef.current.volume = audioMuted ? 0 : audioVolume; } }, [audioVolume, audioMuted]); // Update playback rate useEffect(() => { if (videoRef.current) { videoRef.current.playbackRate = speed; } if (audioRef.current) { audioRef.current.playbackRate = speed; } }, [speed]); const togglePlay = useCallback(() => { const video = videoRef.current; if (!video) return; if (isPlaying) { video.pause(); } else { // Start from trim start if before it if (video.currentTime < trimStart) { video.currentTime = trimStart; } video.play(); } }, [isPlaying, trimStart]); const handleSeek = (time: number) => { const video = videoRef.current; if (!video) return; video.currentTime = Math.max(trimStart, Math.min(trimEnd, time)); }; const handleAudioUpload = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // Cleanup previous URL if (audioUrl) { URL.revokeObjectURL(audioUrl); } setAudioFile(file); setAudioUrl(URL.createObjectURL(file)); }; const removeAudio = () => { if (audioUrl) { URL.revokeObjectURL(audioUrl); } setAudioFile(null); setAudioUrl(null); }; // Cleanup on unmount useEffect(() => { return () => { if (audioUrl) { URL.revokeObjectURL(audioUrl); } }; }, [audioUrl]); const trimmedDuration = trimEnd - trimStart; const outputDuration = trimmedDuration / speed; if (isLoadingInfo) { return (
Loading video info...
); } return (
{/* Header */}

Edit Timelapse

{/* Content */}
{/* Video Preview */}
{/* Timeline with Thumbnails */}
Trim {formatMediaTime(trimStart)} - {formatMediaTime(trimEnd)} ({formatMediaTime(trimmedDuration)})
{/* Thumbnail strip */}
{/* Thumbnails background */}
{thumbnailData?.thumbnails.map((thumb, i) => (
))}
{/* Trim overlay - grayed out areas */}
{/* Selected region border */}
{/* Current time indicator */}
{/* Trim handles */} { const val = parseFloat(e.target.value); if (val < trimEnd - 1) { setTrimStart(val); if (videoRef.current && videoRef.current.currentTime < val) { videoRef.current.currentTime = val; } } }} className="absolute inset-0 w-full opacity-0 cursor-ew-resize" style={{ clipPath: 'inset(0 50% 0 0)' }} /> { const val = parseFloat(e.target.value); if (val > trimStart + 1) { setTrimEnd(val); } }} className="absolute inset-0 w-full opacity-0 cursor-ew-resize" style={{ clipPath: 'inset(0 0 0 50%)' }} />
{/* Playback scrubber */} handleSeek(parseFloat(e.target.value))} className="w-full h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:cursor-pointer" /> {/* Play controls */}
{/* Speed Control */}
Speed {speed}x (output: {formatMediaTime(outputDuration)})
{SPEED_OPTIONS.map((s) => ( ))}
{/* Audio Upload */}
Music Overlay
{audioFile ? (

{audioFile.name}

{(audioFile.size / 1024 / 1024).toFixed(1)} MB

{/* Volume control */} setAudioVolume(parseFloat(e.target.value))} className="w-20 h-1 bg-bambu-dark-tertiary rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-bambu-green [&::-webkit-slider-thumb]:rounded-full" />
) : ( )}
{/* Summary */}

Original: {formatMediaTime(duration)} @ {videoInfo?.width}x{videoInfo?.height}

Output: {formatMediaTime(outputDuration)} @ {speed}x speed {audioFile && ` + music overlay`}

{/* Processing overlay */} {processMutation.isPending && (

Processing timelapse...

This may take a few moments

)}
); }