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
190 changes: 62 additions & 128 deletions src/components/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
const lastId = useRef(0);
const urlRef = useRef<string | null>(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;
Expand All @@ -34,7 +34,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {

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");
Expand All @@ -56,10 +55,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
setIsLoading(true);
const id = ++lastId.current;
const url = URL.createObjectURL(file);

if (urlRef.current) {
URL.revokeObjectURL(urlRef.current);
}
urlRef.current = url;

const video = videoRef.current;
Expand All @@ -74,21 +69,18 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
};

onLoadedRef.current = handleLoaded;

video.addEventListener("loadeddata", handleLoaded);

return () => {
if (onLoadedRef.current) {
video.removeEventListener("loadeddata", onLoadedRef.current);
onLoadedRef.current = null;
}

if (video) {
video.pause();
video.removeAttribute("src");
video.load();
}

if (urlRef.current === url) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
Expand All @@ -106,157 +98,99 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) {
videoRef.current.playbackRate = recipe.speed;
}, [recipe, videoRef]);

const overlay = (() => {
if (!recipe || !showOverlay) return null;

const preset = recipe.preset === "custom"
// --- Absolute WYSIWYG Canvas Math ---
const activePreset = recipe
? 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;
: getPresetById(recipe.preset)
: undefined;

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

let boxTop = 0, boxBottom = 0, boxLeft = 0, boxRight = 0;

if (outputRatio > containerRatio) {
// Ultrawide (e.g. 21:9) -> Hits left/right walls, letterboxed on top/bottom
const boxHeightPct = (containerRatio / outputRatio) * 100;
const barH = (100 - boxHeightPct) / 2;
boxTop = barH;
boxBottom = barH;
} else {
// Vertical (e.g. 9:16) -> Hits top/bottom walls, pillarboxed on left/right
const boxWidthPct = (outputRatio / containerRatio) * 100;
const barW = (100 - boxWidthPct) / 2;
boxLeft = barW;
boxRight = barW;
}

const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.code === "Space") {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}

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();
}
e.preventDefault();
if (video.paused) video.play().catch(() => {});
else video.pause();
}
}
};

if (!file) return null;

return (
<div
role="group"
className="relative w-full rounded-lg overflow-hidden bg-[#0a0a0a] aspect-video focus:outline-none focus-visible:ring-2 focus-visible:ring-film-500"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label="Video preview (press Space to play/pause)"
className="relative w-full rounded-lg bg-[#0a0a0a] aspect-video focus:outline-none focus-visible:ring-2 focus-visible:ring-film-500 overflow-hidden"
>
{isLoading && (
<div
className="absolute inset-0 animate-pulse bg-gray-700 rounded-xl transition-opacity duration-300"
className="absolute inset-0 animate-pulse bg-gray-700 z-20"
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-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,
}}
/>
</>
{/* The WYSIWYG Inner Canvas Boundary */}
<div
className="absolute flex items-center justify-center overflow-hidden transition-all duration-300 ease-in-out ring-1 ring-white/10 shadow-2xl bg-black"
style={{
top: `${boxTop}%`,
bottom: `${boxBottom}%`,
left: `${boxLeft}%`,
right: `${boxRight}%`,
}}
>
<video
ref={videoRef}
controls
onLoadedData={() => setIsLoading(false)}
className={cn(
"w-full h-full transition-all duration-300 ease-in-out",
recipe?.framing === "fill" ? "object-cover" : "object-contain",
isLoading ? "opacity-0" : "opacity-100"
)}
</div>
)}

{/* 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-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"}
style={{
transform: recipe ? `rotate(${recipe.rotate}deg)` : "none",
filter: recipe ? `brightness(${recipe.brightness + 1}) contrast(${recipe.contrast}) saturate(${recipe.saturation})` : "none",
}}
playsInline
muted={!recipe?.keepAudio}
>
{showOverlay ? "Hide overlay" : "Show overlay"}
</button>
)}
<track kind="captions" />
</video>
</div>

{/* Grab frame button */}
{!isLoading && (
<button
type="button"
onClick={handleGrabFrame}
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-heading font-bold uppercase tracking-wider rounded transition-colors z-10 pointer-events-auto bg-black/60 text-white/70 hover:bg-black/80 flex items-center gap-1"
className="absolute top-2 right-2 px-2 py-1 text-[10px] font-heading font-bold uppercase tracking-wider rounded transition-colors z-20 pointer-events-auto bg-black/60 text-white/70 hover:bg-black/80 flex items-center gap-1"
aria-label="Grab frame as PNG"
title="Download current frame as PNG"
>
Expand Down
44 changes: 24 additions & 20 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,31 +262,35 @@ export function useVideoEditor() {

useEffect(() => {
if (typeof window === "undefined") return;
try {
const params = new URLSearchParams();
const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array<keyof EditRecipe>;
const timeoutId = window.setTimeout(() => {
try {
const params = new URLSearchParams();
const recipeKeys = Object.keys(DEFAULT_RECIPE) as Array<keyof EditRecipe>;

recipeKeys.forEach((key) => {
const currentVal = recipe[key];
const defaultVal = DEFAULT_RECIPE[key];
recipeKeys.forEach((key) => {
const currentVal = recipe[key];
const defaultVal = DEFAULT_RECIPE[key];

if (currentVal !== defaultVal) {
params.set(key, currentVal === null ? "null" : String(currentVal));
}
});
if (currentVal !== defaultVal) {
params.set(key, currentVal === null ? "null" : String(currentVal));
}
});

const newQuery = params.toString();
const currentQuery = window.location.search.replace(/^\?/, "");
const newQuery = params.toString();
const currentQuery = window.location.search.replace(/^\?/, "");

if (newQuery !== currentQuery) {
const newUrl = newQuery
? `${window.location.pathname}?${newQuery}`
: window.location.pathname;
window.history.replaceState(null, "", newUrl);
if (newQuery !== currentQuery) {
const newUrl = newQuery
? `${window.location.pathname}?${newQuery}`
: window.location.pathname;
window.history.replaceState(null, "", newUrl);
}
} catch (e) {
// ignore
}
} catch (e) {
// ignore
}
}, 500);

return () => window.clearTimeout(timeoutId);
}, [recipe]);

useEffect(() => {
Expand Down