diff --git a/src/components/ColorGradePresets.tsx b/src/components/ColorGradePresets.tsx new file mode 100644 index 00000000..699d0998 --- /dev/null +++ b/src/components/ColorGradePresets.tsx @@ -0,0 +1,59 @@ +"use client"; + +interface ColorPreset { + label: string; + brightness: number; + contrast: number; + saturation: number; +} + +const PRESETS: ColorPreset[] = [ + { label: "Natural", brightness: 0, contrast: 1, saturation: 1 }, + { label: "Warm", brightness: 0.05, contrast: 1.05, saturation: 1.15 }, + { label: "Cool", brightness: 0, contrast: 1.05, saturation: 0.9 }, + { label: "Cinematic", brightness: -0.05, contrast: 1.2, saturation: 0.8 }, + { label: "Vivid", brightness: 0, contrast: 1.1, saturation: 1.3 }, + { label: "B&W", brightness: 0.15, contrast: 1.15, saturation: 0 }, +]; + +interface Props { + brightness: number; + contrast: number; + saturation: number; + onChange: (patch: { brightness: number; contrast: number; saturation: number }) => void; +} + +export default function ColorGradePresets({ brightness, contrast, saturation, onChange }: Props) { + const activePreset = PRESETS.find( + (p) => p.brightness === brightness && p.contrast === contrast && p.saturation === saturation + ); + + return ( +
+ {PRESETS.map((preset) => { + const isActive = activePreset?.label === preset.label; + return ( + + ); + })} +
+ ); +} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index db5c3793..06b36ba4 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -16,7 +16,7 @@ import ExportSettings from "./ExportSettings"; import ExportOverlay from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; import ImageOverlay from "./ImageOverlay" - +import ColorGradePresets from "./ColorGradePresets"; import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, @@ -64,37 +64,37 @@ function KeyboardShortcutsPanel() { const [open, setOpen] = useState(false); const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { - keys: [ - Ctrl, - +, - Shift, - +, - E - ], - label: "Export video", - }, - { - keys: [M], - label: "Toggle audio mute", - }, - { - keys: [R], - label: "Reset all settings", - }, - { - keys: [Esc], - label: "Cancel export", - }, - { - keys: [1, , 9], - label: "Switch preset by index", - }, - { - keys: [?], - label: "Toggle this panel", - }, -]; + { + keys: [ + Ctrl, + +, + Shift, + +, + E, + ], + label: "Export video", + }, + { + keys: [M], + label: "Toggle audio mute", + }, + { + keys: [R], + label: "Reset all settings", + }, + { + keys: [Esc], + label: "Cancel export", + }, + { + keys: [1, , 9], + label: "Switch preset by index", + }, + { + keys: [?], + label: "Toggle this panel", + }, + ]; return (
@@ -295,6 +295,13 @@ export default function VideoEditor() { title="Adjustments" delay={175} > + +
{/* Brightness */}
diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 71095330..48a94edc 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -3,9 +3,12 @@ import { useEffect, useRef, useState, useCallback, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; +import { buildColorGradeCssFilter } from "@/lib/colorGrade"; import { getPresetById } from "@/lib/presets"; import { cn } from "@/lib/utils"; import { Camera } from "lucide-react"; +import { captureFrameAsPng } from "@/lib/frame-export"; +import { DEFAULT_RECIPE } from "@/lib/constants"; interface Props { file: File | null; @@ -18,37 +21,79 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const urlRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [showOverlay, setShowOverlay] = useState(false); + const [frameNotice, setFrameNotice] = useState<{ + kind: "success" | "error"; + message: string; + } | null>(null); + const [isExportingFrame, setIsExportingFrame] = useState(false); + const isExportingFrameRef = useRef(false); const onLoadedRef = useRef<(() => void) | null>(null); + const activeRecipe = recipe ?? DEFAULT_RECIPE; + + useEffect(() => { + if (!frameNotice) return; + + const timeoutId = window.setTimeout(() => setFrameNotice(null), 2500); + return () => window.clearTimeout(timeoutId); + }, [frameNotice]); /** Capture the current video frame and download it as a PNG. */ - const handleGrabFrame = useCallback(() => { + const handleGrabFrame = useCallback(async () => { + if (isExportingFrameRef.current) return; + const video = videoRef.current; - if (!video || video.readyState < 2) return; + if (!video) { + setFrameNotice({ kind: "error", message: "No video frame is available yet." }); + return; + } + + isExportingFrameRef.current = true; + setIsExportingFrame(true); - const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + try { + const { blob, filename } = await captureFrameAsPng(video, activeRecipe); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + setFrameNotice({ kind: "success", message: `Saved ${filename}` }); + } catch (error) { + console.error("frame export failed:", error); + setFrameNotice({ + kind: "error", + message: error instanceof Error ? error.message : "Frame export failed.", + }); + } finally { + isExportingFrameRef.current = false; + setIsExportingFrame(false); + } + }, [activeRecipe, videoRef]); - const ctx = canvas.getContext("2d"); - if (!ctx) return; - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + useEffect(() => { + const handleShortcut = (e: KeyboardEvent) => { + if (e.repeat) return; - canvas.toBlob((blob) => { - if (!blob) return; + const target = e.target as HTMLElement | null; + if ( + target && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + 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"); - const filename = `frame-${mins}m${secs}s.png`; + if (e.code === "KeyT") { + e.preventDefault(); + void handleGrabFrame(); + } + }; - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - }, "image/png"); - }, [videoRef]); + window.addEventListener("keydown", handleShortcut); + return () => window.removeEventListener("keydown", handleShortcut); + }, [handleGrabFrame]); useEffect(() => { if (!file) return; @@ -58,7 +103,6 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const id = ++lastId.current; const url = URL.createObjectURL(file); - // cleanup previous object URL safely if (urlRef.current) { URL.revokeObjectURL(urlRef.current); } @@ -70,31 +114,26 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { video.src = url; video.load(); - // define handler once per effect run const handleLoaded = () => { if (lastId.current !== id) return; video.play().catch(() => {}); }; onLoadedRef.current = handleLoaded; - video.addEventListener("loadeddata", handleLoaded); return () => { - // cleanup event listener safely if (onLoadedRef.current) { video.removeEventListener("loadeddata", onLoadedRef.current); onLoadedRef.current = null; } - // stop playback safely if (video) { video.pause(); video.removeAttribute("src"); video.load(); } - // revoke only if still current if (urlRef.current === url) { URL.revokeObjectURL(urlRef.current); urlRef.current = null; @@ -102,16 +141,16 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { }; }, [file, videoRef]); - // sync mute state to video element + // sync mute state to video element (audio normalization) useEffect(() => { - if (!videoRef.current || !recipe) return; - videoRef.current.muted = !recipe.keepAudio; - }, [recipe, videoRef]); + if (!videoRef.current) return; + videoRef.current.muted = !activeRecipe.keepAudio; + }, [activeRecipe.keepAudio, videoRef]); useEffect(() => { - if (!videoRef.current || !recipe) return; - videoRef.current.playbackRate = recipe.speed; - }, [recipe, videoRef]); + if (!videoRef.current) return; + videoRef.current.playbackRate = activeRecipe.speed; + }, [activeRecipe.speed, videoRef]); /** * Compute the overlay geometry for the selected preset + framing mode. @@ -119,42 +158,35 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { * We express widths/heights as percentage strings for CSS. */ const overlay = (() => { - if (!recipe || !showOverlay) return null; + if (!activeRecipe || !showOverlay) return null; - const preset = recipe.preset === "custom" - ? { width: recipe.customWidth, height: recipe.customHeight } - : getPresetById(recipe.preset); + const preset = activeRecipe.preset === "custom" + ? { width: activeRecipe.customWidth, height: activeRecipe.customHeight } + : getPresetById(activeRecipe.preset); if (!preset) return null; - // Preview container is 16:9 const containerW = 16; const containerH = 9; - const containerRatio = containerW / containerH; // 1.777… + const containerRatio = containerW / containerH; const outputRatio = preset.width / preset.height; - if (recipe.framing === "fit") { - // Letterbox: the output video fits entirely inside 16:9, padded with bars. + if (activeRecipe.framing === "fit") { 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}%` }; @@ -164,6 +196,8 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { if (!file) return null; + const colorFilter = buildColorGradeCssFilter(activeRecipe); + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.code === "Space") { const target = e.target as HTMLElement; @@ -177,7 +211,7 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { const video = videoRef.current; if (video) { - e.preventDefault(); // Prevent default page scroll + e.preventDefault(); if (video.paused) { video.play().catch(() => {}); } else { @@ -193,8 +227,22 @@ export default function VideoPreview({ file, recipe, videoRef }: Props) { 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)" + aria-label="Video preview (press Space to play/pause, T to export the current frame)" > + {frameNotice && ( +
+ {frameNotice.message} +
+ )} {isLoading && (
setIsLoading(false)} playsInline - muted={!recipe?.keepAudio} + muted={!activeRecipe.keepAudio} > - {/* Letterbox / Crop overlay */} {overlay && (