diff --git a/src/app/page.tsx b/src/app/page.tsx index 637feae3..6e0d2725 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,6 @@ import VideoEditor from "@/components/VideoEditor"; import Footer from "@/components/Footer"; +import PrivacyBanner from "@/components/PrivacyBanner"; export default function Home() { return ( diff --git a/src/components/PrivacyBanner.tsx b/src/components/PrivacyBanner.tsx new file mode 100644 index 00000000..765d7913 --- /dev/null +++ b/src/components/PrivacyBanner.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const STORAGE_KEY = "privacy-banner-dismissed"; + +export default function PrivacyBanner() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const dismissed = localStorage.getItem( + STORAGE_KEY, + ); + + if (!dismissed) { + setVisible(true); + } + }, []); + + const handleDismiss = () => { + localStorage.setItem( + STORAGE_KEY, + "true", + ); + + setVisible(false); + }; + + if (!visible) return null; + + return ( +
+
+
+

+ Your videos never leave your + device. +

+ +

+ Reframe processes videos fully + in-browser using FFmpeg.wasm — + no uploads, no servers, 100% + private. +

+
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 31e20d93..fa850180 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -195,17 +195,26 @@ export default function VideoEditor() {
- {!file && (

Upload a video to get started

-

Supports MP4, MOV, WebM and more

+

+ Supports MP4, MOV, WebM and more +

)} {file && (
- +
} title="Rotate" delay={100}> @@ -466,4 +475,4 @@ export default function VideoEditor() {
); -} \ No newline at end of file +} diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 71095330..c3dc0dcc 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -1,104 +1,31 @@ -/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */ "use client"; -import { useEffect, useRef, useState, useCallback, RefObject } from "react"; +import { useEffect, useRef, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; import { getPresetById } from "@/lib/presets"; -import { cn } from "@/lib/utils"; -import { Camera } from "lucide-react"; - interface Props { file: File | null; - recipe?: EditRecipe; videoRef: RefObject; + recipe: EditRecipe; + overlayFile?: File | null; + overlayPosition?: string; + overlaySize?: number; + overlayOpacity?: number; } -export default function VideoPreview({ file, recipe, videoRef }: Props) { - const lastId = useRef(0); +export default function VideoPreview({ file, videoRef ,recipe }: Props) { const urlRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); - const [showOverlay, setShowOverlay] = useState(false); - const onLoadedRef = useRef<(() => void) | null>(null); - - /** Capture the current video frame and download it as a PNG. */ - const handleGrabFrame = useCallback(() => { - const video = videoRef.current; - if (!video || video.readyState < 2) return; - - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; - - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); - - canvas.toBlob((blob) => { - if (!blob) return; - - const totalSec = Math.floor(video.currentTime); - const mins = String(Math.floor(totalSec / 60)).padStart(2, "0"); - const secs = String(totalSec % 60).padStart(2, "0"); - const filename = `frame-${mins}m${secs}s.png`; - - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }, "image/png"); - }, [videoRef]); useEffect(() => { if (!file) return; if (urlRef.current) URL.revokeObjectURL(urlRef.current); - setIsLoading(true); - const id = ++lastId.current; const url = URL.createObjectURL(file); - - // cleanup previous object URL safely - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - } urlRef.current = url; - - const video = videoRef.current; - if (!video) return; - - video.src = url; - video.load(); - - // define handler once per effect run - const handleLoaded = () => { - if (lastId.current !== id) return; - video.play().catch(() => {}); - }; - - onLoadedRef.current = handleLoaded; - - video.addEventListener("loadeddata", handleLoaded); + if (videoRef.current) videoRef.current.src = url; return () => { - // cleanup event listener safely - if (onLoadedRef.current) { - video.removeEventListener("loadeddata", onLoadedRef.current); - onLoadedRef.current = null; - } - - // stop playback safely - if (video) { - video.pause(); - video.removeAttribute("src"); - video.load(); - } - - // revoke only if still current - if (urlRef.current === url) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } + if (urlRef.current) URL.revokeObjectURL(urlRef.current); }; }, [file, videoRef]); @@ -112,170 +39,43 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { if (!videoRef.current || !recipe) return; videoRef.current.playbackRate = recipe.speed; }, [recipe, videoRef]); - - /** - * Compute the overlay geometry for the selected preset + framing mode. - * The preview container always uses a 16:9 aspect-video box. - * We express widths/heights as percentage strings for CSS. - */ - const overlay = (() => { - if (!recipe || !showOverlay) return null; - - const preset = recipe.preset === "custom" - ? { width: recipe.customWidth, height: recipe.customHeight } - : getPresetById(recipe.preset); - - if (!preset) return null; - - // Preview container is 16:9 - const containerW = 16; - const containerH = 9; - const containerRatio = containerW / containerH; // 1.777… - const outputRatio = preset.width / preset.height; - - if (recipe.framing === "fit") { - // Letterbox: the output video fits entirely inside 16:9, padded with bars. - if (outputRatio > containerRatio) { - // Wider output → pillarbox bars on top & bottom - const contentH = (containerRatio / outputRatio) * 100; - const barH = (100 - contentH) / 2; - return { mode: "fit", barTop: `${barH}%`, barBottom: `${barH}%`, barLeft: "0", barRight: "0" }; - } else { - // Taller output → letterbox bars on left & right - const contentW = (outputRatio / containerRatio) * 100; - const barW = (100 - contentW) / 2; - return { mode: "fit", barTop: "0", barBottom: "0", barLeft: `${barW}%`, barRight: `${barW}%` }; - } - } else { - // Fill / crop: the output fills the entire 16:9 preview — show a box representing what survives the crop. - if (outputRatio < containerRatio) { - // Output is taller → crops top & bottom - const visibleH = (outputRatio / containerRatio) * 100; - const cropH = (100 - visibleH) / 2; - return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" }; - } else { - // Output is wider → crops left & right - const visibleW = (containerRatio / outputRatio) * 100; - const cropW = (100 - visibleW) / 2; - return { mode: "fill", barTop: "0", barBottom: "0", barLeft: `${cropW}%`, barRight: `${cropW}%` }; - } - } - })(); - - if (!file) return null; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.code === "Space") { - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { - return; - } - - const video = videoRef.current; - if (video) { - e.preventDefault(); // Prevent default page scroll - if (video.paused) { - video.play().catch(() => {}); - } else { - video.pause(); - } - } - } - }; - + const preset = + recipe.preset !== "custom" + ? getPresetById(recipe.preset) + : null; + + const previewWidth = + recipe.preset === "custom" + ? recipe.customWidth || 1920 + : preset?.width || 1920; + + const previewHeight = + recipe.preset === "custom" + ? recipe.customHeight || 1080 + : preset?.height || 1080; + + const aspectRatio = `${previewWidth}/${previewHeight}`; return (
- {isLoading && ( -
- )} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + - - {/* Letterbox / Crop overlay */} - {overlay && ( -