-
{!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 */}
+
setIsLoading(false)}
+ className={`w-full h-full ${
+ recipe.framing === "fill"
+ ? "object-cover"
+ : "object-contain"
+ }`}
playsInline
muted={!recipe?.keepAudio}
>
-
- {/* Letterbox / Crop overlay */}
- {overlay && (
-
- {overlay.mode === "fit" ? (
- // Letterbox: semi-transparent bars outside the content area
- <>
-
-
-
-
- >
- ) : (
- // Fill/crop: dashed border around the surviving area, dimmed outside
- <>
-
-
-
-
-
- >
- )}
-
- )}
-
- {/* Toggle button */}
- {recipe && !isLoading && (
-
setShowOverlay((v) => !v)}
- className={`absolute top-2 left-2 px-2 py-1 text-[10px] font-heading font-bold uppercase tracking-wider rounded transition-colors z-10 pointer-events-auto ${
- showOverlay
- ? "bg-film-600 text-white"
- : "bg-black/60 text-white/70 hover:bg-black/80"
- }`}
- aria-pressed={showOverlay}
- aria-label={showOverlay ? "Hide framing overlay" : "Show framing overlay"}
- title={showOverlay ? "Hide framing overlay" : "Show framing overlay"}
- >
- {showOverlay ? "Hide overlay" : "Show overlay"}
-
- )}
-
- {/* Grab frame button */}
- {!isLoading && (
-
-
- Grab frame
-
- )}
);
-}
\ No newline at end of file
+}