From 524a3fa4f29100ed9ff654601b7417e82b19d500 Mon Sep 17 00:00:00 2001 From: srinidhi-2006-bit Date: Thu, 21 May 2026 14:52:06 +0530 Subject: [PATCH] feat: add custom preset support --- src/components/PresetSelector.tsx | 261 +++++++++++++---------- src/components/VideoEditor.tsx | 342 +++++++++++++++++++++++------- src/hooks/useVideoEditor.ts | 23 +- src/lib/presets.ts | 130 ++++++++++-- 4 files changed, 552 insertions(+), 204 deletions(-) diff --git a/src/components/PresetSelector.tsx b/src/components/PresetSelector.tsx index c4bc7bb6..343f1f21 100644 --- a/src/components/PresetSelector.tsx +++ b/src/components/PresetSelector.tsx @@ -4,21 +4,44 @@ import { useCallback, useState } from "react"; import { Search, Settings2 } from "lucide-react"; -import { PRESETS } from "@/lib/presets"; +import { + PRESETS, + type CustomPreset, +} from "@/lib/presets"; + import { EditRecipe } from "@/lib/types"; import { cn } from "@/lib/utils"; interface Props { recipe: EditRecipe; onChange: (patch: Partial) => void; + + customPresets: CustomPreset[]; + savePreset: (name: string) => void; + deletePreset: (id: string) => void; + applyPreset: (preset: CustomPreset) => void; } -function getOrientationLabel(width: number, height: number): string { - const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b)); +function getOrientationLabel( + width: number, + height: number, +): string { + const gcd = ( + a: number, + b: number, + ): number => (b === 0 ? a : gcd(b, a % b)); + const divisor = gcd(width, height); + const ratio = `${width / divisor}:${height / divisor}`; + const orientation = - width === height ? "Square" : width > height ? "Landscape" : "Portrait"; + width === height + ? "Square" + : width > height + ? "Landscape" + : "Portrait"; + return `${orientation} (${ratio})`; } @@ -32,7 +55,9 @@ function RatioBox({ active: boolean; }) { const MAX = 32; + const ratio = width / height; + const [w, h] = ratio >= 1 ? [MAX, Math.max(4, Math.round(MAX / ratio))] @@ -42,7 +67,9 @@ function RatioBox({
@@ -56,7 +83,7 @@ const QUICK_ACTIONS = [ platform: "Instagram", icon: ( - + ), }, @@ -66,7 +93,7 @@ const QUICK_ACTIONS = [ platform: "TikTok", icon: ( - + ), }, @@ -76,7 +103,7 @@ const QUICK_ACTIONS = [ platform: "YouTube", icon: ( - + ), }, @@ -86,7 +113,7 @@ const QUICK_ACTIONS = [ platform: "YouTube", icon: ( - + ), }, @@ -96,7 +123,7 @@ const QUICK_ACTIONS = [ platform: "Twitter", icon: ( - + ), }, @@ -108,13 +135,18 @@ export default function PresetSelector({ recipe, onChange }: Props) { const filteredPresets = PRESETS.filter( (preset) => preset.id !== "custom" && - (preset.label.toLowerCase().includes(search.toLowerCase()) || - preset.platform.toLowerCase().includes(search.toLowerCase())), + (preset.label + .toLowerCase() + .includes(search.toLowerCase()) || + preset.platform + .toLowerCase() + .includes(search.toLowerCase())), ); const handlePresetSelect = useCallback( (presetId: string) => { onChange({ preset: presetId }); + setSearch(""); }, [onChange], @@ -148,7 +180,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { - ); - }) - )} - -
- + + +
+

+ {preset.label} +

+ +

+ {preset.platform} +

+
+ + ); + })} + {customPresets.length > 0 && ( +
+

+ Saved Presets +

+ + {customPresets.map((preset) => ( +
+ + {preset.name} + + +
+ + + +
+
+ ))} +
+ )} + {recipe.preset === "custom" && (
@@ -281,6 +319,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { > Width (px) + handleWidthChange(Number(e.target.value))} - className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + onChange={(e) => + handleWidthChange( + Number(e.target.value), + ) + } + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm" />
-
- - × - -
-
+ handleHeightChange(Number(e.target.value))} - className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm font-heading transition-all focus:outline-none focus:ring-2 focus:ring-film-400 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + onChange={(e) => + handleHeightChange( + Number(e.target.value), + ) + } + className="w-full rounded-md border border-[var(--border)] bg-[var(--bg)] px-3 py-2 text-sm" />
@@ -324,6 +366,7 @@ export default function PresetSelector({ recipe, onChange }: Props) { Ratio +
{getOrientationLabel( recipe.customWidth || 0, @@ -335,4 +378,4 @@ export default function PresetSelector({ recipe, onChange }: Props) { )}
); -} \ No newline at end of file +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 31e20d93..c0264ca7 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -1,8 +1,8 @@ "use client"; - import { useState, useRef, useEffect, useMemo } from "react"; import { useVideoEditor } from "@/hooks/useVideoEditor"; + import FileUpload from "./FileUpload"; import VideoPreview from "./VideoPreview"; import ThumbnailStrip from "./ThumbnailStrip"; @@ -15,6 +15,7 @@ import FormatSelector from "./FormatSelector"; import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; + import ImageOverlay from "./ImageOverlay" import { cn } from "@/lib/utils"; @@ -22,8 +23,25 @@ import { Layers, Crop, Scissors, RotateCw, Volume2, SlidersHorizontal, Zap, AlertTriangle, Github, Copy } from "lucide-react"; + +import ImageOverlay from "./ImageOverlay"; + import OnboardingTour from "./OnboardingTour"; +import { cn } from "@/lib/utils"; + +import { + Layers, + Crop, + Scissors, + RotateCw, + Volume2, + SlidersHorizontal, + Zap, + AlertTriangle, + Copy, +} from "lucide-react"; + interface SectionProps { icon: React.ReactNode; title: string; @@ -31,26 +49,39 @@ interface SectionProps { delay?: number; } -function Section({ icon, title, children, delay = 0 }: SectionProps) { +function Section({ + icon, + title, + children, + delay = 0, +}: SectionProps) { return (
- {icon} + + {icon} + +

{title}

+
+ {children}
); } -/** Inline keyboard hint badge. */ -function Kbd({ children }: { children: React.ReactNode }) { +function Kbd({ + children, +}: { + children: React.ReactNode; +}) { return ( {children} @@ -58,13 +89,27 @@ 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: [M], label: "Toggle audio mute" }, - { keys: [Ctrl, +, ], label: "Export video" }, + const shortcuts = [ + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [ + Ctrl, + + + + , + , + ], + label: "Export video", + }, ]; return ( @@ -80,15 +125,25 @@ function KeyboardShortcutsPanel() { Keyboard Shortcuts + @@ -98,9 +153,17 @@ function KeyboardShortcutsPanel() { className="px-4 pb-3 space-y-2 border-t border-[var(--border)]" > {shortcuts.map(({ keys, label }) => ( -
  • - {label} - {keys} +
  • + + {label} + + + + {keys} +
  • ))} @@ -111,109 +174,190 @@ function KeyboardShortcutsPanel() { export default function VideoEditor() { const { - file, duration, recipe, status, progress, - result, error, updateRecipe, - handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, + file, + duration, + recipe, + status, + progress, + result, + error, + + updateRecipe, + handleFileSelect, + fileError, + handleExport, + cancelExport, + reset, + resetSettings, + videoRef, seekTo, - overlayFile, setOverlayFile, - overlayPosition, setOverlayPosition, - overlaySize, setOverlaySize, - overlayOpacity, setOverlayOpacity, + + overlayFile, + setOverlayFile, + overlayPosition, + setOverlayPosition, + overlaySize, + setOverlaySize, + overlayOpacity, + setOverlayOpacity, + recommendedPreset, + toggleSound, + + + customPresets, + savePreset, + deletePreset, + applyPreset, + } = useVideoEditor(); + const [copied, setCopied] = useState(false); - const [shareCopied, setShareCopied] = useState(false); - const downloadRef = useRef(null); + + const [shareCopied, setShareCopied] = + useState(false); + + const downloadRef = + useRef(null); const handleCopyLink = () => { - if (typeof window === "undefined") return; - navigator.clipboard.writeText(window.location.href).then(() => { - setShareCopied(true); - setTimeout(() => setShareCopied(false), 2000); - }); + if (typeof window === "undefined") { + return; + } + + navigator.clipboard + .writeText(window.location.href) + .then(() => { + setShareCopied(true); + + setTimeout(() => { + setShareCopied(false); + }, 2000); + }); }; useEffect(() => { if (status === "done" && downloadRef.current) { - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; downloadRef.current.scrollIntoView({ - behavior: prefersReducedMotion ? "instant" : "smooth", + behavior: "smooth", block: "center", }); } }, [status]); + const isProcessing = status === "loading-engine" || status === "exporting"; const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); + const isProcessing = + status === "loading-engine" || + status === "exporting"; + + const videoSrc = useMemo( - () => (file ? URL.createObjectURL(file) : null), - [file] + () => + file + ? URL.createObjectURL(file) + : null, + [file], ); useEffect(() => { return () => { - if (videoSrc) URL.revokeObjectURL(videoSrc); + if (videoSrc) { + URL.revokeObjectURL(videoSrc); + } }; }, [videoSrc]); return ( -
    - +
    + + -
    - {status === "exporting" && `Exporting video: ${progress}%`} - {status === "done" && "Export complete! Video ready to download."} - {status === "error" && `Export failed: ${error}`} +
    + {status === "exporting" && + `Exporting video: ${progress}%`} + + {status === "done" && + "Export complete! Video ready to download."} + + {status === "error" && + `Export failed: ${error}`}
    -
    -
    +

    REFRAME

    +

    Your video, any format

    -
    - - No login. No ads. 100% private - your video never leaves your device. -
    -
    - + {!file && (
    +

    Upload a video to get started

    Supports MP4, MOV, WebM and more

    + +

    + Upload a video to get started +

    )} {file && (
    - +
    @@ -221,6 +365,7 @@ export default function VideoEditor() { )}
    + {file && file.size > 100 * 1024 * 1024 && (

    ⚠️ Large file - processing may take several minutes @@ -355,10 +500,15 @@ export default function VideoEditor() { )} {status === "error" && error && ( + + {status === "done" && result && ( +

    +

    Error

    @@ -392,43 +542,81 @@ export default function VideoEditor() { {status === "done" && result && (
    + + +
    )}
    -
    -
    -
    } title="Output size"> +
    +
    +
    } + title="Output size" + > {recommendedPreset && (

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

    )} - + +
    -
    } title="Framing" delay={100}> - +
    } + title="Framing" + > +
    + @@ -441,19 +629,31 @@ export default function VideoEditor() { id="export-button" type="button" onClick={handleExport} - disabled={!file || isProcessing} - aria-label='Export video' - aria-disabled={!file || isProcessing ? "true" : undefined} + disabled={ + !file || isProcessing + } 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" )} > {isProcessing ? "PROCESSING" : "EXPORT"} + + ? "bg-film-600 hover:bg-film-700 text-white" + : "bg-[var(--border)] text-[var(--muted)] opacity-40 cursor-not-allowed", + )} + > + + + {isProcessing + ? "PROCESSING" + : "EXPORT"} {file && !isProcessing && ( @@ -466,4 +666,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..1605a825 100644 --- a/src/hooks/useVideoEditor.ts +++ b/src/hooks/useVideoEditor.ts @@ -3,7 +3,12 @@ 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 { + getPresetById, + loadCustomPresets, + saveCustomPresets, + type CustomPreset, +} from "@/lib/presets"; import { loadFFmpeg, exportVideo, terminateFFmpeg, FFmpegLoadError } from "@/lib/ffmpeg"; import { suggestPreset } from "@/lib/presetSuggestion"; @@ -147,6 +152,8 @@ export function useVideoEditor() { const [overlayPosition, setOverlayPosition] = useState("bottom-right"); const [overlaySize, setOverlaySize] = useState(150); const [overlayOpacity, setOverlayOpacity] = useState(100); + const [customPresets, setCustomPresets] = + useState([]); const updateRecipe = useCallback((patch: Partial) => { setRecipe((prev) => { @@ -492,7 +499,7 @@ export function useVideoEditor() { window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [status]); - + useEffect(() => { const handleKeydown = (e: KeyboardEvent) => { if ( @@ -545,12 +552,6 @@ export function useVideoEditor() { } },[result?.blobUrl]) - useEffect(() => { - return () => { - terminateFFmpeg(); - }; - }, []); - const resetSettings = useCallback(() => { setRecipe(DEFAULT_RECIPE); try { @@ -592,6 +593,9 @@ export function useVideoEditor() { useEffect(() => { localStorage.setItem("soundOnCompletion", String(recipe.soundOnCompletion)); }, [recipe.soundOnCompletion]); + useEffect(() => { + setCustomPresets(loadCustomPresets()); + }, []); const seekTo = useCallback((time: number) => { if (videoRef.current) { videoRef.current.currentTime = time; @@ -636,6 +640,5 @@ export function useVideoEditor() { overlayOpacity, setOverlayOpacity, recommendedPreset, - toggleSound, }; -} \ No newline at end of file +} diff --git a/src/lib/presets.ts b/src/lib/presets.ts index 0ec7dee8..74acebe2 100644 --- a/src/lib/presets.ts +++ b/src/lib/presets.ts @@ -1,26 +1,128 @@ +import type { EditRecipe } from "./types"; + export interface Preset { id: string; label: string; - platform: string; // short platform label shown under the ratio visual + platform: string; width: number; height: number; } +export const CUSTOM_PRESET_KEY = "reframe-custom-presets"; + +export interface CustomPreset { + id: string; + name: string; + recipe: EditRecipe; +} + export const PRESETS: Preset[] = [ - { id: "vertical-9-16", label: "9 : 16", platform: "Reels · TikTok · Shorts", width: 1080, height: 1920 }, - { id: "instagram-4-5", label: "4 : 5", platform: "Instagram Feed", width: 1080, height: 1350 }, - { id: "square-1-1", label: "1 : 1", platform: "Square", width: 1080, height: 1080 }, - { id: "landscape-16-9", label: "16 : 9", platform: "YouTube · Landscape", width: 1920, height: 1080 }, - { id: "twitter-hd", label: "16 : 9", platform: "Twitter / X", width: 1280, height: 720 }, - { id: "ultrawide-21-9", label: "21 : 9", platform: "Ultrawide", width: 2560, height: 1080 }, - { id: "instagram-panoramic",label: "47 : 10", platform: "IG Panoramic", width: 5120, height: 1080 }, - { id: "portrait-3-4", label: "3 : 4", platform: "Portrait", width: 1080, height: 1440 }, - { id: "cinema-scope", label: "2.39 : 1", platform: "Anamorphic Cinema", width: 2048, height: 858 }, - { id: "dci-2k", label: "17 : 9", platform: "DCI 2K", width: 2048, height: 1080 }, - { id: "custom", label: "Custom", platform: "Set your own", width: 1920, height: 1080 }, + { + id: "vertical-9-16", + label: "9 : 16", + platform: "Reels · TikTok · Shorts", + width: 1080, + height: 1920, + }, + { + id: "instagram-4-5", + label: "4 : 5", + platform: "Instagram Feed", + width: 1080, + height: 1350, + }, + { + id: "square-1-1", + label: "1 : 1", + platform: "Square", + width: 1080, + height: 1080, + }, + { + id: "landscape-16-9", + label: "16 : 9", + platform: "YouTube · Landscape", + width: 1920, + height: 1080, + }, + { + id: "twitter-hd", + label: "16 : 9", + platform: "Twitter / X", + width: 1280, + height: 720, + }, + { + id: "ultrawide-21-9", + label: "21 : 9", + platform: "Ultrawide", + width: 2560, + height: 1080, + }, + { + id: "instagram-panoramic", + label: "47 : 10", + platform: "IG Panoramic", + width: 5120, + height: 1080, + }, + { + id: "portrait-3-4", + label: "3 : 4", + platform: "Portrait", + width: 1080, + height: 1440, + }, + { + id: "cinema-scope", + label: "2.39 : 1", + platform: "Anamorphic Cinema", + width: 2048, + height: 858, + }, + { + id: "dci-2k", + label: "17 : 9", + platform: "DCI 2K", + width: 2048, + height: 1080, + }, + { + id: "custom", + label: "Custom", + platform: "Set your own", + width: 1920, + height: 1080, + }, ]; -/** Returns the preset matching the given ID, or undefined if no match is found. */ export function getPresetById(id: string): Preset | undefined { - return PRESETS.find((p) => p.id === id); + return PRESETS.find((preset) => preset.id === id); +} + +export function loadCustomPresets(): CustomPreset[] { + if (typeof window === "undefined") { + return []; + } + + const raw = localStorage.getItem(CUSTOM_PRESET_KEY); + + if (!raw) { + return []; + } + + try { + return JSON.parse(raw); + } catch { + return []; + } +} + +export function saveCustomPresets( + presets: CustomPreset[], +) { + localStorage.setItem( + CUSTOM_PRESET_KEY, + JSON.stringify(presets), + ); }