diff --git a/src/components/ExportSettings.tsx b/src/components/ExportSettings.tsx index ddc29a85..d79d13d7 100644 --- a/src/components/ExportSettings.tsx +++ b/src/components/ExportSettings.tsx @@ -137,54 +137,12 @@ export default function ExportSettings({ aria-label="Play sound when export completes" className="accent-film-600 cursor-pointer" /> + )}
-
- - - - - onChange({ - stabilization: e.target.checked, - }) - } - aria-label="Enable video stabilization" - aria-checked={recipe.stabilization} - className="w-full accent-film-600 cursor-pointer" - /> - -
- - {/* Short descriptive label explaining what the setting does */} -

- Reduce camera shake -

- -
- - Note: significantly increases processing time. - -
); diff --git a/src/components/StabilizationControl.tsx b/src/components/StabilizationControl.tsx new file mode 100644 index 00000000..0e69f569 --- /dev/null +++ b/src/components/StabilizationControl.tsx @@ -0,0 +1,46 @@ +import { SlidersHorizontal } from "lucide-react"; + +interface StabilizationControlProps { + recipe: { + stabilization?: boolean; + }; + + onChange: (updates: { stabilization: boolean }) => void; +} + +export default function StabilizationControl({ + recipe, + onChange, +}: StabilizationControlProps) { + return ( +
+ +
+ + + + onChange({ + stabilization: e.target.checked, + }) + } + className="h-4 w-4 accent-film-500 cursor-pointer" + /> +
+ +

+ Note: significantly increases processing time. +

+ +
+ ); +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index db5c3793..c296f63e 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,13 +14,22 @@ 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 StabilizationControl from "./StabilizationControl"; import { cn } from "@/lib/utils"; import { - Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + Layers, + Crop, + Scissors, + RotateCw, + Volume2, + SlidersHorizontal, + Zap, + AlertTriangle, + Copy, } from "lucide-react"; + import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -40,17 +48,19 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { >
{icon} +

{title}

+
+ {children}
); } -/** Inline keyboard hint badge. */ function Kbd({ children }: { children: React.ReactNode }) { return ( @@ -59,43 +69,54 @@ 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", - }, -]; - + 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", + }, + ]; return (
@@ -127,8 +158,12 @@ function KeyboardShortcutsPanel() { className="px-4 pb-3 space-y-2 border-t border-[var(--border)]" > {shortcuts.map(({ keys, label }) => ( -
  • +
  • {label} + {keys}
  • ))} @@ -140,45 +175,55 @@ 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, + 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); + const downloadRef = useRef(null); const handleCopyLink = () => { 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; + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; + downloadRef.current.scrollIntoView({ behavior: prefersReducedMotion ? "instant" : "smooth", block: "center", @@ -187,32 +232,45 @@ 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(() => { 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}`}
    -
    REFRAME +

    Your video, any format

    +
    - 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 && (

    Upload a video to get started

    +

    Supports MP4, MOV, WebM and more

    )} {file && (
    - +
    )} + {file && ( -
    +
    + {/* LEFT COLUMN */}
    -
    } title="Trim" delay={50}> +
    } + title="Timeline" + delay={50} + >
    -
    } title="Rotate" delay={100}> + +
    } + title="Transform" + delay={100} + >
    + +
    } + title="Quality & Format" + delay={200} + > +
    + + + +
    +
    + +
    } + title="Processing" + delay={225} + > + +
    -
    -
    } title="Audio & Speed" delay={150}> - + {/* RIGHT COLUMN */} +
    +
    } + title="Playback" + delay={150} + > +
    +
    } - title="Adjustments" + title="Colour" delay={175} >
    @@ -300,15 +419,20 @@ export default function VideoEditor() {
    +
    + updateRecipe({ brightness: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ + brightness: Number(e.target.value), + }) + } aria-label="Adjust brightness" - className="w-full accent-film-600" + className="w-full" />
    + {/* Contrast */}
    +
    + updateRecipe({ contrast: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ + contrast: Number(e.target.value), + }) + } aria-label="Adjust contrast" - className="w-full accent-film-600" + className="w-full" />
    + {/* Saturation */}
    +
    + updateRecipe({ saturation: Number(e.target.value) })} + onChange={(e) => + updateRecipe({ + saturation: Number(e.target.value), + }) + } aria-label="Adjust saturation" - className="w-full accent-film-600" + className="w-full" />
    -
    } title="Output format" delay={190}> - -
    -
    } title="Export quality" delay={200}> - -
    -
    } title="Image overlay" delay={120}> + +
    } + title="Image Overlay" + delay={125} + > - + +

    Error

    +

    {error}

    + + {!error.includes("Validation Failed") && (
    )}
    -
    -
    -
    } title="Output size"> +
    +
    +
    } title="Format & 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, "")})

    )} +
    @@ -464,8 +635,10 @@ export default function VideoEditor() { className="flex items-center gap-1.5 text-xs font-heading font-bold uppercase tracking-widest text-film-500 hover:text-film-600 hover:opacity-100 transition-all cursor-pointer" > + {shareCopied ? "Copied!" : "Copy Link"} + @@ -507,4 +684,4 @@ export default function VideoEditor() {
    ); -} \ No newline at end of file +}