Skip to content
Open
Show file tree
Hide file tree
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
108 changes: 28 additions & 80 deletions src/components/ComparisonPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,43 +19,11 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
const [isDragging, setIsDragging] = useState(false);
const containerRef = useRef<HTMLDivElement>(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}%` };
}
}
})();
const preset = recipe?.preset === "custom"
? { width: recipe.customWidth, height: recipe.customHeight }
: recipe ? getPresetById(recipe.preset) : null;

const outputRatio = preset ? preset.width / preset.height : 16 / 9;

// Load video source for both left and right videos
useEffect(() => {
Expand Down Expand Up @@ -188,51 +156,31 @@ export default function ComparisonPreview({ file, recipe, videoRef }: Props) {
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
<div className="absolute inset-0 flex items-center justify-center" style={{ left: `-${sliderPosition}%`, right: 0 }}>
<div
className="relative bg-black transition-all duration-300"
style={{
aspectRatio: `${outputRatio}`,
width: outputRatio > (16 / 9) ? '100%' : 'auto',
height: outputRatio <= (16 / 9) ? '100%' : 'auto',
maxWidth: '100%',
maxHeight: '100%'
}}
>
<track kind="captions" />
</video>
</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,
}}
/>
</>
)}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={rightVideoRef}
className="w-full h-full"
style={{ objectFit: recipe?.framing === "fit" ? "contain" : "cover" }}
playsInline
muted
autoPlay
loop
>
<track kind="captions" />
</video>
</div>
)}
</div>
</div>

{/* Draggable divider slider */}
Expand Down
183 changes: 61 additions & 122 deletions src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,12 @@ export default function VideoPreview({
const lastId = useRef(0);
const urlRef = useRef<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [showOverlay, setShowOverlay] = useState(false);
const [showComparison, setShowComparison] = useState(false);
const [containerDimensions, setContainerDimensions] = useState({
width: 0,
height: 0,
});
const previewContainerRef = useRef<HTMLDivElement>(null);
const outputFrameRef = useRef<HTMLDivElement>(null);
const onLoadedRef = useRef<(() => void) | null>(null);

const handleGrabFrame = useCallback(() => {
Expand Down Expand Up @@ -125,67 +124,39 @@ export default function VideoPreview({
}, [recipe, videoRef]);

/**
* Track preview container dimensions for text overlay positioning.
* Track output frame dimensions for accurate text overlay positioning.
*/
useEffect(() => {
const updateDimensions = () => {
if (previewContainerRef.current) {
const rect = previewContainerRef.current.getBoundingClientRect();
if (outputFrameRef.current) {
const rect = outputFrameRef.current.getBoundingClientRect();
setContainerDimensions({
width: rect.width,
height: rect.height,
});
}
};

// Need to observe the element directly in case layout changes without window resize
const observer = new ResizeObserver(updateDimensions);
if (outputFrameRef.current) {
observer.observe(outputFrameRef.current);
}

updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);

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}%` };
}
}
})();

return () => {
observer.disconnect();
window.removeEventListener("resize", updateDimensions);
};
}, [recipe?.preset, recipe?.customWidth, recipe?.customHeight, recipe?.framing]);

const preset = recipe?.preset === "custom"
? { width: recipe.customWidth, height: recipe.customHeight }
: recipe ? getPresetById(recipe.preset) : null;

const outputRatio = preset ? preset.width / preset.height : 16 / 9;

if (!file) return null;

Expand Down Expand Up @@ -215,92 +186,60 @@ export default function VideoPreview({
return (
<>
<div
ref={previewContainerRef}
role="group"
className="relative w-full rounded-lg overflow-hidden bg-[var(--bg)] aspect-video focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
className="relative w-full rounded-lg overflow-hidden bg-[var(--bg)] aspect-video focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] flex items-center justify-center"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label="Video preview (press Space to play/pause)"
>
{isLoading && (
<div
className="absolute inset-0 animate-pulse bg-[var(--surface)] rounded-xl transition-opacity duration-300"
className="absolute inset-0 animate-pulse bg-[var(--surface)] transition-opacity duration-300"
aria-label="Loading video preview"
/>
)}
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
controls
className={cn("w-full h-full object-contain transition-opacity duration-300", isLoading ? "opacity-0" : "opacity-100")}
onLoadedData={() => setIsLoading(false)}
playsInline
muted={!recipe?.keepAudio}
>
<track kind="captions" />
</video>

{/* Letterbox / Crop overlay */}
{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-[color-mix(in_srgb,var(--bg)_60%,transparent)]" style={{ height: overlay.barTop }} />
<div className="absolute left-0 right-0 bottom-0 bg-[color-mix(in_srgb,var(--bg)_60%,transparent)]" style={{ height: overlay.barBottom }} />
<div className="absolute top-0 bottom-0 left-0 bg-[color-mix(in_srgb,var(--bg)_60%,transparent)]" style={{ width: overlay.barLeft }} />
<div className="absolute top-0 bottom-0 right-0 bg-[color-mix(in_srgb,var(--bg)_60%,transparent)]" style={{ width: overlay.barRight }} />
</>
) : (
// Fill/crop: dashed border around the surviving area, dimmed outside
<>
<div className="absolute left-0 right-0 top-0 bg-[var(--error-bg)]" style={{ height: overlay.barTop }} />
<div className="absolute left-0 right-0 bottom-0 bg-[var(--error-bg)]" style={{ height: overlay.barBottom }} />
<div className="absolute top-0 bottom-0 left-0 bg-[var(--error-bg)]" style={{ width: overlay.barLeft }} />
<div className="absolute top-0 bottom-0 right-0 bg-[var(--error-bg)]" 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,
}}
/>
</>

{/* Output Frame Wrapper */}
{recipe && (
<div
ref={outputFrameRef}
className={cn("relative flex items-center justify-center transition-all duration-300 bg-black", isLoading ? "opacity-0" : "opacity-100")}
style={{
aspectRatio: `${outputRatio}`,
width: outputRatio > (16 / 9) ? '100%' : 'auto',
height: outputRatio <= (16 / 9) ? '100%' : 'auto',
maxWidth: '100%',
maxHeight: '100%'
}}
>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
controls
className="w-full h-full"
style={{ objectFit: recipe.framing === "fit" ? "contain" : "cover" }}
onLoadedData={() => setIsLoading(false)}
playsInline
muted={!recipe?.keepAudio}
>
<track kind="captions" />
</video>

{/* Draggable Text Overlays */}
{!isLoading && containerDimensions.width > 0 && (
<DraggableTextOverlays
recipe={recipe}
containerWidth={containerDimensions.width}
containerHeight={containerDimensions.height}
selectedTextId={selectedTextId ?? null}
onSelectText={onSelectText || (() => {})}
onUpdateText={onUpdateText || (() => {})}
/>
)}
</div>
)}

{/* Draggable Text Overlays */}
{recipe && !isLoading && containerDimensions.width > 0 && (
<DraggableTextOverlays
recipe={recipe}
containerWidth={containerDimensions.width}
containerHeight={containerDimensions.height}
selectedTextId={selectedTextId ?? null}
onSelectText={onSelectText || (() => {})}
onUpdateText={onUpdateText || (() => {})}
/>
)}

{/* Toggle button */}
{recipe && !isLoading && (
<button
type="button"
onClick={() => 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-[var(--accent)] text-white"
: "bg-[var(--surface)] text-[var(--muted)] hover:bg-[var(--accent-muted)] hover:text-[var(--text)]"
}`}
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"}
</button>
)}

{/* Compare button */}
{recipe && !isLoading && (
Expand Down