Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 122 additions & 96 deletions src/components/ComparisonPreview.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,52 +12,63 @@ interface Props {
videoRef: RefObject<HTMLVideoElement | null>;
}

const CONTAINER_RATIO = 16 / 9; // matches the `aspect-video` wrapper below

export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
const leftVideoRef = useRef<HTMLVideoElement>(null);
const rightVideoRef = useRef<HTMLVideoElement>(null);
const [sliderPosition, setSliderPosition] = useState(50);
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const rightFrameRef = useRef<HTMLDivElement>(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);
Expand All @@ -74,14 +85,20 @@ 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;

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;
};

Expand All @@ -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]);
Expand Down Expand Up @@ -163,14 +176,38 @@ 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 (
<div
ref={containerRef}
className="relative w-full rounded-lg overflow-hidden bg-[#0a0a0a] aspect-video"
role="group"
aria-label="Video comparison preview"
>
{/* Left side: Original video — clipped to left of slider */}
{/* Left side: Original, unmodified video — clipped to left of slider */}
<div className="absolute inset-0 overflow-hidden" style={{ width: `${sliderPosition}%` }}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
Expand All @@ -181,58 +218,39 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
>
<track kind="captions" />
</video>
<span className="absolute top-2 left-2 px-2 py-1 rounded bg-black/60 text-white text-[10px] font-heading font-bold uppercase tracking-wider pointer-events-none">
Original
</span>
</div>

{/* Right side: Reframed video with overlay — clipped to right of slider */}
<div
className="absolute inset-0 overflow-hidden"
style={{ left: `${sliderPosition}%` }}
>
{/* Right side: Reframed preview — real crop/letterbox via CSS, matching the export pipeline */}
<div className="absolute inset-0 overflow-hidden" style={{ left: `${sliderPosition}%` }}>
<div className="absolute inset-0" style={{ left: `-${sliderPosition}%`, right: 0 }}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={rightVideoRef}
className="w-full h-full object-contain"
playsInline
muted
autoPlay
loop
>
<track kind="captions" />
</video>
<div className="absolute inset-0 flex items-center justify-center">
<div
ref={rightFrameRef}
className="relative bg-black border border-white/10 overflow-hidden"
style={frameStyle}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video ref={rightVideoRef} style={videoStyle} playsInline muted autoPlay loop>
<track kind="captions" />
</video>
</div>
</div>
</div>

{/* Overlay on reframed side */}
{overlay && (
<div className="absolute inset-0 pointer-events-none" aria-hidden="true">
{overlay.mode === "fit" ? (
// Letterbox: semi-transparent bars outside the content area
<>
<div className="absolute left-0 right-0 top-0 bg-black/50" style={{ height: overlay.barTop }} />
<div className="absolute left-0 right-0 bottom-0 bg-black/50" style={{ height: overlay.barBottom }} />
<div className="absolute top-0 bottom-0 left-0 bg-black/50" style={{ width: overlay.barLeft }} />
<div className="absolute top-0 bottom-0 right-0 bg-black/50" style={{ width: overlay.barRight }} />
</>
) : (
// Fill/crop: dashed border around the surviving area, dimmed outside
<>
<div className="absolute left-0 right-0 top-0 bg-red-900/50" style={{ height: overlay.barTop }} />
<div className="absolute left-0 right-0 bottom-0 bg-red-900/50" style={{ height: overlay.barBottom }} />
<div className="absolute top-0 bottom-0 left-0 bg-red-900/50" style={{ width: overlay.barLeft }} />
<div className="absolute top-0 bottom-0 right-0 bg-red-900/50" style={{ width: overlay.barRight }} />
<div
className="absolute border-2 border-dashed border-film-400"
style={{
top: overlay.barTop,
bottom: overlay.barBottom,
left: overlay.barLeft,
right: overlay.barRight,
}}
/>
</>
)}
</div>
)}
<span className="absolute top-2 right-2 px-2 py-1 rounded bg-black/60 text-white text-[10px] font-heading font-bold uppercase tracking-wider pointer-events-none text-right">
Reframed
{targetDims && (
<>
{" · "}
{recipe?.framing === "fill" ? "Fill" : "Fit"}
{" · "}
{targetDims.width}×{targetDims.height}
</>
)}
</span>
</div>

{/* Draggable divider slider */}
Expand All @@ -256,15 +274,23 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
aria-label="Drag to compare original vs reframed"
title="Drag left/right to compare"
>
<svg
className="w-4 h-4 text-black"
fill="currentColor"
viewBox="0 0 24 24"
>
<svg className="w-4 h-4 text-black" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 6h2v12H9V6zm4 0h2v12h-2V6z" />
</svg>
</button>
</div>
</div>
);
}

// Approximate, in-browser preview of the brightness/contrast/saturation
// adjustments applied at export time. Not pixel-identical to FFmpeg's `eq`
// filter, but gives a useful directional preview.
function colorFilter(recipe?: EditRecipe): string | undefined {
if (!recipe) return undefined;
if (recipe.brightness === 0 && recipe.contrast === 1 && recipe.saturation === 1) {
return undefined;
}
const brightness = Math.max(0, 1 + recipe.brightness);
return `brightness(${brightness}) contrast(${recipe.contrast}) saturate(${recipe.saturation})`;
}
Loading