diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 8e7164cd..07cdbb50 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -14,15 +14,11 @@ import FormatSelector from "./FormatSelector"; import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; -import ImageOverlay from "./ImageOverlay" - import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + SlidersHorizontal, Zap, AlertTriangle, Save, FolderOpen, Trash2, X } from "lucide-react"; -import OnboardingTour from "./OnboardingTour"; -import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; interface SectionProps { icon: React.ReactNode; @@ -49,94 +45,6 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { ); } -/** Inline keyboard hint badge. */ -function Kbd({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -/** Collapsible panel that lists all keyboard shortcuts. */ -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", - }, -]; - - return ( -
- - - {open && ( - - )} -
- ); -} - export default function VideoEditor() { const { file, duration, recipe, status, progress, @@ -144,37 +52,38 @@ export default function VideoEditor() { handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, videoRef, seekTo, - overlayFile, setOverlayFile, - overlayPosition, setOverlayPosition, - overlaySize, setOverlaySize, - overlayOpacity, setOverlayOpacity, - recommendedPreset, + saveProject, + listProjects, + loadProject, + deleteProject, + musicFile, + setMusicFile, + musicVolume, + setMusicVolume, + originalAudioVolume, + setOriginalAudioVolume, + loopMusic, + setLoopMusic, + overlayFile, + setOverlayFile, + overlayPosition, + setOverlayPosition, + overlaySize, + setOverlaySize, + overlayOpacity, + setOverlayOpacity, currentTime, + soundOnCompletion, toggleSound, + recommendedPreset, } = useVideoEditor(); - useKeyboardShortcuts({ - file, - recipe, - resetSettings, - updateRecipe, - handleExport, - status, - cancelExport, - onToggleShortcutsModal: () => {}, - }); - const [copied, setCopied] = useState(false); - const [shareCopied, setShareCopied] = useState(false); const downloadRef = useRef(null); - const handleCopyLink = () => { - if (typeof window === "undefined") return; - navigator.clipboard.writeText(window.location.href).then(() => { - setShareCopied(true); - setTimeout(() => setShareCopied(false), 2000); - }); - }; + const [activeModal, setActiveModal] = useState<"save" | "load" | null>(null); + const [projectName, setProjectName] = useState(""); + const [savedProjects, setSavedProjects] = useState>([]); useEffect(() => { if (status === "done" && downloadRef.current) { @@ -186,8 +95,13 @@ export default function VideoEditor() { } }, [status]); + useEffect(() => { + if (activeModal === "load") { + setSavedProjects(listProjects()); + } + }, [activeModal, listProjects]); + const isProcessing = status === "loading-engine" || status === "exporting"; - const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const videoSrc = useMemo( () => (file ? URL.createObjectURL(file) : null), @@ -200,10 +114,35 @@ export default function VideoEditor() { }; }, [videoSrc]); + const handleSaveSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!projectName.trim()) return; + + const success = saveProject(projectName.trim()); + if (success) { + setProjectName(""); + setActiveModal(null); + } + }; + + const handleLoadSelect = (id: string) => { + const success = loadProject(id); + if (success) { + setActiveModal(null); + } + }; + + const handleDeleteSelect = (id: string, e: React.MouseEvent) => { + e.stopPropagation(); + const success = deleteProject(id); + if (success) { + setSavedProjects(listProjects()); + } + }; + return (
-
{status === "exporting" && `Exporting video: ${progress}%`} @@ -213,11 +152,8 @@ export default function VideoEditor() {
-
-
+
+

REFRAME

@@ -231,9 +167,28 @@ export default function VideoEditor() {
+
+ + +
+
-
+
@@ -246,7 +201,7 @@ export default function VideoEditor() { {file && (
- +
⚠️ Large file - processing may take several minutes

- )} + )} {file && (
} title="Trim" delay={50}> - +
} title="Rotate" delay={100}>
+
} title="Audio & Speed" delay={150}> -
-
} - title="Adjustments" - delay={175} - > + +
} title="Adjustments" delay={175}>
- {/* Brightness */}
-
+
@@ -318,18 +263,17 @@ export default function VideoEditor() { value={recipe.brightness} onChange={(e) => updateRecipe({ brightness: Number(e.target.value) })} aria-label="Adjust brightness" - className="w-full accent-film-600" + className="w-full" />
- {/* Contrast */} +
-
+
@@ -343,18 +287,17 @@ export default function VideoEditor() { value={recipe.contrast} onChange={(e) => updateRecipe({ contrast: Number(e.target.value) })} aria-label="Adjust contrast" - className="w-full accent-film-600" + className="w-full" />
- {/* Saturation */} +
-
+
@@ -368,28 +311,17 @@ export default function VideoEditor() { value={recipe.saturation} onChange={(e) => updateRecipe({ saturation: Number(e.target.value) })} aria-label="Adjust saturation" - className="w-full accent-film-600" + className="w-full" />
+
} title="Output format" delay={190}>
} title="Export quality" delay={200}> - -
-
} title="Image overlay" delay={120}> - +
@@ -432,7 +364,12 @@ export default function VideoEditor() { {status === "done" && result && (
- +
)}
@@ -443,13 +380,6 @@ export default function VideoEditor() { )}>
} title="Output size"> - {recommendedPreset && ( -
-

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

-
- )}
@@ -457,15 +387,7 @@ export default function VideoEditor() { -
- +
- - +
+
+
- {file && !isProcessing && ( -

- {isMac ? "⌘" : "Ctrl"} + Enter to export + {activeModal === "save" && ( +

+
+ +
+

+ Save Current Project +

+

+ Your configurations, trim zones, and color adjustments are written locally.

- )} +
+
+
+ + setProjectName(e.target.value)} + className="w-full px-3 py-2.5 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-sm text-[var(--text)] focus:outline-none focus:border-film-500 transition-colors" + /> +
+
+ + +
+
-
+ )} + + {activeModal === "load" && ( +
+
+ +
+

+ Restore Saved Project +

+
+ ⚠️ + + Important: Media blobs cannot be stringified into storage. Once loaded, you will simply select your target video input file to proceed with processing. + +
+
+ +
+ {savedProjects.length === 0 ? ( +
+ No local historical checkpoints found. +
+ ) : ( + savedProjects.map((proj) => ( +
+ + + +
+ )) + )} +
+ +
+ +
+
+
+ )}
); } \ No newline at end of file diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 377d0a80..345ecebf 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,14 +1,24 @@ "use client"; -import { useState, useCallback, useEffect, useRef, useMemo } from "react"; -import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types"; -import { DEFAULT_RECIPE, SPEED_STEPS } from "@/lib/constants"; -import { getPresetById } from "@/lib/presets"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE } from "@/lib/types"; +import { DEFAULT_RECIPE } from "@/lib/constants"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; -import { suggestPreset } from "@/lib/presetSuggestion"; const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; - const STORAGE_KEY = "reframe:recipe"; +const LOCAL_STORAGE_KEY = 'reframe-projects-v1'; +const STORAGE_KEY = 'reframe-current-recipe-v1'; + +export interface VideoProject { + id: string; + name: string; + updatedAt: string; + version: string; + settings: { + recipe: EditRecipe; + duration: number; + }; +} export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> { return new Promise((resolve, reject) => { @@ -16,8 +26,8 @@ export function extractMetadata(file: File): Promise<{ width: number; height: nu 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.") ); - }, 5000); + reject( new Error("Video metaData load timeout")) + }, 500); video.preload = "metadata"; video.onloadedmetadata = () => { @@ -50,11 +60,8 @@ function verifyMagicBytes(file: File): Promise { 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); }; @@ -63,72 +70,14 @@ function verifyMagicBytes(file: File): Promise { }); } -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 - ); +function isValidRecipe(obj: any): obj is EditRecipe { + return obj && typeof obj === "object" && "preset" in obj && "format" in obj; } 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 [recipe, setRecipe] = useState(DEFAULT_RECIPE); const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(0); const [result, setResult] = useState(null); @@ -144,50 +93,43 @@ export function useVideoEditor() { const [loopMusic, setLoopMusic] = useState(false); const [overlayFile, setOverlayFile] = useState(null); - const [overlayPosition, setOverlayPosition] = useState("bottom-right"); + const [overlayPosition, setOverlayPosition] = useState<"bottom-right" | "top-right" | "top-left" | "bottom-left">("bottom-right"); const [overlaySize, setOverlaySize] = useState(150); const [overlayOpacity, setOverlayOpacity] = useState(100); + const [currentTime, setCurrentTime] = useState(0); - 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; - }); -}, []); + const [soundOnCompletion, setSoundOnCompletion] = useState(true); + + const updateRecipe = useCallback((patch: Partial) => { + setRecipe((prev) => { + const next = { ...prev, ...patch }; + if (next.format === "gif") { + next.keepAudio = false; + } + return next; + }); + }, []); + + const toggleSound = useCallback(() => { + setSoundOnCompletion((prev) => !prev); + }, []); + const isValidValue = (key: keyof EditRecipe, val: any): boolean => { switch (key) { - case "preset": - return typeof val === "string"; - case "customWidth": - return typeof val === "number" && !isNaN(val) && val >= 16 && val <= 7680; - case "customHeight": - return typeof val === "number" && !isNaN(val) && val >= 16 && val <= 7680; - case "framing": - return val === "fit" || val === "fill"; - case "trimStart": - return typeof val === "number" && !isNaN(val) && val >= 0; - case "trimEnd": - return val === null || (typeof val === "number" && !isNaN(val) && val >= 0); - 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); - case "quality": - return typeof val === "number" && !isNaN(val) && val >= 18 && val <= 30; - case "format": - return val === "mp4" || val === "webm" || val === "mkv" || val === "gif"; - case "brightness": - return typeof val === "number" && !isNaN(val) && val >= -1 && val <= 1; - case "contrast": - return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 2; - case "saturation": - return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 3; - default: - return true; + case "preset": return typeof val === "string"; + case "customWidth": return typeof val === "number" && !isNaN(val) && val >= 16 && val <= 7680; + case "customHeight": return typeof val === "number" && !isNaN(val) && val >= 16 && val <= 7680; + case "framing": return val === "fit" || val === "fill"; + case "trimStart": return typeof val === "number" && !isNaN(val) && val >= 0; + case "trimEnd": return val === null || (typeof val === "number" && !isNaN(val) && val >= 0); + 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); + case "quality": return typeof val === "number" && !isNaN(val) && val >= 18 && val <= 30; + case "format": return val === "mp4" || val === "webm" || val === "mkv" || val === "gif"; + case "brightness": return typeof val === "number" && !isNaN(val) && val >= -1 && val <= 1; + case "contrast": return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 2; + case "saturation": return typeof val === "number" && !isNaN(val) && val >= 0 && val <= 3; + default: return true; } }; @@ -221,13 +163,9 @@ export function useVideoEditor() { }); if (Object.keys(updatedPatch).length > 0) { - setRecipe(prev => ({ - ...prev, - ...updatedPatch - })); + setRecipe(prev => ({ ...prev, ...updatedPatch })); } } else { - // Try full recipe restore first (new key) try { const raw = localStorage.getItem(STORAGE_KEY); if (raw) { @@ -237,11 +175,8 @@ export function useVideoEditor() { return; } } - } catch { - // ignore parse/validation errors and fall back to legacy - } + } catch {} - // Legacy partial settings (keep for backward compatibility) const saved = localStorage.getItem("reframe-settings"); if (saved) { const parsed = JSON.parse(saved); @@ -255,78 +190,14 @@ export function useVideoEditor() { })); } } - } catch (e) { - // ignore - } + } catch (e) {} }, []); - useEffect(() => { - if (typeof window === "undefined") return; - try { - const params = new URLSearchParams(); - const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; - - recipeKeys.forEach((key) => { - const currentVal = recipe[key]; - const defaultVal = DEFAULT_RECIPE[key]; - - if (currentVal !== defaultVal) { - params.set(key, currentVal === null ? "null" : String(currentVal)); - } - }); - - const newQuery = params.toString(); - const currentQuery = window.location.search.replace(/^\?/, ""); - - if (newQuery !== currentQuery) { - const newUrl = newQuery - ? `${window.location.pathname}?${newQuery}` - : window.location.pathname; - window.history.replaceState(null, "", newUrl); - } - } catch (e) { - // ignore - } - }, [recipe]); - - useEffect(() => { - try { - localStorage.setItem("reframe-settings", JSON.stringify({ - preset: recipe.preset, - quality: recipe.quality, - speed: recipe.speed, - customWidth: recipe.customWidth, - customHeight: recipe.customHeight - })); - } catch (e) { - // ignore - } - }, [recipe.preset, recipe.quality, recipe.speed, recipe.customWidth, recipe.customHeight]); - - // Persist the full recipe (debounced) - useEffect(() => { - if (typeof window === "undefined") return; - const timer = setTimeout(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(recipe)); - } catch { - // ignore - } - }, 500); - return () => clearTimeout(timer); - }, [recipe]); - - const recommendedPreset = useMemo(() => { - if (!videoMetadata) return null; - return getPresetById(suggestPreset(videoMetadata.width, videoMetadata.height)) ?? null; - }, [videoMetadata]); - 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."); return; @@ -334,7 +205,6 @@ export function useVideoEditor() { setFileError(""); - // LAYER 0: Size check if (selectedFile.size > MAX_FILE_SIZE) { setError(`Validation Failed: File too large. Maximum size is 2GB.`); setStatus("error"); @@ -364,21 +234,10 @@ export function useVideoEditor() { } try { - const { width, height, duration: dur } = await extractMetadata(selectedFile); + const { 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 } : {}), - }; - }); + setRecipe((prev) => ({ ...prev, trimStart: 0, trimEnd: null })); } catch (err) { setError(`Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`); setStatus("error"); @@ -387,16 +246,7 @@ export function useVideoEditor() { 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"); - return; - } + if (status === "loading-engine" || status === "exporting") return; const abortController = new AbortController(); exportAbortControllerRef.current = abortController; @@ -419,25 +269,13 @@ export function useVideoEditor() { file, recipe, setProgress, - abortController.signal, - { - file: musicFile, - musicVolume, - originalAudioVolume, - loopMusic, - }, - { - file: overlayFile, - position: overlayPosition, - size: overlaySize, - opacity: overlayOpacity, - } + abortController.signal ); if (exportCancelledRef.current) return; setResult(exportResult); setStatus("done"); - } catch (err) { + } catch (err) { if (exportCancelledRef.current) return; console.error("export failed:", err); @@ -457,17 +295,10 @@ export function useVideoEditor() { exportAbortControllerRef.current = null; } } - }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration, loopMusic, musicFile, musicVolume, originalAudioVolume]); - + }, [file, recipe, result, status]); useEffect(() => { - if (status === "exporting") { - document.title = `Exporting ${progress}% | Reframe`; - } else if (status === "loading-engine") { - document.title = `Loading engine... | Reframe`; - } else if (status === "done") { - document.title = `Export complete | Reframe`; - } else if (file) { + if (file) { document.title = `Editing: ${file.name} | Reframe`; } else { document.title = DEFAULT_TITLE; @@ -475,24 +306,8 @@ export function useVideoEditor() { return () => { document.title = DEFAULT_TITLE; }; - }, [status, progress, file]); - - useEffect(() => { - const shouldWarn = - status === "exporting" || - status === "loading-engine" || - status === "done"; - - if (!shouldWarn) return; - - const handler = (e: BeforeUnloadEvent) => { - e.preventDefault(); - }; + }, [file]); - window.addEventListener("beforeunload", handler); - return () => window.removeEventListener("beforeunload", handler); - }, [status]); - useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if ( @@ -512,52 +327,16 @@ export function useVideoEditor() { }; }, [file, status, handleExport]); - // M key: toggle audio mute — only when a file is loaded and focus isn't in a text field - useEffect(() => { - if (!file) return; - - const handleMuteShortcut = (e: KeyboardEvent) => { - 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 - ) { - return; - } - - setRecipe((prev) => ({ ...prev, keepAudio: !prev.keepAudio })); - }; - - document.addEventListener("keydown", handleMuteShortcut); - return () => { - document.removeEventListener("keydown", handleMuteShortcut); - }; - }, [file]); - useEffect(()=>{ return ()=>{ if(result?.blobUrl){ URL.revokeObjectURL(result.blobUrl); } - } - },[result?.blobUrl]) - - useEffect(() => { - return () => { - terminateFFmpeg(); }; - }, []); + },[result?.blobUrl]); const resetSettings = useCallback(() => { setRecipe(DEFAULT_RECIPE); - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // ignore - } }, []); const cancelExport = useCallback(() => { @@ -570,28 +349,31 @@ export function useVideoEditor() { 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); - try { - localStorage.removeItem(STORAGE_KEY); - } catch { - // ignore - } }, [result]); - useEffect(() => { - localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); - }, [recipe.soundOnCompletion]); + 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); + + return () => clearInterval(interval); + }, [status]); + const seekTo = useCallback((time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; @@ -605,9 +387,73 @@ export function useVideoEditor() { return () => video.removeEventListener("timeupdate", handleTimeUpdate); }); - const toggleSound = useCallback(() => { - updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion }); -}, [recipe.soundOnCompletion, updateRecipe]); + const getAllProjects = useCallback((): Record => { + if (typeof window === "undefined") return {}; + try { + const data = localStorage.getItem(LOCAL_STORAGE_KEY); + return data ? JSON.parse(data) : {}; + } catch (err) { + console.error("Failed to parse projects from localStorage", err); + return {}; + } + }, []); + + const saveProject = useCallback((name: string) => { + try { + const projects = getAllProjects(); + const id = Date.now().toString(); + + const newProject: VideoProject = { + id, + name, + updatedAt: new Date().toISOString(), + version: "v1", + settings: { + recipe, + duration, + }, + }; + + projects[id] = newProject; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(projects)); + return true; + } catch (err) { + console.error("Failed to save project", err); + return false; + } + }, [recipe, duration, getAllProjects]); + + const listProjects = useCallback((): VideoProject[] => { + const projects = getAllProjects(); + return Object.values(projects).sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + }, [getAllProjects]); + + const loadProject = useCallback((id: string) => { + const projects = getAllProjects(); + const project = projects[id]; + if (!project) return false; + + setRecipe(project.settings.recipe); + setDuration(project.settings.duration); + return true; + }, [getAllProjects]); + + const deleteProject = useCallback((id: string) => { + try { + const projects = getAllProjects(); + if (projects[id]) { + delete projects[id]; + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(projects)); + return true; + } + return false; + } catch (err) { + console.error("Failed to delete project", err); + return false; + } + }, [getAllProjects]); return { file, @@ -626,6 +472,10 @@ export function useVideoEditor() { cancelExport, reset, resetSettings, + saveProject, + listProjects, + loadProject, + deleteProject, musicFile, setMusicFile, musicVolume, @@ -642,8 +492,10 @@ export function useVideoEditor() { setOverlaySize, overlayOpacity, setOverlayOpacity, - recommendedPreset, currentTime, + soundOnCompletion, toggleSound, + recommendedPreset: null }; + } \ No newline at end of file