diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 4e6f7e9d..1b38f88c 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -18,10 +18,10 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const lastId = useRef(0); const urlRef = useRef(null); const [isLoading, setIsLoading] = useState(true); - const [showOverlay, setShowOverlay] = useState(false); const [showComparison, setShowComparison] = 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; @@ -36,7 +36,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"); @@ -58,10 +57,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; @@ -76,7 +71,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { }; onLoadedRef.current = handleLoaded; - video.addEventListener("loadeddata", handleLoaded); return () => { @@ -84,13 +78,11 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { 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; @@ -108,162 +100,103 @@ 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) => { 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 ( <>
{isLoading && (
)} - {/* eslint-disable-next-line jsx-a11y/media-has-caption */} - - {/* Letterbox / Crop overlay */} - {overlay && ( -