From 908cede264d1cf6b1063157514c0238804a78e2f Mon Sep 17 00:00:00 2001 From: Shiv Date: Wed, 20 May 2026 10:04:38 +0530 Subject: [PATCH 1/2] feat: add export history panel with re-download support --- src/components/ExportHistory.tsx | 154 +++++++++ src/components/VideoEditor.tsx | 299 ++++++++++++------ src/hooks/useVideoEditor.ts | 521 ++++++++++++++++--------------- 3 files changed, 615 insertions(+), 359 deletions(-) create mode 100644 src/components/ExportHistory.tsx diff --git a/src/components/ExportHistory.tsx b/src/components/ExportHistory.tsx new file mode 100644 index 00000000..eb56df8d --- /dev/null +++ b/src/components/ExportHistory.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useState } from "react"; + +interface ExportHistoryItem { + id: string; + blobUrl: string; + filename: string; + format: string; + size: number; + width: number; + height: number; + exportedAt: number; +} + +interface ExportHistoryProps { + history: ExportHistoryItem[]; + onClear: () => void; +} + +function formatFileSize(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export default function ExportHistory({ + history, + onClear, +}: ExportHistoryProps) { + const [isOpen, setIsOpen] = useState(true); + + if (!history.length) return null; + + const handleDownload = (item: ExportHistoryItem) => { + const link = document.createElement("a"); + + link.href = item.blobUrl; + link.download = item.filename; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }; + + return ( +
+
+ + + +
+ +

+ History is cleared when you close or refresh the page. +

+ + {isOpen && ( +
+ {history.map((item) => ( +
+
+

+ {item.filename} +

+ +
+ {item.format.toUpperCase()} + + + + + {item.width}×{item.height} + + + + + {formatFileSize(item.size)} +
+ +

+ Exported{" "} + {new Date(item.exportedAt).toLocaleString()} +

+
+ + +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index db5c3793..4d6358c0 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -1,6 +1,5 @@ "use client"; - import { useState, useRef, useEffect, useMemo } from "react"; import { useVideoEditor } from "@/hooks/useVideoEditor"; import FileUpload from "./FileUpload"; @@ -15,15 +14,23 @@ 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, Copy + Layers, + Crop, + Scissors, + RotateCw, + Volume2, + SlidersHorizontal, + Zap, + AlertTriangle, + Github, + Copy, } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; -import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; +import ExportHistory from "@/components/ExportHistory"; interface SectionProps { icon: React.ReactNode; @@ -50,7 +57,6 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { ); } -/** Inline keyboard hint badge. */ function Kbd({ children }: { children: React.ReactNode }) { return ( @@ -59,43 +65,37 @@ function Kbd({ children }: { children: React.ReactNode }) { ); } -/** 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", - }, -]; - + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [ + Ctrl, + + + + , + , + ], + label: "Export video", + }, + { + keys: [R], + label: "Reset all settings", + }, + { + keys: [Esc], + label: "Cancel export", + }, + { + keys: [?], + label: "Toggle shortcuts panel", + }, + ]; return (
@@ -127,7 +136,10 @@ function KeyboardShortcutsPanel() { className="px-4 pb-3 space-y-2 border-t border-[var(--border)]" > {shortcuts.map(({ keys, label }) => ( -
  • +
  • {label} {keys}
  • @@ -140,29 +152,35 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, updateRecipe, - handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, - videoRef, - seekTo, - overlayFile, setOverlayFile, - overlayPosition, setOverlayPosition, - overlaySize, setOverlaySize, - overlayOpacity, setOverlayOpacity, - recommendedPreset, - toggleSound, - } = useVideoEditor(); - - useKeyboardShortcuts({ file, + duration, recipe, - resetSettings, + status, + progress, + result, + exportHistory, + clearExportHistory, + error, updateRecipe, + handleFileSelect, + fileError, handleExport, - status, cancelExport, - onToggleShortcutsModal: () => {}, - }); + reset, + resetSettings, + videoRef, + seekTo, + overlayFile, + setOverlayFile, + overlayPosition, + setOverlayPosition, + overlaySize, + setOverlaySize, + overlayOpacity, + setOverlayOpacity, + recommendedPreset, + toggleSound, + } = useVideoEditor(); const [copied, setCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false); @@ -178,7 +196,9 @@ export default function VideoEditor() { useEffect(() => { if (status === "done" && downloadRef.current) { - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; downloadRef.current.scrollIntoView({ behavior: prefersReducedMotion ? "instant" : "smooth", block: "center", @@ -187,11 +207,12 @@ export default function VideoEditor() { }, [status]); const isProcessing = status === "loading-engine" || status === "exporting"; - const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); + const isMac = + typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); const videoSrc = useMemo( () => (file ? URL.createObjectURL(file) : null), - [file] + [file], ); useEffect(() => { @@ -201,8 +222,15 @@ export default function VideoEditor() { }, [videoSrc]); return ( -
    - +
    +
    @@ -212,7 +240,6 @@ export default function VideoEditor() {
    -
    - No login. No ads. 100% private - your video never leaves your device. + No login. No ads. 100% private - your video never leaves your + device.
    -
    - + {!file && (
    @@ -246,7 +278,11 @@ export default function VideoEditor() { {file && (
    - +
    )}
    - {file && file.size > 100 * 1024 * 1024 && (

    ⚠️ Large file - processing may take several minutes

    )} {file && ( -
    +
    -
    } title="Trim" delay={50}> +
    } + title="Trim" + delay={50} + >
    -
    } title="Rotate" delay={100}> +
    } + title="Rotate" + delay={100} + >
    -
    } title="Audio & Speed" delay={150}> - - +
    } + title="Audio & Speed" + delay={150} + > +
    } @@ -316,7 +367,9 @@ export default function VideoEditor() { max="1" step="0.1" value={recipe.brightness} - onChange={(e) => updateRecipe({ brightness: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ brightness: Number(e.target.value) }) + } aria-label="Adjust brightness" className="w-full accent-film-600" /> @@ -341,7 +394,9 @@ export default function VideoEditor() { max="2" step="0.1" value={recipe.contrast} - onChange={(e) => updateRecipe({ contrast: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ contrast: Number(e.target.value) }) + } aria-label="Adjust contrast" className="w-full accent-film-600" /> @@ -366,20 +421,38 @@ export default function VideoEditor() { max="3" step="0.1" value={recipe.saturation} - onChange={(e) => updateRecipe({ saturation: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ saturation: Number(e.target.value) }) + } aria-label="Adjust saturation" className="w-full accent-film-600" />
    -
    } title="Output format" delay={190}> +
    } + title="Output format" + delay={190} + >
    -
    } title="Export quality" delay={200}> - +
    } + title="Export quality" + delay={200} + > +
    -
    } title="Image overlay" delay={120}> +
    } + title="Image overlay" + delay={120} + >
    )} - {status === "error" && error && (
    - +

    Error

    {error}

    @@ -429,24 +504,40 @@ export default function VideoEditor() { )}
    )} - {status === "done" && result && (
    - +
    )} + {" "}
    -
    -
    +
    +
    } title="Output size"> {recommendedPreset && (

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

    )} @@ -483,17 +574,21 @@ export default function VideoEditor() { type="button" onClick={handleExport} disabled={!file || isProcessing} - aria-label='Export video' + aria-label="Export video" aria-disabled={!file || isProcessing ? "true" : undefined} className={cn( "w-full flex items-center justify-center gap-3 py-5 min-h-[44px] rounded-xl", "font-display text-2xl tracking-widest transition-all duration-200", file && !isProcessing ? "bg-film-600 hover:bg-film-700 hover:scale-[1.01] text-white shadow-lg shadow-film-200 active:scale-[0.98] cursor-pointer" - : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed" + : "bg-[var(--border)] text-[var(--muted)] cursor-not-allowed", )} > - + + {isProcessing ? "PROCESSING" : "EXPORT"} @@ -507,4 +602,4 @@ export default function VideoEditor() {
    ); -} \ No newline at end of file +} diff --git a/src/hooks/useVideoEditor.ts b/src/hooks/useVideoEditor.ts index d894ed07..d6ee4f26 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -1,39 +1,82 @@ "use client"; import { useState, useCallback, useEffect, useRef, useMemo } from "react"; -import { EditRecipe, ExportResult, ExportStatus, MAX_FILE_SIZE, OverlayPosition, isValidRecipe } from "@/lib/types"; + +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 { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; + +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"; +interface ExportHistoryItem extends ExportResult { + id: string; + filename: string; + exportedAt: number; +} + +const DEFAULT_TITLE = + "Reframe — Resize, trim, and export videos in your browser"; + +const STORAGE_KEY = "reframe:recipe"; -export function extractMetadata(file: File): Promise<{ width: number; height: number; duration: number }> { +export function extractMetadata(file: File): Promise<{ + width: number; + height: number; + duration: number; +}> { return new Promise((resolve, reject) => { const url = URL.createObjectURL(file); + 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.") ); + + reject( + new Error( + "Video metaData load timeout — the file may be too large or the device too slow. Please try again.", + ), + ); }, 5000); video.preload = "metadata"; + video.onloadedmetadata = () => { - clearTimeout(timeout) + clearTimeout(timeout); + resolve({ width: video.videoWidth, height: video.videoHeight, duration: isFinite(video.duration) ? video.duration : 0, }); + URL.revokeObjectURL(url); }; + video.onerror = () => { - clearTimeout(timeout) + clearTimeout(timeout); + URL.revokeObjectURL(url); + reject(new Error("Failed to load video metadata")); }; + video.src = url; }); } @@ -41,58 +84,84 @@ export function extractMetadata(file: File): Promise<{ width: number; height: nu function verifyMagicBytes(file: File): Promise { return new Promise((resolve) => { const reader = new FileReader(); + reader.onloadend = (e) => { if (!e.target?.result) { resolve(false); return; } + const arr = new Uint8Array(e.target.result as ArrayBuffer); - const hex = Array.from(arr).map(b => b.toString(16).padStart(2, "0")).join("").toUpperCase(); + + 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); + 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); + else if (hex.startsWith("52494646")) { + resolve(true); + } + + // MP4 / MOV + else if (ascii.substring(0, 12).includes("ftyp")) { + resolve(true); + } else { + resolve(false); + } }; + reader.onerror = () => resolve(false); + reader.readAsArrayBuffer(file.slice(0, 12)); }); } -function validateRecipe(recipe: EditRecipe, duration: number ): string | null { +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.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).`, + `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), + 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), + 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.", @@ -109,216 +178,85 @@ function validateRecipe(recipe: EditRecipe, duration: number ): string | null { ], ]; - return ( - validations.find(([condition]) => condition)?.[1] ?? - null - ); + return validations.find(([condition]) => condition)?.[1] ?? null; } 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 [status, setStatus] = useState("idle"); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + + const [exportHistory, setExportHistory] = useState([]); + const [error, setError] = useState(null); + const [fileError, setFileError] = useState(""); + const exportAbortControllerRef = useRef(null); + const exportCancelledRef = useRef(false); + const videoRef = useRef(null); const [musicFile, setMusicFile] = useState(null); + const [musicVolume, setMusicVolume] = useState(70); + const [originalAudioVolume, setOriginalAudioVolume] = useState(40); + const [loopMusic, setLoopMusic] = useState(false); const [overlayFile, setOverlayFile] = useState(null); - const [overlayPosition, setOverlayPosition] = useState("bottom-right"); - 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 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; - } - }; - - useEffect(() => { - if (typeof window === "undefined") return; - try { - const params = new URLSearchParams(window.location.search); - const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array; - const hasRecipeParams = recipeKeys.some(key => params.has(key)); - - if (hasRecipeParams) { - const updatedPatch: Partial = {}; - recipeKeys.forEach((key) => { - const paramVal = params.get(key); - if (paramVal !== null) { - const defaultType = typeof DEFAULT_RECIPE[key]; - let parsedVal: any; - - if (defaultType === "number") { - parsedVal = parseFloat(paramVal); - } else if (defaultType === "boolean") { - parsedVal = paramVal === "true"; - } else { - parsedVal = paramVal === "null" ? null : paramVal; - } - - if (isValidValue(key, parsedVal)) { - (updatedPatch as any)[key] = parsedVal; - } - } - }); + const [overlayPosition, setOverlayPosition] = + useState("bottom-right"); - if (Object.keys(updatedPatch).length > 0) { - setRecipe(prev => ({ - ...prev, - ...updatedPatch - })); - } - } else { - // Try full recipe restore first (new key) - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw); - if (isValidRecipe(parsed)) { - setRecipe(parsed); - return; - } - } - } catch { - // ignore parse/validation errors and fall back to legacy - } - - // Legacy partial settings (keep for backward compatibility) - const saved = localStorage.getItem("reframe-settings"); - if (saved) { - const parsed = JSON.parse(saved); - setRecipe(prev => ({ - ...prev, - preset: parsed.preset ?? prev.preset, - quality: parsed.quality ?? prev.quality, - speed: parsed.speed ?? prev.speed, - customWidth: parsed.customWidth ?? prev.customWidth, - customHeight: parsed.customHeight ?? prev.customHeight - })); - } - } - } catch (e) { - // ignore - } - }, []); - - 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]; + const [overlaySize, setOverlaySize] = useState(150); - if (currentVal !== defaultVal) { - params.set(key, currentVal === null ? "null" : String(currentVal)); - } - }); + const [overlayOpacity, setOverlayOpacity] = useState(100); - const newQuery = params.toString(); - const currentQuery = window.location.search.replace(/^\?/, ""); + const updateRecipe = useCallback((patch: Partial) => { + setRecipe((prev) => { + const next = { + ...prev, + ...patch, + }; - if (newQuery !== currentQuery) { - const newUrl = newQuery - ? `${window.location.pathname}?${newQuery}` - : window.location.pathname; - window.history.replaceState(null, "", newUrl); + if (next.format === "gif") { + next.keepAudio = false; } - } 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]); + return next; + }); + }, []); const recommendedPreset = useMemo(() => { if (!videoMetadata) return null; - return getPresetById(suggestPreset(videoMetadata.width, videoMetadata.height)) ?? null; + + return ( + getPresetById(suggestPreset(videoMetadata.width, videoMetadata.height)) ?? + null + ); }, [videoMetadata]); const handleFileSelect = useCallback(async (selectedFile: File) => { @@ -327,71 +265,75 @@ export function useVideoEditor() { setError(null); setFile(null); setVideoMetadata(null); + if (!selectedFile.type.startsWith("video/")) { setFileError("Please upload a video file only."); + return; } setFileError(""); - // LAYER 0: Size check if (selectedFile.size > MAX_FILE_SIZE) { - setError(`Validation Failed: File too large. Maximum size is 2GB.`); - setStatus("error"); - return; - } + setError("Validation Failed: File too large. Maximum size is 2GB."); - const validExtensions = ['.mp4', '.mov', '.avi', '.webm', '.mkv']; - const filename = selectedFile.name.toLowerCase(); - const hasValidExtension = validExtensions.some(ext => filename.endsWith(ext)); - if (!hasValidExtension) { - setError(`Layer 1 Validation Failed: Invalid file extension. Expected one of: ${validExtensions.join(', ')}`); setStatus("error"); - return; - } - if (!selectedFile.type.startsWith("video/")) { - setError(`Layer 2 Validation Failed: Invalid MIME type. Expected video/*, got ${selectedFile.type || 'unknown'}`); - setStatus("error"); return; } 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."); + setError("Layer 3 Validation Failed: Invalid file content."); + setStatus("error"); + return; } try { - const { width, height, duration: dur } = await extractMetadata(selectedFile); + const { + width, + height, + 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 } : {}), - }; + + setVideoMetadata({ + width, + height, + duration: dur, }); + + setFile(selectedFile); + + setRecipe((prev) => ({ + ...prev, + trimStart: 0, + trimEnd: null, + })); } catch (err) { - setError(`Layer 4 Validation Failed: ${err instanceof Error ? err.message : "Unknown error"}`); + setError( + `Layer 4 Validation Failed: ${ + err instanceof Error ? err.message : "Unknown error" + }`, + ); + setStatus("error"); } }, []); 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"); @@ -399,17 +341,19 @@ export function useVideoEditor() { } const abortController = new AbortController(); + exportAbortControllerRef.current = abortController; + exportCancelledRef.current = false; try { setStatus("loading-engine"); setProgress(0); setError(null); - if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); setResult(null); const ffmpeg = await loadFFmpeg(abortController.signal); + if (exportCancelledRef.current) return; setStatus("exporting"); @@ -431,47 +375,80 @@ export function useVideoEditor() { position: overlayPosition, size: overlaySize, opacity: overlayOpacity, - } + }, ); + if (exportCancelledRef.current) return; setResult(exportResult); + + const historyItem: ExportHistoryItem = { + ...exportResult, + id: crypto.randomUUID(), + filename: file.name, + exportedAt: Date.now(), + }; + + setExportHistory((prev) => { + const updated = [historyItem, ...prev]; + + if (updated.length > 5) { + const removed = updated.pop(); + + if (removed?.blobUrl) { + URL.revokeObjectURL(removed.blobUrl); + } + } + + return updated; + }); + setStatus("done"); - } catch (err) { + } catch (err) { if (exportCancelledRef.current) return; console.error("export failed:", err); + if (err instanceof FFmpegLoadError) { setError(err.message); - } else if (err instanceof Error && err.message.includes('network')) { - setError('Network error. Check your internet connection and try again.'); - } else if (err instanceof Error && err.message.includes('codec')) { - setError('This video format is not supported. Try converting to MP4 first.'); } else { - setError('Export failed. Please try again or use a different video.'); + 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, loopMusic, musicFile, musicVolume, originalAudioVolume]); - + }, [ + file, + recipe, + status, + overlayFile, + overlayPosition, + overlaySize, + overlayOpacity, + duration, + musicFile, + musicVolume, + originalAudioVolume, + loopMusic, + ]); useEffect(() => { if (status === "exporting") { document.title = `Exporting ${progress}% | Reframe`; } else if (status === "loading-engine") { - document.title = `Loading engine... | Reframe`; + document.title = "Loading engine... | Reframe"; } else if (status === "done") { - document.title = `Export complete | Reframe`; + document.title = "Export complete | Reframe"; } else if (file) { document.title = `Editing: ${file.name} | Reframe`; } else { document.title = DEFAULT_TITLE; } + return () => { document.title = DEFAULT_TITLE; }; @@ -490,9 +467,10 @@ export function useVideoEditor() { }; window.addEventListener("beforeunload", handler); + return () => window.removeEventListener("beforeunload", handler); }, [status]); - + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if ( @@ -507,19 +485,22 @@ export function useVideoEditor() { }; document.addEventListener("keydown", handleKeydown); + return () => { document.removeEventListener("keydown", handleKeydown); }; }, [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; + 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" || @@ -528,51 +509,72 @@ export function useVideoEditor() { return; } - setRecipe((prev) => ({ ...prev, keepAudio: !prev.keepAudio })); + setRecipe((prev) => ({ + ...prev, + keepAudio: !prev.keepAudio, + })); }; document.addEventListener("keydown", handleMuteShortcut); + return () => { document.removeEventListener("keydown", handleMuteShortcut); }; }, [file]); - useEffect(()=>{ - return ()=>{ - if(result?.blobUrl){ + useEffect(() => { + return () => { + if (result?.blobUrl) { URL.revokeObjectURL(result.blobUrl); } - } - },[result?.blobUrl]) - useEffect(() => { - return () => { + exportHistory.forEach((item) => { + if (item.blobUrl) { + URL.revokeObjectURL(item.blobUrl); + } + }); + terminateFFmpeg(); }; - }, []); + }, [result, exportHistory]); + + const clearExportHistory = useCallback(() => { + exportHistory.forEach((item) => { + if (item.blobUrl) { + URL.revokeObjectURL(item.blobUrl); + } + }); + + setExportHistory([]); + }, [exportHistory]); const resetSettings = useCallback(() => { setRecipe(DEFAULT_RECIPE); + try { localStorage.removeItem(STORAGE_KEY); - } catch { - // ignore - } + } catch {} }, []); const cancelExport = useCallback(() => { exportCancelledRef.current = true; + exportAbortControllerRef.current?.abort(); + exportAbortControllerRef.current = null; + terminateFFmpeg(); + setStatus("idle"); setProgress(0); setError(null); }, []); - const reset = useCallback(() => { - if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl); + if (result?.blobUrl) { + URL.revokeObjectURL(result.blobUrl); + } + setFile(null); setVideoMetadata(null); setDuration(0); @@ -581,17 +583,18 @@ export function useVideoEditor() { setProgress(0); setResult(null); setError(null); + try { localStorage.removeItem(STORAGE_KEY); } catch { // ignore } - }, [result]); - + }, [result?.blobUrl]); useEffect(() => { localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); }, [recipe.soundOnCompletion]); + const seekTo = useCallback((time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; @@ -599,8 +602,10 @@ export function useVideoEditor() { }, []); const toggleSound = useCallback(() => { - updateRecipe({ soundOnCompletion: !recipe.soundOnCompletion }); -}, [recipe.soundOnCompletion, updateRecipe]); + updateRecipe({ + soundOnCompletion: !recipe.soundOnCompletion, + }); + }, [recipe.soundOnCompletion, updateRecipe]); return { file, @@ -609,6 +614,8 @@ export function useVideoEditor() { status, progress, result, + exportHistory, + clearExportHistory, error, videoRef, seekTo, @@ -638,4 +645,4 @@ export function useVideoEditor() { recommendedPreset, toggleSound, }; -} \ No newline at end of file +} From e051da89808ec423fb115f9f6982c3ac375d96f6 Mon Sep 17 00:00:00 2001 From: Shiv Date: Fri, 22 May 2026 00:29:34 +0530 Subject: [PATCH 2/2] style: clean JSX formatting --- src/components/VideoEditor.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 4d6358c0..c00f45f0 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -505,7 +505,11 @@ export default function VideoEditor() {
    )} {status === "done" && result && ( -
    +
    {" "} + />