diff --git a/src/components/ComparisonPreview.tsx b/src/components/ComparisonPreview.tsx index 2fe4a89e..b509902f 100644 --- a/src/components/ComparisonPreview.tsx +++ b/src/components/ComparisonPreview.tsx @@ -1,7 +1,7 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ "use client"; -import { useEffect, useRef, useState, useCallback, RefObject } from "react"; +import { useEffect, useRef, useState, useCallback, CSSProperties, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; @@ -12,52 +12,63 @@ interface Props { videoRef: RefObject; } +const CONTAINER_RATIO = 16 / 9; // matches the `aspect-video` wrapper below + 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); + const rightFrameRef = useRef(null); + const [frameSize, setFrameSize] = useState({ width: 0, height: 0 }); - // Calculate overlay for the right (reframed) side - const overlay = (() => { + // Resolve the actual output pixel dimensions the export will produce. + const targetDims = (() => { if (!recipe) return null; + const dims = + recipe.preset === "custom" + ? { width: recipe.customWidth, height: recipe.customHeight } + : getPresetById(recipe.preset); + if (!dims || dims.width <= 0 || dims.height <= 0) return null; + return dims; + })(); - 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}%` }; - } - } + const rotated = recipe?.rotate === 90 || recipe?.rotate === 270; + + // Size the "target frame" box so it's letterboxed/pillarboxed inside the + // 16:9 comparison view exactly like the real output dimensions would be, + // without distorting the aspect ratio. + const frameStyle: CSSProperties = (() => { + if (!targetDims) return { width: "100%", height: "100%" }; + + const outputRatio = targetDims.width / targetDims.height; + const aspectRatio = `${targetDims.width} / ${targetDims.height}`; + + return outputRatio >= CONTAINER_RATIO + ? { width: "100%", height: "auto", aspectRatio } + : { width: "auto", height: "100%", aspectRatio }; })(); - // Load video source for both left and right videos + // Track the rendered pixel size of the target frame. Needed so a 90/270 + // rotation can be applied to the video correctly (CSS percentages can't + // express "swap my width and height relative to my own box"). + useEffect(() => { + const el = rightFrameRef.current; + if (!el) return; + + const update = () => { + const rect = el.getBoundingClientRect(); + setFrameSize({ width: rect.width, height: rect.height }); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(el); + return () => observer.disconnect(); + }, [targetDims?.width, targetDims?.height]); + + // Load video source for both left (original) and right (reframed) videos useEffect(() => { if (!file) return; const url = URL.createObjectURL(file); @@ -74,7 +85,7 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { return () => URL.revokeObjectURL(url); }, [file]); - // Sync right video with left video and auto-play left + // Keep the right video's playhead locked to the left (original) video. useEffect(() => { const leftVideo = leftVideoRef.current; const rightVideo = rightVideoRef.current; @@ -82,6 +93,12 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { if (!leftVideo || !rightVideo || !file) return; const handleTimeUpdate = () => { + if (Math.abs(rightVideo.currentTime - leftVideo.currentTime) > 0.05) { + rightVideo.currentTime = leftVideo.currentTime; + } + }; + + const handleSeeking = () => { rightVideo.currentTime = leftVideo.currentTime; }; @@ -93,25 +110,21 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { rightVideo.pause(); }; - const handleRateChange = () => { - rightVideo.playbackRate = leftVideo.playbackRate; - }; - const handleLoadedData = () => { leftVideo.play().catch(() => {}); }; leftVideo.addEventListener("timeupdate", handleTimeUpdate); + leftVideo.addEventListener("seeking", handleSeeking); leftVideo.addEventListener("play", handlePlay); leftVideo.addEventListener("pause", handlePause); - leftVideo.addEventListener("ratechange", handleRateChange); leftVideo.addEventListener("loadeddata", handleLoadedData); return () => { leftVideo.removeEventListener("timeupdate", handleTimeUpdate); + leftVideo.removeEventListener("seeking", handleSeeking); leftVideo.removeEventListener("play", handlePlay); leftVideo.removeEventListener("pause", handlePause); - leftVideo.removeEventListener("ratechange", handleRateChange); leftVideo.removeEventListener("loadeddata", handleLoadedData); }; }, [file, videoRef]); @@ -163,6 +176,30 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) { if (!file) return null; + // Pre-rotation video box. When rotate is 90/270 the video's own box must + // be the *swapped* pixel dimensions of the frame so that, once rotated, + // it exactly fills the frame (percentages can't express this swap). + const videoStyle: CSSProperties = + rotated && frameSize.width > 0 && frameSize.height > 0 + ? { + position: "absolute", + top: "50%", + left: "50%", + width: `${frameSize.height}px`, + height: `${frameSize.width}px`, + transform: `translate(-50%, -50%) rotate(${recipe?.rotate}deg)`, + objectFit: recipe?.framing === "fill" ? "cover" : "contain", + filter: colorFilter(recipe), + } + : { + position: "absolute", + inset: 0, + width: "100%", + height: "100%", + objectFit: recipe?.framing === "fill" ? "cover" : "contain", + filter: colorFilter(recipe), + }; + return (
- {/* Left side: Original video — clipped to left of slider */} + {/* Left side: Original, unmodified video — clipped to left of slider */}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */} + + Original +
- {/* Right side: Reframed video with overlay — clipped to right of slider */} -
+ {/* Right side: Reframed preview — real crop/letterbox via CSS, matching the export pipeline */} +
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - +
+
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + +
+
- {/* Overlay on reframed side */} - {overlay && ( -