diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx
index 3ffb6a67..1c2b87a5 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"
+ />
+ {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