From d8b48d9f481873cf30f7fd0539a1db242767a389 Mon Sep 17 00:00:00 2001 From: Akshar Sawhney Date: Sat, 16 May 2026 11:05:21 +0530 Subject: [PATCH 1/2] Update VideoEditor.tsx --- src/components/VideoEditor.tsx | 98 ++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 3 deletions(-) diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 917c9611..e7fc5212 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -49,6 +49,7 @@ export default function VideoEditor() { handleFileSelect, handleExport, cancelExport, reset, } = useVideoEditor(); + const isProcessing = status === "loading-engine" || status === "exporting"; return ( @@ -92,6 +93,11 @@ export default function VideoEditor() { )} + {file && file.size > 100 * 1024 * 1024 && ( +

+ ⚠️ Large file — processing may take several minutes +

+ )} {file && (
} title="Audio & Speed" delay={150}> + +
} title="Adjustments" delay={175}> +
+ {/* Brightness */} +
+
+ Brightness + +
+ updateRecipe({ brightness: Number(e.target.value) })} + className="w-full" + /> +
+ + {/* Contrast */} +
+
+ Contrast + +
+ updateRecipe({ contrast: Number(e.target.value) })} + className="w-full" + /> +
+ + {/* Saturation */} +
+
+ Saturation + +
+ updateRecipe({ saturation: Number(e.target.value) })} + className="w-full" + /> +
+
+
+
} title="Export quality" delay={200}> - +
@@ -122,10 +206,18 @@ export default function VideoEditor() { className="flex items-start gap-3 p-4 bg-film-50 border border-film-200 rounded-xl text-film-800 text-sm animate-fade-in" > -
+

Error

{error}

+ {!error.includes("Validation Failed") && ( + + )}
)} @@ -181,7 +273,7 @@ export default function VideoEditor() { href="https://github.com/magic-peach/reframe" target="_blank" rel="noopener noreferrer" - className="flex items-center gap-1.5 text-[11px] font-heading font-medium text-[var(--muted)] hover:text-film-600 transition-colors" + className="min-h-[44px] min-w-[44px] flex items-center gap-1.5 px-2 text-[11px] font-heading font-medium text-[var(--muted)] hover:text-film-600 transition-colors" > Source on GitHub From d72c3ee817127b831c29b2ac3c53dd9a0efb8153 Mon Sep 17 00:00:00 2001 From: aksharsawhney74-rgb Date: Tue, 19 May 2026 23:35:15 +0530 Subject: [PATCH 2/2] feat: add shareable URL state syncing via window location hash --- src/components/ExportSettings.tsx | 89 ++++++++++++++++------ src/components/VideoEditor.tsx | 8 +- src/hooks/useVideoEditor.ts | 120 +++++++++++++++++++++++++----- 3 files changed, 171 insertions(+), 46 deletions(-) diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx index d61e1743..9e2f9cfe 100644 --- a/src/components/ExportSettings.tsx +++ b/src/components/ExportSettings.tsx @@ -1,7 +1,8 @@ "use client"; +import { useState } from "react"; import { EditRecipe } from "@/lib/types"; -import { SlidersHorizontal } from "lucide-react"; +import { SlidersHorizontal, Link2, Check } from "lucide-react"; interface Props { recipe: EditRecipe; @@ -9,33 +10,73 @@ interface Props { } export default function ExportSettings({ recipe, onChange }: Props) { + const [copied, setCopied] = useState(false); const label = recipe.quality <= 20 ? "High" : recipe.quality <= 24 ? "Balanced" : "Small file"; + // NEW: Copy link logic + const handleCopyLink = () => { + // window.location.href automatically includes the #hash we set in the hook! + navigator.clipboard.writeText(window.location.href); + setCopied(true); + + // Reset the button back to normal after 2 seconds + setTimeout(() => { + setCopied(false); + }, 2000); + }; + return ( -
-
- - - {label} - CRF {recipe.quality} - +
+ {/* Existing Quality Slider */} +
+
+ + + {label} + CRF {recipe.quality} + +
+ onChange({ quality: Number(e.target.value) })} + className="w-full accent-film-600 cursor-pointer" + /> +
+ Best quality + Smallest file +
- onChange({ quality: Number(e.target.value) })} - className="w-full accent-film-600 cursor-pointer" - /> -
- Best quality - Smallest file + + {/* NEW: Copy Settings Link Button */} +
+ +

+ Share this link to instantly load your exact dimensions, quality, and trim settings. +

); -} +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index e7fc5212..28fc8b8b 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -47,6 +47,7 @@ export default function VideoEditor() { file, duration, recipe, status, progress, result, error, updateRecipe, handleFileSelect, handleExport, cancelExport, reset, + exportHistory // FIXED: Grab exportHistory from the hook } = useVideoEditor(); @@ -190,10 +191,10 @@ export default function VideoEditor() {
} title="Export quality" delay={200}> + {/* The new copy link button lives inside this component! */}
@@ -223,7 +224,8 @@ export default function VideoEditor() { {status === "done" && result && (
- + {/* FIXED: Passed exportHistory into DownloadResult */} +
)}
@@ -282,4 +284,4 @@ export default function VideoEditor() {
); -} +} \ No newline at end of file diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index 0e0413a3..6f8b8016 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,7 +1,7 @@ "use client"; import { useState, useCallback, useEffect, useRef } from "react"; -import { EditRecipe, ExportResult, ExportStatus, DEFAULT_RECIPE } from "@/lib/types"; +import { EditRecipe, ExportResult, ExportStatus, DEFAULT_RECIPE, ExportHistoryItem } from "@/lib/types"; import { loadFFmpeg, exportVideo, terminateFFmpeg } from "@/lib/ffmpeg"; const DEFAULT_TITLE = "Reframe — Resize, trim, and export videos in your browser"; @@ -35,11 +35,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); }; @@ -52,6 +49,10 @@ export function useVideoEditor() { const [file, setFile] = useState(null); const [duration, setDuration] = useState(0); const [recipe, setRecipe] = useState(DEFAULT_RECIPE); + + const [rememberSettings, setRememberSettings] = useState(false); + const [exportHistory, setExportHistory] = useState([]); + const [status, setStatus] = useState("idle"); const [progress, setProgress] = useState(0); const [result, setResult] = useState(null); @@ -59,6 +60,70 @@ export function useVideoEditor() { const exportAbortControllerRef = useRef(null); const exportCancelledRef = useRef(false); + // --- Initialization (Hash & LocalStorage) --- + useEffect(() => { + const savedToggle = localStorage.getItem('rememberSettings') === 'true'; + setRememberSettings(savedToggle); + + // 1. Check URL Hash First (Highest Priority) + if (window.location.hash && window.location.hash.length > 1) { + try { + const hashParams = new URLSearchParams(window.location.hash.slice(1)); + const parsedRecipe: Partial = {}; + + // Carefully cast strings back to proper types + if (hashParams.has("preset")) parsedRecipe.preset = hashParams.get("preset")!; + if (hashParams.has("customWidth")) parsedRecipe.customWidth = Number(hashParams.get("customWidth")); + if (hashParams.has("customHeight")) parsedRecipe.customHeight = Number(hashParams.get("customHeight")); + if (hashParams.has("framing")) parsedRecipe.framing = hashParams.get("framing") as "fit" | "fill"; + if (hashParams.has("trimStart")) parsedRecipe.trimStart = Number(hashParams.get("trimStart")); + if (hashParams.has("trimEnd")) parsedRecipe.trimEnd = hashParams.get("trimEnd") === "null" ? null : Number(hashParams.get("trimEnd")); + if (hashParams.has("rotate")) parsedRecipe.rotate = Number(hashParams.get("rotate")) as 0 | 90 | 180 | 270; + if (hashParams.has("keepAudio")) parsedRecipe.keepAudio = hashParams.get("keepAudio") === "true"; + if (hashParams.has("speed")) parsedRecipe.speed = Number(hashParams.get("speed")); + if (hashParams.has("quality")) parsedRecipe.quality = Number(hashParams.get("quality")); + + setRecipe((prev) => ({ ...prev, ...parsedRecipe })); + return; // Skip localStorage if hash exists + } catch (e) { + console.error("Failed to parse URL hash settings", e); + } + } + + // 2. Fallback to LocalStorage + if (savedToggle) { + const savedRecipe = localStorage.getItem('videoEditorRecipe'); + if (savedRecipe) { + try { + setRecipe(JSON.parse(savedRecipe)); + } catch (error) { + console.error("Failed to parse saved video recipe", error); + } + } + } + }, []); + + // --- Save to LocalStorage --- + useEffect(() => { + localStorage.setItem('rememberSettings', String(rememberSettings)); + if (rememberSettings) { + localStorage.setItem('videoEditorRecipe', JSON.stringify(recipe)); + } else { + localStorage.removeItem('videoEditorRecipe'); + } + }, [rememberSettings, recipe]); + + // --- NEW: Sync state to URL Hash --- + useEffect(() => { + const params = new URLSearchParams(); + Object.entries(recipe).forEach(([key, value]) => { + params.set(key, String(value)); + }); + + // replaceState prevents creating a massive browser history + window.history.replaceState(null, '', `#${params.toString()}`); + }, [recipe]); + const updateRecipe = useCallback((patch: Partial) => { setRecipe((prev) => ({ ...prev, ...patch })); }, []); @@ -69,7 +134,6 @@ export function useVideoEditor() { setError(null); setFile(null); - // LAYER 1: Extension check const validExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']; const name = selectedFile.name.toLowerCase(); const hasValidExtension = validExtensions.some(ext => name.endsWith(ext)); @@ -79,14 +143,12 @@ export function useVideoEditor() { return; } - // LAYER 2: MIME type check if (!selectedFile.type.startsWith("video/")) { setError(`Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || 'unknown'}`); setStatus("error"); return; } - // LAYER 3: Magic Bytes Verification 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."); @@ -133,6 +195,24 @@ export function useVideoEditor() { if (exportCancelledRef.current) return; setResult(exportResult); + + setExportHistory((prevHistory) => { + const newItem: ExportHistoryItem = { + id: Date.now().toString(), + timestamp: Date.now(), + result: exportResult, + recipe: recipe, + }; + + const updatedHistory = [newItem, ...prevHistory]; + + if (updatedHistory.length > 5) { + URL.revokeObjectURL(updatedHistory[5].result.blobUrl); + } + + return updatedHistory.slice(0, 5); + }); + setStatus("done"); } catch (err) { if (exportCancelledRef.current) return; @@ -160,21 +240,13 @@ export function useVideoEditor() { useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { - if ( - (e.ctrlKey || e.metaKey) && - e.key === "Enter" && - file && - status === "idle" - ) { + if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && file && status === "idle") { handleExport(); } }; document.addEventListener("keydown", handleKeydown); - - return () => { - document.removeEventListener("keydown", handleKeydown); - }; + return () => document.removeEventListener("keydown", handleKeydown); }, [file, status, handleExport]); const cancelExport = useCallback(() => { @@ -197,7 +269,6 @@ export function useVideoEditor() { setError(null); }, []); - // Development-only memory monitoring during export useEffect(() => { if (process.env.NODE_ENV !== "development") return; if (status !== "exporting") return; @@ -212,6 +283,14 @@ export function useVideoEditor() { return () => clearInterval(interval); }, [status]); + useEffect(() => { + return () => { + exportHistory.forEach((item) => { + URL.revokeObjectURL(item.result.blobUrl); + }); + }; + }, [exportHistory]); + return { file, duration, @@ -220,10 +299,13 @@ export function useVideoEditor() { progress, result, error, + rememberSettings, + exportHistory, + setRememberSettings, updateRecipe, handleFileSelect, handleExport, cancelExport, reset, }; -} +} \ No newline at end of file