From 412e5d242e8b771873c95c90c9ef646f74549b66 Mon Sep 17 00:00:00 2001 From: Pranav-IIITM Date: Sat, 23 May 2026 10:14:57 +0530 Subject: [PATCH] feat: add side-by-side comparison preview with draggable slider (#896) --- src/components/ComparisonPreview.tsx | 270 +++++++++++++++++++++++++++ src/components/VideoPreview.tsx | 200 +++++++++++--------- 2 files changed, 384 insertions(+), 86 deletions(-) create mode 100644 src/components/ComparisonPreview.tsx diff --git a/src/components/ComparisonPreview.tsx b/src/components/ComparisonPreview.tsx new file mode 100644 index 00000000..2fe4a89e --- /dev/null +++ b/src/components/ComparisonPreview.tsx @@ -0,0 +1,270 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +"use client"; + +import { useEffect, useRef, useState, useCallback, RefObject } from "react"; +import { EditRecipe } from "@/lib/types"; +import { getPresetById } from "@/lib/presets"; +import { cn } from "@/lib/utils"; + +interface Props { + file: File | null; + recipe?: EditRecipe; + videoRef: RefObject; +} + +export default function ComparisonPreview({ file, recipe, videoRef }: Props) { + const leftVideoRef = useRef(null); + const rightVideoRef = useRef(null); + const [sliderPosition, setSliderPosition] = useState(50); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + // Calculate overlay for the right (reframed) side + const overlay = (() => { + if (!recipe) return null; + + const preset = recipe.preset === "custom" + ? { width: recipe.customWidth, height: recipe.customHeight } + : getPresetById(recipe.preset); + + if (!preset) return null; + + const containerW = 16; + const containerH = 9; + const containerRatio = containerW / containerH; + const outputRatio = preset.width / preset.height; + + if (recipe.framing === "fit") { + if (outputRatio > containerRatio) { + const contentH = (containerRatio / outputRatio) * 100; + const barH = (100 - contentH) / 2; + return { mode: "fit", barTop: `${barH}%`, barBottom: `${barH}%`, barLeft: "0", barRight: "0" }; + } else { + const contentW = (outputRatio / containerRatio) * 100; + const barW = (100 - contentW) / 2; + return { mode: "fit", barTop: "0", barBottom: "0", barLeft: `${barW}%`, barRight: `${barW}%` }; + } + } else { + if (outputRatio < containerRatio) { + const visibleH = (outputRatio / containerRatio) * 100; + const cropH = (100 - visibleH) / 2; + return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" }; + } else { + const visibleW = (containerRatio / outputRatio) * 100; + const cropW = (100 - visibleW) / 2; + return { mode: "fill", barTop: "0", barBottom: "0", barLeft: `${cropW}%`, barRight: `${cropW}%` }; + } + } + })(); + + // Load video source for both left and right videos + useEffect(() => { + if (!file) return; + const url = URL.createObjectURL(file); + + if (leftVideoRef.current) { + leftVideoRef.current.src = url; + leftVideoRef.current.load(); + } + if (rightVideoRef.current) { + rightVideoRef.current.src = url; + rightVideoRef.current.load(); + } + + return () => URL.revokeObjectURL(url); + }, [file]); + + // Sync right video with left video and auto-play left + useEffect(() => { + const leftVideo = leftVideoRef.current; + const rightVideo = rightVideoRef.current; + + if (!leftVideo || !rightVideo || !file) return; + + const handleTimeUpdate = () => { + rightVideo.currentTime = leftVideo.currentTime; + }; + + const handlePlay = () => { + rightVideo.play().catch(() => {}); + }; + + const handlePause = () => { + rightVideo.pause(); + }; + + const handleRateChange = () => { + rightVideo.playbackRate = leftVideo.playbackRate; + }; + + const handleLoadedData = () => { + leftVideo.play().catch(() => {}); + }; + + leftVideo.addEventListener("timeupdate", handleTimeUpdate); + leftVideo.addEventListener("play", handlePlay); + leftVideo.addEventListener("pause", handlePause); + leftVideo.addEventListener("ratechange", handleRateChange); + leftVideo.addEventListener("loadeddata", handleLoadedData); + + return () => { + leftVideo.removeEventListener("timeupdate", handleTimeUpdate); + leftVideo.removeEventListener("play", handlePlay); + leftVideo.removeEventListener("pause", handlePause); + leftVideo.removeEventListener("ratechange", handleRateChange); + leftVideo.removeEventListener("loadeddata", handleLoadedData); + }; + }, [file, videoRef]); + + // Handle slider dragging (mouse + touch) + const handleMouseDown = useCallback(() => { + setIsDragging(true); + }, []); + + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const container = containerRef.current; + if (!container) return; + + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setSliderPosition(percentage); + }; + + const handleTouchMove = (e: TouchEvent) => { + const container = containerRef.current; + if (!container || !e.touches[0]) return; + + const rect = container.getBoundingClientRect(); + const x = e.touches[0].clientX - rect.left; + const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setSliderPosition(percentage); + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("touchmove", handleTouchMove, { passive: true }); + document.addEventListener("mouseup", handleMouseUp); + document.addEventListener("touchend", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("mouseup", handleMouseUp); + document.removeEventListener("touchend", handleMouseUp); + }; + }, [isDragging]); + + if (!file) return null; + + return ( +
+ {/* Left side: Original video — clipped to left of slider */} +
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + +
+ + {/* Right side: Reframed video with overlay — clipped to right of slider */} +
+
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + +
+ + {/* Overlay on reframed side */} + {overlay && ( +