diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index e83216e0..2b0a4874 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -17,13 +17,22 @@ import FormatSelector from "./FormatSelector"; import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; -import ImageOverlay from "./ImageOverlay" +import ImageOverlay from "./ImageOverlay"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; import { - Layers, Crop, Scissors, RotateCw, Volume2, Type, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + Layers, + Crop, + Scissors, + RotateCw, + Volume2, + Type, + SlidersHorizontal, + Zap, + AlertTriangle, + Github, + Copy, } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -38,10 +47,7 @@ interface SectionProps { function Section({ icon, title, children, delay = 0 }: SectionProps) { return ( -
+
{icon}

@@ -82,8 +88,12 @@ function AccordionSection({ className="w-full flex items-center justify-between px-3 py-2 text-left hover:bg-[var(--border)] transition-all duration-200 group rounded-lg" >
- {icon} - {title} + + {icon} + + + {title} +
{children}
@@ -124,45 +140,55 @@ function KeyboardShortcutsPanel() { const [open, setOpen] = useState(false); const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { - keys: [ - Ctrl, - +, - Shift, - +, - E - ], - label: "Export video", - }, - { - keys: [M], - label: "Toggle audio mute", - }, - { - keys: [R], - label: "Reset all settings", - }, - { - keys: [Esc], - label: "Cancel export", - }, - { - keys: [1, , 9], - label: "Switch preset by index", - }, - { - keys: [?], - label: "Toggle this panel", - }, - { - keys: [I], - label: "Set trim in point", - }, - { - keys: [O], - label: "Set trim out point", - }, -]; + { + keys: [ + Ctrl, + + + + , + Shift, + + + + , + E, + ], + label: "Export video", + }, + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [R], + label: "Reset all settings", + }, + { + keys: [Esc], + label: "Cancel export", + }, + { + keys: [ + 1, + + – + , + 9, + ], + label: "Switch preset by index", + }, + { + keys: [?], + label: "Toggle this panel", + }, + { + keys: [I], + label: "Set trim in point", + }, + { + keys: [O], + label: "Set trim out point", + }, + ]; return (
@@ -183,9 +209,18 @@ function KeyboardShortcutsPanel() { height="12" viewBox="0 0 12 12" fill="none" - className={cn("text-[var(--muted)] transition-transform duration-200", open && "rotate-180")} + className={cn( + "text-[var(--muted)] transition-transform duration-200", + open && "rotate-180" + )} > - + @@ -208,15 +243,31 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, exportStartedAt, updateRecipe, - handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, + file, + duration, + recipe, + status, + progress, + result, + error, + exportStartedAt, + updateRecipe, + handleFileSelect, + fileError, + handleExport, + cancelExport, + reset, + resetSettings, videoRef, seekTo, - overlayFile, setOverlayFile, - overlayPosition, setOverlayPosition, - overlaySize, setOverlaySize, - overlayOpacity, setOverlayOpacity, + overlayFile, + setOverlayFile, + overlayPosition, + setOverlayPosition, + overlaySize, + setOverlaySize, + overlayOpacity, + setOverlayOpacity, recommendedPreset, currentTime, toggleSound, @@ -288,7 +339,7 @@ export default function VideoEditor() { const handleCopyLink = () => { if (typeof window === "undefined") return; - const encoded = btoa(JSON.stringify(recipe)); + const encoded = encodeURIComponent(JSON.stringify(recipe)); const url = new URL(window.location.href); url.searchParams.set("settings", encoded); history.replaceState(null, "", url.toString()); @@ -300,7 +351,6 @@ export default function VideoEditor() { useEffect(() => { if (status === "done" && downloadRef.current) { - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; downloadRef.current.scrollIntoView({ behavior: prefersReducedMotion ? "instant" : "smooth", @@ -309,7 +359,6 @@ export default function VideoEditor() { } }, [status]); - const isProcessing = status === "loading-engine" || status === "exporting"; const [isMac, setIsMac] = useState(false); @@ -324,23 +373,19 @@ export default function VideoEditor() { return 30; }, [duration]); - const videoSrc = useMemo( - () => (file ? URL.createObjectURL(file) : null), - [file] - ); + const videoSrc = useMemo(() => (file ? URL.createObjectURL(file) : null), [file]); const exportSummary = useMemo(() => { const preset = getPresetById(recipe.preset); - const width = recipe.preset === "custom" ? recipe.customWidth : (preset?.width ?? recipe.customWidth); - const height = recipe.preset === "custom" ? recipe.customHeight : (preset?.height ?? recipe.customHeight); + const width = + recipe.preset === "custom" ? recipe.customWidth : (preset?.width ?? recipe.customWidth); + const height = + recipe.preset === "custom" ? recipe.customHeight : (preset?.height ?? recipe.customHeight); const framingLabel = recipe.framing === "fit" ? "Fit" : "Fill"; const speedLabel = `${recipe.speed}× speed`; - const qualityLabel = recipe.quality <= 21 - ? "High" - : recipe.quality <= 25 - ? "Balanced" - : "Small file"; + const qualityLabel = + recipe.quality <= 21 ? "High" : recipe.quality <= 25 ? "Balanced" : "Small file"; return `Exporting to ${width}×${height} ${recipe.format.toUpperCase()} • ${framingLabel} • ${speedLabel} • Quality: ${qualityLabel}`; }, [recipe]); @@ -367,58 +412,67 @@ export default function VideoEditor() { {status === "error" && `Export failed: ${error}`}
-
+
-
-

- REFRAME -

-

- Your video, any format -

-
- - No login. No ads. 100% private. -
-
-
- - No login. No ads. 100% private - your video never leaves your device. -
-
+
+

+ REFRAME +

+

+ Your video, any format +

+
+ + No login. No ads. 100% private. +
+
+
+ + No login. No ads. 100% private - your video never leaves your device. +
+
-
{!file && }
- + {!file && (
@@ -459,10 +513,12 @@ export default function VideoEditor() {

)} {file && ( -
+
-
} - title="Adjustments" - delay={175} - > +
} title="Adjustments" delay={175}>
{/* Brightness */}
@@ -667,12 +719,15 @@ export default function VideoEditor() {
-
+
{!file && (

@@ -712,7 +774,10 @@ export default function VideoEditor() {

)} -
+
} @@ -724,7 +789,9 @@ export default function VideoEditor() { {recommendedPreset && (

- We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended: {(recommendedPreset.platform.split("·")[0] ?? "").trim()} ({recommendedPreset.label.replace(/\s/g, "")}) + We detected a {recommendedPreset.label.replace(/\s/g, "")} video → + Recommended: {(recommendedPreset.platform.split("·")[0] ?? "").trim()} ( + {recommendedPreset.label.replace(/\s/g, "")})

)} @@ -765,10 +832,10 @@ export default function VideoEditor() { id="export-button" type="button" onClick={handleExport} - disabled={!file || isProcessing} - aria-label='Export video' - aria-disabled={!file || isProcessing ? "true" : undefined} - title={!file ? "Upload a video to enable export" : undefined} + disabled={!file || isProcessing} + aria-label="Export video" + aria-disabled={!file || isProcessing ? "true" : undefined} + title={!file ? "Upload a video to enable export" : undefined} className={cn( // Hidden on mobile — replaced by the fixed bottom bar below. "w-full hidden lg:flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl", @@ -778,7 +845,7 @@ export default function VideoEditor() { : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed" )} > - + {isProcessing ? "PROCESSING" : "EXPORT"} diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index ab67dcf8..6a9952d7 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,7 +1,16 @@ "use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; -import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, TimelineTrack, MultiTrackEditorState, isValidRecipe } from "@/lib/types"; +import { + EditRecipe, + ExportResult, + ExportStatus, + MAX_FILE_SIZE, + OverlayPosition, + TimelineTrack, + MultiTrackEditorState, + isValidRecipe, +} from "@/lib/types"; import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants"; import { getPresetById } from "@/lib/presets"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; @@ -28,18 +37,24 @@ import { saveSessionFile, loadSessionFile, clearSessionFile } from "@/lib/sessio const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; -export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> { +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 — the file may be too large or the device too slow. Please try again.") ); + reject( + new Error( + "Video metaData load timeout — the file may be too large or the device too slow. Please try again." + ) + ); }, 5000); video.preload = "metadata"; video.onloadedmetadata = () => { - clearTimeout(timeout) + clearTimeout(timeout); resolve({ width: video.videoWidth, height: video.videoHeight, @@ -48,7 +63,7 @@ export function extractMetadata(file: File): Promise<{ width: number; height: nu URL.revokeObjectURL(url); }; video.onerror = () => { - clearTimeout(timeout) + clearTimeout(timeout); URL.revokeObjectURL(url); reject(new Error("Failed to load video metadata")); }; @@ -65,7 +80,10 @@ function verifyMagicBytes(file: File): Promise { 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 @@ -81,71 +99,63 @@ function verifyMagicBytes(file: File): Promise { }); } -function validateRecipe(recipe: EditRecipe, duration: number ): string | null { +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.trimStart < 0, "Trim start time cannot be less than 0 seconds."], [ recipe.trimEnd !== null && duration > 0 && recipe.trimEnd > duration, `Trim end time cannot exceed the video duration (${Math.floor(duration)}s).`, ], [ - recipe.trimEnd !== null - ? recipe.trimStart >= recipe.trimEnd - : (duration > 0 && recipe.trimStart >= duration), + recipe.trimEnd !== null + ? recipe.trimStart >= recipe.trimEnd + : duration > 0 && recipe.trimStart >= duration, "Trim start time must be earlier than the end time.", ], [ - recipe.preset === "custom" && (Number.isNaN(recipe.customWidth) || recipe.customWidth < 16 || recipe.customWidth > 7680), + recipe.preset === "custom" && + (Number.isNaN(recipe.customWidth) || recipe.customWidth < 16 || recipe.customWidth > 7680), "Width must be between 16px and 7680px.", ], [ - recipe.preset === "custom" && (Number.isNaN(recipe.customHeight) || recipe.customHeight < 16 || recipe.customHeight > 7680), + recipe.preset === "custom" && + (Number.isNaN(recipe.customHeight) || + 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.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.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.", - ], - [ - recipe.sharpness < 0 || recipe.sharpness > 3, - "Sharpness must be between 0 and 3.", - ], + [recipe.saturation < 0 || recipe.saturation > 3, "Saturation must be between 0 and 3."], + [recipe.sharpness < 0 || recipe.sharpness > 3, "Sharpness must be between 0 and 3."], ]; - return ( - validations.find(([condition]) => condition)?.[1] ?? - null - ); + return validations.find(([condition]) => condition)?.[1] ?? null; } function encodeRecipe(recipe: EditRecipe): string { - return btoa(JSON.stringify(recipe)); + return encodeURIComponent(JSON.stringify(recipe)); } function decodeRecipe(encoded: string): Partial | null { try { - const decoded = JSON.parse(atob(encoded)); + let jsonString: string; + try { + jsonString = decodeURIComponent(encoded); + if (!jsonString.trim().startsWith("{")) { + throw new Error(); + } + } catch { + jsonString = atob(encoded); + } + const decoded = JSON.parse(jsonString); if (!decoded || typeof decoded !== "object") return null; // Validate the merged recipe before accepting any decoded values. // This prevents a tampered or malformed share URL from injecting @@ -191,36 +201,40 @@ export function useVideoEditor() { const [currentTime, setCurrentTime] = useState(0); // Phase 1 MVP: Multi-track timeline support - const [multiTrackState, setMultiTrackState] = useState(createMultiTrackState); + const [multiTrackState, setMultiTrackState] = + useState(createMultiTrackState); const addTrack = useCallback((track: TimelineTrack) => { - setMultiTrackState(prev => addTrackToTimeline(prev, track)); + setMultiTrackState((prev) => addTrackToTimeline(prev, track)); }, []); const removeTrack = useCallback((trackId: string) => { - setMultiTrackState(prev => removeTrackFromTimeline(prev, trackId)); + setMultiTrackState((prev) => removeTrackFromTimeline(prev, trackId)); }, []); const updateTrack = useCallback((trackId: string, updates: Partial) => { - setMultiTrackState(prev => updateTrackInTimeline(prev, trackId, updates)); + setMultiTrackState((prev) => updateTrackInTimeline(prev, trackId, updates)); }, []); - const addVideoTrack = useCallback((videoFile: File, startTime: number = 0) => { - const track = createTimelineTrack("video", videoFile, startTime); - addTrack(track); - return track; - }, [addTrack]); + const addVideoTrack = useCallback( + (videoFile: File, startTime: number = 0) => { + const track = createTimelineTrack("video", videoFile, startTime); + addTrack(track); + return track; + }, + [addTrack] + ); const updateRecipe = useCallback((patch: Partial) => { - setRecipe((prev) => { - const next = { ...prev, ...patch }; - // GIF has no audio — force keepAudio off - if (next.format === "gif") { - next.keepAudio = false; - } - return next; - }); -}, []); + setRecipe((prev) => { + const next = { ...prev, ...patch }; + // GIF has no audio — force keepAudio off + if (next.format === "gif") { + next.keepAudio = false; + } + return next; + }); + }, []); const isValidValue = (key: keyof EditRecipe, val: any): boolean => { switch (key) { case "preset": @@ -238,7 +252,11 @@ export function useVideoEditor() { case "rotate": return val === 0 || val === 90 || val === 180 || val === 270; case "speed": - return typeof val === "number" && !isNaN(val) && [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4].includes(val); + return ( + typeof val === "number" && + !isNaN(val) && + [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4].includes(val) + ); case "quality": return typeof val === "number" && !isNaN(val) && val >= 18 && val <= 30; case "format": @@ -256,21 +274,23 @@ export function useVideoEditor() { useEffect(() => { if (typeof window === "undefined") return; - + // Auto-restore saved video session - loadSessionFile().then(async (savedFile) => { - if (savedFile) { - try { - const { width, height, duration: dur } = await extractMetadata(savedFile); - setDuration(dur); - setVideoMetadata({ width, height, duration: dur }); - setFile(savedFile); - } catch (e) { - console.error("Failed to restore video session:", e); - clearSessionFile().catch(console.error); + loadSessionFile() + .then(async (savedFile) => { + if (savedFile) { + try { + const { width, height, duration: dur } = await extractMetadata(savedFile); + setDuration(dur); + setVideoMetadata({ width, height, duration: dur }); + setFile(savedFile); + } catch (e) { + console.error("Failed to restore video session:", e); + clearSessionFile().catch(console.error); + } } - } - }).catch(console.error); + }) + .catch(console.error); try { const params = new URLSearchParams(window.location.search); @@ -284,7 +304,7 @@ export function useVideoEditor() { } const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; - const hasRecipeParams = recipeKeys.some(key => params.has(key)); + const hasRecipeParams = recipeKeys.some((key) => params.has(key)); if (hasRecipeParams) { const updatedPatch: Partial = {}; @@ -309,16 +329,21 @@ export function useVideoEditor() { }); if (Object.keys(updatedPatch).length > 0) { - setRecipe(prev => ({ + setRecipe((prev) => ({ ...prev, - ...updatedPatch + ...updatedPatch, })); } } else { - setRecipe((current) => loadPersistedRecipe(localStorage, migratePersistedRecipe({ - ...current, - soundOnCompletion: getStoredSoundPreference(localStorage), - }))); + setRecipe((current) => + loadPersistedRecipe( + localStorage, + migratePersistedRecipe({ + ...current, + soundOnCompletion: getStoredSoundPreference(localStorage), + }) + ) + ); } } catch (e) { // ignore @@ -378,7 +403,7 @@ export function useVideoEditor() { setError(null); setFile(null); setVideoMetadata(null); - + if (!selectedFile) { setFileError(""); return; @@ -398,17 +423,21 @@ export function useVideoEditor() { return; } - const validExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']; + const validExtensions = [".mp4", ".mov", ".avi", ".webm", ".mkv"]; const filename = selectedFile.name.toLowerCase(); - const hasValidExtension = validExtensions.some(ext => filename.endsWith(ext)); + const hasValidExtension = validExtensions.some((ext) => filename.endsWith(ext)); if (!hasValidExtension) { - setError(`Layer 1 Validation Failed: Invalid file extension. Expected one of: ${validExtensions.join(', ')}`); + setError( + `Layer 1 Validation Failed: Invalid file extension. Expected one of: ${validExtensions.join(", ")}` + ); setStatus("error"); return; } if (!selectedFile.type.startsWith("video/")) { - setError(`Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || 'unknown'}`); + setError( + `Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || "unknown"}` + ); setStatus("error"); return; } @@ -418,7 +447,9 @@ export function useVideoEditor() { try { 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."); + setError( + "Layer 3 Validation Failed: Invalid file content. The file's magic bytes do not match known video formats." + ); setStatus("error"); return; } @@ -431,7 +462,7 @@ export function useVideoEditor() { const suggested = getDownscaledDimensions(width, height); setError( `Layer 5 Validation Failed: Resolution too high (${width}×${height}). ` + - `Maximum supported is 8K. Suggested safe size: ${suggested.width}×${suggested.height}.` + `Maximum supported is 8K. Suggested safe size: ${suggested.width}×${suggested.height}.` ); setStatus("error"); return; @@ -443,7 +474,9 @@ export function useVideoEditor() { saveSessionFile(selectedFile).catch(console.error); if (dimensionCheck === "warning") { - console.warn(`[Reframe] High resolution video detected (${width}×${height}). Export may be slow.`); + console.warn( + `[Reframe] High resolution video detected (${width}×${height}). Export may be slow.` + ); } setRecipe((prev) => { const suggestedPreset = suggestPreset(width, height); @@ -457,11 +490,12 @@ export function useVideoEditor() { }; }); } catch (err) { - setError(`Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`); + setError( + `Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}` + ); setStatus("error"); } })(); - }, []); const handleExport = useCallback(async () => { @@ -521,23 +555,22 @@ export function useVideoEditor() { exportDurationMs: Date.now() - startedAt, }); setStatus("done"); - } catch (err) { + } 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 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.'); + setError("Export failed. Please try again or use a different video."); } setExportStartedAt(null); setStatus("error"); - } - finally { + } finally { if (exportAbortControllerRef.current === abortController) { exportAbortControllerRef.current = null; } @@ -558,7 +591,6 @@ export function useVideoEditor() { status, ]); - useEffect(() => { if (status === "exporting") { document.title = `Exporting ${progress}% | Reframe`; @@ -580,9 +612,7 @@ export function useVideoEditor() { }, []); useEffect(() => { - const shouldWarn = - status === "exporting" || - status === "loading-engine"; + const shouldWarn = status === "exporting" || status === "loading-engine"; if (!shouldWarn) return; @@ -594,7 +624,7 @@ export function useVideoEditor() { window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [status]); - + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if ( @@ -622,11 +652,7 @@ export function useVideoEditor() { if (e.key.toLowerCase() !== "m" || e.ctrlKey || e.metaKey || e.altKey) return; const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { + if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) { return; } @@ -639,13 +665,13 @@ export function useVideoEditor() { }; }, [file]); - useEffect(()=>{ - return ()=>{ - if(result?.blobUrl){ + useEffect(() => { + return () => { + if (result?.blobUrl) { URL.revokeObjectURL(result.blobUrl); } - } - },[result?.blobUrl]) + }; + }, [result?.blobUrl]); useEffect(() => { return () => { @@ -675,7 +701,6 @@ export function useVideoEditor() { setExportStartedAt(null); }, []); - const reset = useCallback(() => { if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); @@ -696,7 +721,6 @@ export function useVideoEditor() { } }, [result]); - useEffect(() => { persistSoundPreference(localStorage, recipe.soundOnCompletion); }, [recipe.soundOnCompletion]); @@ -714,8 +738,8 @@ export function useVideoEditor() { }); const toggleSound = useCallback(() => { - updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion }); -}, [recipe.soundOnCompletion, updateRecipe]); + updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion }); + }, [recipe.soundOnCompletion, updateRecipe]); return { file,