- We detected a {recommendedPreset.label.replace(/\s/g, "")} video → Recommended: {(recommendedPreset.platform.split("·")[0] ?? "").trim()} ({recommendedPreset.label.replace(/\s/g, "")})
-
- {isMac ? "⌘" : "Ctrl"} + Enter to export
+ {activeModal === "save" && (
+
+
+
+
+
+ Save Current Project
+
+
+ Your configurations, trim zones, and color adjustments are written locally.
- )}
+
+
-
+ )}
+
+ {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