From bc0d699294d6153cc1c078b7d5c8124d48a60711 Mon Sep 17 00:00:00 2001 From: Flux Date: Wed, 20 May 2026 04:59:26 +0530 Subject: [PATCH 1/2] feat: implement save/load project state using localStorage (#688) --- src/components/VideoEditor.tsx | 169 +++++++++++++++++++++++++++++---- src/hooks/useVideoEditor.ts | 167 +++++++++++++++++++++++++------- 2 files changed, 286 insertions(+), 50 deletions(-) diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 3ffb6a67..35ff1bf4 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -1,8 +1,7 @@ "use client"; - import { useState, useRef, useEffect, useMemo } from "react"; -import { useVideoEditor } from "@/hooks/useVideoEditor"; +import { useVideoEditor, VideoProject } from "@/hooks/useVideoEditor"; import FileUpload from "./FileUpload"; import VideoPreview from "./VideoPreview"; import ThumbnailStrip from "./ThumbnailStrip"; @@ -15,11 +14,12 @@ 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 { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github + SlidersHorizontal, Zap, AlertTriangle, + Save, FolderOpen, Trash2 } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; @@ -50,18 +50,25 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { export default function VideoEditor() { const { - file, duration, recipe, status, progress, + file, duration, recipe, status, progress, result, error, updateRecipe, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, - videoRef, - seekTo, + videoRef, seekTo, overlayFile, setOverlayFile, overlayPosition, setOverlayPosition, overlaySize, setOverlaySize, overlayOpacity, setOverlayOpacity, recommendedPreset, + saveProject, listProjects, loadProject, deleteProject, } = useVideoEditor(); + const [copied, setCopied] = useState(false); + const [showSaveModal, setShowSaveModal] = useState(false); + const [showLoadModal, setShowLoadModal] = useState(false); + const [projectName, setProjectName] = useState(""); + const [saveError, setSaveError] = useState(""); + const [loadedProjects, setLoadedProjects] = useState>([]); + const [loadNotice, setLoadNotice] = useState(""); const downloadRef = useRef(null); useEffect(() => { @@ -122,10 +129,10 @@ export default function VideoEditor() { {!file && ( -
-

Upload a video to get started

-

Supports MP4, MOV, WebM and more

-
+
+

Upload a video to get started

+

Supports MP4, MOV, WebM and more

+
)} {file && ( @@ -151,6 +158,7 @@ export default function VideoEditor() { ⚠️ Large file - processing may take several minutes

)} + {file && (
} title="Audio & Speed" delay={150}> -
} - title="Adjustments" - delay={175} - > +
} title="Adjustments" delay={175}>
{/* Brightness */}
@@ -336,7 +340,31 @@ export default function VideoEditor() {
-
+
+
+ + +
+ + {/* Save Modal */} + {showSaveModal && ( +
+
+

Save project

+ setProjectName(e.target.value)} + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] text-sm focus:outline-none focus:ring-1 focus:ring-film-500" + autoFocus + /> + {saveError &&

{saveError}

} +
+ + +
+
+
+ )} + + {/* Load Modal */} + {showLoadModal && ( +
+
+

Load project

+
+ ⚠️ Settings will be restored, but you'll need to re-select your video file. +
+ {loadNotice &&

{loadNotice}

} + {loadedProjects.length === 0 ? ( +

No saved projects yet.

+ ) : ( +
    + {loadedProjects.map((p: VideoProject) => ( +
  • +
    +

    {p.name}

    +

    {new Date(p.savedAt).toLocaleString()}

    +
    +
    + + +
    +
  • + ))} +
+ )} +
+ +
+
+
+ )}
); } \ No newline at end of file diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 2f94bcbf..abe806a7 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -7,6 +7,47 @@ import { getPresetById } from "@/lib/presets"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; import { suggestPreset } from "@/lib/presetSuggestion"; +// --- Project Save/Load --- +export interface VideoProject { + id: string; + name: string; + savedAt: string; + schemaVersion: "v1"; + settings: { + preset: string; + quality: number; + speed: number; + customWidth: number; + customHeight: number; + brightness: number; + contrast: number; + saturation: number; + trimStart: number; + trimEnd: number | null; + }; +} + +const PROJECT_KEY = "reframe-projects-v1"; + +const readProjects = (): Record => { + if (typeof window === "undefined") return {}; + try { + const raw = localStorage.getItem(PROJECT_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +}; + +const writeProjects = (projects: Record): void => { + if (typeof window === "undefined") return; + try { + localStorage.setItem(PROJECT_KEY, JSON.stringify(projects)); + } catch { + // quota exceeded or storage blocked + } +}; + const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> { @@ -15,12 +56,12 @@ 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")) + reject(new Error("Video metaData load timeout")); }, 500); video.preload = "metadata"; video.onloadedmetadata = () => { - clearTimeout(timeout) + clearTimeout(timeout); resolve({ width: video.videoWidth, height: video.videoHeight, @@ -29,7 +70,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")); }; @@ -62,7 +103,7 @@ 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, @@ -96,12 +137,10 @@ function validateRecipe(recipe: EditRecipe, duration: number ): string | null { 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.", @@ -147,16 +186,17 @@ export function useVideoEditor() { const [overlaySize, setOverlaySize] = useState(150); const [overlayOpacity, setOverlayOpacity] = useState(100); - 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 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; + }); + }, []); + useEffect(() => { try { const saved = localStorage.getItem("reframe-settings"); @@ -168,10 +208,10 @@ export function useVideoEditor() { quality: parsed.quality ?? prev.quality, speed: parsed.speed ?? prev.speed, customWidth: parsed.customWidth ?? prev.customWidth, - customHeight: parsed.customHeight ?? prev.customHeight + customHeight: parsed.customHeight ?? prev.customHeight, })); } - } catch (e) { + } catch { // ignore } }, []); @@ -183,9 +223,9 @@ export function useVideoEditor() { quality: recipe.quality, speed: recipe.speed, customWidth: recipe.customWidth, - customHeight: recipe.customHeight + customHeight: recipe.customHeight, })); - } catch (e) { + } catch { // ignore } }, [recipe.preset, recipe.quality, recipe.speed, recipe.customWidth, recipe.customHeight]); @@ -195,6 +235,68 @@ export function useVideoEditor() { return getPresetById(suggestPreset(videoMetadata.width, videoMetadata.height)) ?? null; }, [videoMetadata]); + const saveProject = useCallback((name: string): boolean => { + try { + const projects = readProjects(); + const id = crypto.randomUUID(); + const project: VideoProject = { + id, + name: name.trim(), + savedAt: new Date().toISOString(), + schemaVersion: "v1", + settings: { + preset: recipe.preset, + quality: recipe.quality, + speed: recipe.speed, + customWidth: recipe.customWidth, + customHeight: recipe.customHeight, + brightness: recipe.brightness, + contrast: recipe.contrast, + saturation: recipe.saturation, + trimStart: recipe.trimStart, + trimEnd: recipe.trimEnd, + }, + }; + projects[id] = project; + writeProjects(projects); + return true; + } catch { + return false; + } + }, [recipe]); + + const listProjects = useCallback((): VideoProject[] => { + return Object.values(readProjects()).sort( + (a, b) => new Date(b.savedAt).getTime() - new Date(a.savedAt).getTime() + ); + }, []); + + const loadProject = useCallback((id: string): boolean => { + const project = readProjects()[id]; + if (!project) return false; + updateRecipe({ + preset: project.settings.preset as EditRecipe["preset"], + quality: project.settings.quality, + speed: project.settings.speed, + customWidth: project.settings.customWidth, + customHeight: project.settings.customHeight, + brightness: project.settings.brightness, + contrast: project.settings.contrast, + saturation: project.settings.saturation, + trimStart: project.settings.trimStart, + trimEnd: project.settings.trimEnd, + }); + return true; + }, [updateRecipe]); + + const deleteProject = useCallback((id: string): boolean => { + const projects = readProjects(); + if (!projects[id]) return false; + delete projects[id]; + writeProjects(projects); + return true; + }, []); + const handleFileSelect = useCallback(async (selectedFile: File) => { setResult(null); setStatus("idle"); @@ -245,7 +347,6 @@ export function useVideoEditor() { setRecipe((prev) => { const suggestedPreset = suggestPreset(width, height); const shouldApplySuggestion = prev.preset === DEFAULT_RECIPE.preset; - return { ...prev, trimStart: 0, @@ -311,7 +412,7 @@ export function useVideoEditor() { setResult(exportResult); setStatus("done"); - } catch (err) { + } catch (err) { if (exportCancelledRef.current) return; console.error("export failed:", err); @@ -325,15 +426,13 @@ export function useVideoEditor() { setError('Export failed. Please try again or use a different video.'); } setStatus("error"); - } - finally { + } finally { if (exportAbortControllerRef.current === abortController) { exportAbortControllerRef.current = null; } } }, [file, recipe, result, status, overlayFile, overlayPosition, overlaySize, overlayOpacity, duration]); - useEffect(() => { if (status === "exporting") { document.title = `Exporting ${progress}% | Reframe`; @@ -366,7 +465,7 @@ export function useVideoEditor() { window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [status]); - + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if ( @@ -386,13 +485,13 @@ export function useVideoEditor() { }; }, [file, status, handleExport]); - useEffect(()=>{ - return ()=>{ - if(result?.blobUrl){ + useEffect(() => { + return () => { + if (result?.blobUrl) { URL.revokeObjectURL(result.blobUrl); } - } - },[result?.blobUrl]) + }; + }, [result?.blobUrl]); const resetSettings = useCallback(() => { setRecipe(DEFAULT_RECIPE); @@ -408,7 +507,6 @@ export function useVideoEditor() { setError(null); }, []); - const reset = useCallback(() => { if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setFile(null); @@ -438,6 +536,7 @@ export function useVideoEditor() { useEffect(() => { localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); }, [recipe.soundOnCompletion]); + const seekTo = useCallback((time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; @@ -478,5 +577,9 @@ export function useVideoEditor() { overlayOpacity, setOverlayOpacity, recommendedPreset, + saveProject, + listProjects, + loadProject, + deleteProject, }; } \ No newline at end of file From 9e4ef138bf08e3b0275edbc1fe2f4cfe219d6719 Mon Sep 17 00:00:00 2001 From: Flux Date: Wed, 20 May 2026 05:07:02 +0530 Subject: [PATCH 2/2] fix: resolve lint errors in VideoEditor (autoFocus, unescaped apostrophe) --- src/components/VideoEditor.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 35ff1bf4..1c2b87a5 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -408,7 +408,6 @@ export default function VideoEditor() { value={projectName} onChange={(e) => setProjectName(e.target.value)} className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--bg)] text-[var(--text)] text-sm focus:outline-none focus:ring-1 focus:ring-film-500" - autoFocus /> {saveError &&

{saveError}

}
@@ -448,7 +447,7 @@ export default function VideoEditor() {

Load project

- ⚠️ Settings will be restored, but you'll need to re-select your video file. + ⚠️ Settings will be restored, but you'll need to re-select your video file.
{loadNotice &&

{loadNotice}

} {loadedProjects.length === 0 ? (