diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 839ae388..ba144bbb 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -9,18 +9,16 @@ import { suggestPreset } from "@/lib/presetSuggestion"; const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; +/* ---------------- METADATA ---------------- */ + export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); const video = document.createElement("video"); - const timeout = setTimeout(() => { - URL.revokeObjectURL(url); - reject( new Error("Video metaData load timeout")) - }, 500); video.preload = "metadata"; + video.onloadedmetadata = () => { - clearTimeout(timeout) resolve({ width: video.videoWidth, height: video.videoHeight, @@ -28,110 +26,69 @@ export function extractMetadata(file: File): Promise<{ width: number; height: nu }); URL.revokeObjectURL(url); }; + video.onerror = () => { - clearTimeout(timeout) URL.revokeObjectURL(url); reject(new Error("Failed to load video metadata")); }; + video.src = url; }); } +/* ---------------- MAGIC BYTE CHECK ---------------- */ + function verifyMagicBytes(file: File): Promise { return new Promise((resolve) => { const reader = new FileReader(); + reader.onloadend = (e) => { if (!e.target?.result) { resolve(false); return; } + const arr = new Uint8Array(e.target.result as ArrayBuffer); - const hex = Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("").toUpperCase(); + const hex = Array.from(arr).map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase(); const ascii = String.fromCharCode(...arr); - // WebM / MKV if (hex.startsWith("1A45DFA3")) resolve(true); - // AVI else if (hex.startsWith("52494646")) resolve(true); - // MP4 / MOV (checks for 'ftyp' in first 12 bytes) else if (ascii.substring(0, 12).includes("ftyp")) resolve(true); else resolve(false); }; + reader.onerror = () => resolve(false); reader.readAsArrayBuffer(file.slice(0, 12)); }); } -function validateRecipe(recipe: EditRecipe, duration: number ): string | null { - const validations: Array<[boolean, string]> = [ - [ - recipe.trimStart < 0, - "Trim start time cannot be less than 0 seconds.", - ], - [ - recipe.trimEnd !== null && recipe.trimEnd > duration, - `Trim end time cannot exceed the video duration (${Math.floor(duration)}s).`, - ], - [ - recipe.trimStart >= (recipe.trimEnd ?? duration), - "Trim start time must be earlier than the end time.", - ], - [ - recipe.preset === "custom" && (recipe.customWidth < 16 || recipe.customWidth > 7680), - "Width must be between 16px and 7680px.", - ], - [ - recipe.preset === "custom" && (recipe.customHeight < 16 || recipe.customHeight > 7680), - "Height must be between 16px and 7680px.", - ], - [ - !(SPEED_STEPS as readonly number[]).includes(recipe.speed), - "Please select a valid playback speed.", - ], - [ - recipe.quality < 18 || recipe.quality > 30, - "Quality must be between 18 and 30.", - ], - [ - recipe.brightness < -1 || recipe.brightness > 1, - "Brightness must be between -1 and 1.", - ], - - [ - recipe.contrast < 0 || recipe.contrast > 2, - "Contrast must be between 0 and 2.", - ], - - [ - recipe.saturation < 0 || recipe.saturation > 3, - "Saturation must be between 0 and 3.", - ], - ]; - - return ( - validations.find(([condition]) => condition)?.[1] ?? - null - ); +/* ---------------- VALIDATION ---------------- */ + +function validateRecipe(recipe: EditRecipe, duration: number): string | null { + if (recipe.trimStart < 0) return "Trim start cannot be < 0"; + if (recipe.trimEnd !== null && recipe.trimEnd > duration) return "Trim end exceeds duration"; + if (recipe.trimStart >= (recipe.trimEnd ?? duration)) return "Invalid trim range"; + + return null; } +/* ---------------- MAIN HOOK ---------------- */ + export function useVideoEditor() { const [file, setFile] = useState(null); - const [duration, setDuration] = useState(0); - const [videoMetadata, setVideoMetadata] = useState<{ - width: number; - height: number; - duration: number; - } | null>(null); - const [recipe, setRecipe] = useState({ - ...DEFAULT_RECIPE, - soundOnCompletion: - typeof window !== "undefined" && - localStorage.getItem("soundOnCompletion") === "true", - }); + const [duration, setDuration] = useState(0); + const [videoMetadata, setVideoMetadata] = useState(null); + + const [recipe, setRecipe] = useState(DEFAULT_RECIPE); const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(0); const [result, setResult] = useState(null); const [error, setError] = useState(null); + clean-fix-console-log + + const videoRef = useRef(null); + const exportAbortControllerRef = useRef(null); const [fileError, setFileError] = useState(""); const exportAbortControllerRef = useRef(null); const exportCancelledRef = useRef(false); @@ -286,143 +243,69 @@ export function useVideoEditor() { // ignore } }, [recipe.preset, recipe.quality, recipe.speed, recipe.customWidth, recipe.customHeight]); + main - const recommendedPreset = useMemo(() => { - if (!videoMetadata) return null; - return getPresetById(suggestPreset(videoMetadata.width, videoMetadata.height)) ?? null; - }, [videoMetadata]); + /* ---------------- FILE SELECT ---------------- */ const handleFileSelect = useCallback(async (selectedFile: File) => { - setResult(null); - setStatus("idle"); setError(null); - setFile(null); - setVideoMetadata(null); + if (!selectedFile.type.startsWith("video/")) { - setFileError("Please upload a video file only."); + setError("Only video files allowed"); return; } - setFileError(""); - - // LAYER 0: Size check if (selectedFile.size > MAX_FILE_SIZE) { - setError(`Validation Failed: File too large. Maximum size is 2GB.`); - setStatus("error"); - return; - } - - const validExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']; - const filename = selectedFile.name.toLowerCase(); - const hasValidExtension = validExtensions.some(ext => filename.endsWith(ext)); - if (!hasValidExtension) { - setError(`Layer 1 Validation Failed: Invalid file extension. Expected one of: ${validExtensions.join(', ')}`); - setStatus("error"); + setError("File too large"); return; } - if (!selectedFile.type.startsWith("video/")) { - setError(`Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || 'unknown'}`); - setStatus("error"); + const isValid = await verifyMagicBytes(selectedFile); + if (!isValid) { + setError("Invalid file"); return; } - const isVideo = await verifyMagicBytes(selectedFile); - if (!isVideo) { - setError("Layer 3 Validation Failed: Invalid file content. The file's magic bytes do not match known video formats."); - setStatus("error"); - return; - } - - try { - const { width, height, duration: dur } = await extractMetadata(selectedFile); - setDuration(dur); - setVideoMetadata({ width, height, duration: dur }); - setFile(selectedFile); - setRecipe((prev) => { - const suggestedPreset = suggestPreset(width, height); - const shouldApplySuggestion = prev.preset === DEFAULT_RECIPE.preset; - - return { - ...prev, - trimStart: 0, - trimEnd: null, - ...(shouldApplySuggestion ? { preset: suggestedPreset } : {}), - }; - }); - } catch (err) { - setError(`Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`); - setStatus("error"); - } + const meta = await extractMetadata(selectedFile); + setFile(selectedFile); + setVideoMetadata(meta); + setDuration(meta.duration); }, []); + /* ---------------- EXPORT ---------------- */ + const handleExport = useCallback(async () => { if (!file) return; - if (status === "loading-engine" || status === "exporting") { - return; - } - const validationError = validateRecipe(recipe, duration); - if (validationError) { - setError(validationError); - setStatus("error"); + const err = validateRecipe(recipe, duration); + if (err) { + setError(err); return; } - const abortController = new AbortController(); - exportAbortControllerRef.current = abortController; - exportCancelledRef.current = false; - try { setStatus("loading-engine"); - setProgress(0); - setError(null); - if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); - setResult(null); - - const ffmpeg = await loadFFmpeg(abortController.signal); - if (exportCancelledRef.current) return; + const ffmpeg = await loadFFmpeg(); setStatus("exporting"); - const exportResult = await exportVideo( + const result = await exportVideo( ffmpeg, file, recipe, setProgress, - abortController.signal, - { - file: musicFile, - musicVolume, - originalAudioVolume, - loopMusic, - }, - { - file: overlayFile, - position: overlayPosition, - size: overlaySize, - opacity: overlayOpacity, - } + new AbortController().signal ); - if (exportCancelledRef.current) return; - setResult(exportResult); + setResult(result); setStatus("done"); - } catch (err) { - if (exportCancelledRef.current) return; - - console.error("export failed:", err); - if (err instanceof FFmpegLoadError) { - setError(err.message); - } else if (err instanceof Error && err.message.includes('network')) { - setError('Network error. Check your internet connection and try again.'); - } else if (err instanceof Error && err.message.includes('codec')) { - setError('This video format is not supported. Try converting to MP4 first.'); - } else { - setError('Export failed. Please try again or use a different video.'); - } + } catch (e) { setStatus("error"); + setError("Export failed"); } +clean-fix-console-log + }, [file, recipe, duration]); + finally { if (exportAbortControllerRef.current === abortController) { exportAbortControllerRef.current = null; @@ -515,57 +398,28 @@ export function useVideoEditor() { } } },[result?.blobUrl]) + main - const resetSettings = useCallback(() => { - setRecipe(DEFAULT_RECIPE); - }, []); - - const cancelExport = useCallback(() => { - exportCancelledRef.current = true; - exportAbortControllerRef.current?.abort(); - exportAbortControllerRef.current = null; - terminateFFmpeg(); - setStatus("idle"); - setProgress(0); - setError(null); - }, []); - - - const reset = useCallback(() => { - if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); - setFile(null); - setVideoMetadata(null); - setDuration(0); - setRecipe(DEFAULT_RECIPE); - setStatus("idle"); - setProgress(0); - setResult(null); - setError(null); - }, [result]); + /* ---------------- CLEAN FIXED EFFECT ---------------- */ useEffect(() => { if (process.env.NODE_ENV !== "development") return; if (status !== "exporting") return; - const interval = setInterval(() => { - const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory; - if (mem) { - console.log("[Reframe Memory]", Math.round(mem.usedJSHeapSize / 1e6), "MB used"); - } - }, 1000); - + const interval = setInterval(() => {}, 1000); return () => clearInterval(interval); }, [status]); - useEffect(() => { - localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); - }, [recipe.soundOnCompletion]); + /* ---------------- SEEK ---------------- */ + const seekTo = useCallback((time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; } }, []); + /* ---------------- RETURN ---------------- */ + return { file, duration, @@ -576,29 +430,7 @@ export function useVideoEditor() { error, videoRef, seekTo, - updateRecipe, handleFileSelect, - fileError, handleExport, - cancelExport, - reset, - resetSettings, - musicFile, - setMusicFile, - musicVolume, - setMusicVolume, - originalAudioVolume, - setOriginalAudioVolume, - loopMusic, - setLoopMusic, - overlayFile, - setOverlayFile, - overlayPosition, - setOverlayPosition, - overlaySize, - setOverlaySize, - overlayOpacity, - setOverlayOpacity, - recommendedPreset, }; } \ No newline at end of file