diff --git a/src/components/BeforeAfterSlider.tsx b/src/components/BeforeAfterSlider.tsx new file mode 100644 index 00000000..2a58cac1 --- /dev/null +++ b/src/components/BeforeAfterSlider.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { useRef, useState, useCallback, useEffect, RefObject } from "react"; + +interface Props { + videoRef: RefObject; + filterStyle: string; +} + +export default function BeforeAfterSlider({ videoRef, filterStyle }: Props) { + const [dividerX, setDividerX] = useState(50); + const containerRef = useRef(null); + const dragging = useRef(false); + + const updatePosition = useCallback((clientX: number) => { + const container = containerRef.current; + if (!container) return; + const rect = container.getBoundingClientRect(); + const x = Math.min(Math.max(clientX - rect.left, 0), rect.width); + setDividerX((x / rect.width) * 100); + }, []); + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (dragging.current) updatePosition(e.clientX); + }; + const onMouseUp = () => { dragging.current = false; }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [updatePosition]); + + useEffect(() => { + const onTouchMove = (e: TouchEvent) => { + if (dragging.current) updatePosition(e.touches[0].clientX); + }; + const onTouchEnd = () => { dragging.current = false; }; + window.addEventListener("touchmove", onTouchMove); + window.addEventListener("touchend", onTouchEnd); + return () => { + window.removeEventListener("touchmove", onTouchMove); + window.removeEventListener("touchend", onTouchEnd); + }; + }, [updatePosition]); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowLeft") setDividerX((x) => Math.max(0, x - 1)); + if (e.key === "ArrowRight") setDividerX((x) => Math.min(100, x + 1)); + }; + + const video = videoRef.current; + if (!video) return null; + + return ( +
+ {/* After side: same video with filter, clipped to right of divider */} +
+ ); +} \ No newline at end of file diff --git a/src/components/RotateControl.tsx b/src/components/RotateControl.tsx index db2cafe9..2ef47a40 100644 --- a/src/components/RotateControl.tsx +++ b/src/components/RotateControl.tsx @@ -9,33 +9,87 @@ interface Props { onChange: (patch: Partial) => void; } -const ROTATIONS = [0, 90, 180, 270] as const; +const PRESETS = [0, 90, 180, 270] as const; export default function RotateControl({ recipe, onChange }: Props) { + const rotation = recipe.rotate ?? 0; + + const handleSlider = (val: number) => { + onChange({ rotate: val }); + }; + + const handleInput = (val: string) => { + const n = parseFloat(val); + if (isNaN(n)) return; + const clamped = Math.min(180, Math.max(-180, n)); + onChange({ rotate: clamped }); + }; + return ( -
- {ROTATIONS.map((deg) => { - const active = recipe.rotate === deg; - return ( - - ); - })} +
+ + {/* Preset buttons */} +
+ {PRESETS.map((deg) => { + const active = rotation === deg; + return ( + + ); + })} +
+ + {/* Custom rotation slider */} +
+
+ +
+ handleInput(e.target.value)} + className="w-16 text-xs px-2 py-1 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading text-[var(--text)] focus:outline-none focus:ring-2 focus:ring-film-400 text-center" + /> + ° +
+
+ handleSlider(parseFloat(e.target.value))} + className="w-full h-1.5 appearance-none bg-[var(--border)] rounded-full cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-film-400 [&::-webkit-slider-thumb]:shadow-md" + /> +
+ -180° + + 180° +
+
+
); -} +} \ No newline at end of file diff --git a/src/components/TrimControl.tsx b/src/components/TrimControl.tsx index efe6bf47..c8cca6d4 100644 --- a/src/components/TrimControl.tsx +++ b/src/components/TrimControl.tsx @@ -4,26 +4,20 @@ import { EditRecipe } from "@/lib/types"; import { useState, useEffect } from "react"; import { AlertCircle } from "lucide-react"; import { formatDuration } from "@/lib/utils"; -import { useAudioWaveform } from "@/hooks/useAudioWaveform"; -import WaveformCanvas from "@/components/WaveformCanvas"; interface Props { recipe: EditRecipe; onChange: (patch: Partial) => void; duration: number; - file: File | null; } -export default function TrimControl({ recipe, onChange, duration, file }: Props) { +export default function TrimControl({ recipe, onChange, duration }: Props) { const [invalidStart, setStart] = useState(false); const [invalidEnd, setEnd] = useState(false); const [startErrorMsg, setStartErrorMsg] = useState(""); const [endErrorMsg, setEndErrorMsg] = useState(""); const [startInput, setStartInput] = useState(recipe.trimStart.toString()); - const { waveform, isLoading: waveformLoading } = useAudioWaveform(file); - const hasAudio = waveform.length > 0; - useEffect(() => { setStartInput(recipe.trimStart.toString()); }, [recipe.trimStart]); @@ -114,19 +108,73 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props) setEndErrorMsg(""); }; - const inputClass = - "w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"; + const handleSliderStart = (val: number) => { + const end = recipe.trimEnd ?? duration; + if (val >= end) return; + setStart(false); + onChange({ trimStart: val }); + }; + + const handleSliderEnd = (val: number) => { + if (val <= recipe.trimStart) return; + setEnd(false); + onChange({ trimEnd: val }); + }; + + const inputClass = "w-full text-sm px-3 py-2 border border-[var(--border)] rounded-md bg-[var(--bg)] font-heading focus:outline-none focus:ring-2 focus:ring-film-400 text-[var(--text)] transition-shadow"; + + const thumbClass = [ + "absolute w-full h-1.5 appearance-none bg-transparent cursor-pointer", + "[&::-webkit-slider-thumb]:appearance-none", + "[&::-webkit-slider-thumb]:w-4", + "[&::-webkit-slider-thumb]:h-4", + "[&::-webkit-slider-thumb]:rounded-full", + "[&::-webkit-slider-thumb]:bg-white", + "[&::-webkit-slider-thumb]:border-2", + "[&::-webkit-slider-thumb]:border-film-400", + "[&::-webkit-slider-thumb]:shadow-md", + ].join(" "); + + const trimEnd = recipe.trimEnd ?? duration; + const startPercent = duration > 0 ? (recipe.trimStart / duration) * 100 : 0; + const endPercent = duration > 0 ? (trimEnd / duration) * 100 : 100; return (
- {/* Waveform — shown while loading or when file is present */} - {(file && (waveformLoading || hasAudio)) && ( -
- + + {duration > 0 && ( +
+
+
+
+ handleSliderStart(parseFloat(e.target.value))} + className={thumbClass} + style={{ zIndex: 3 }} + /> + handleSliderEnd(parseFloat(e.target.value))} + className={thumbClass} + style={{ zIndex: 4 }} + /> +
+
+ 0s + {duration.toFixed(1)}s +
)} diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 6e297539..e18783a9 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -19,7 +19,7 @@ import ImageOverlay from "./ImageOverlay" import { cn } from "@/lib/utils"; import { Layers, Crop, Scissors, RotateCw, Volume2, - SlidersHorizontal, Zap, AlertTriangle, Github, Copy + SlidersHorizontal, Zap, AlertTriangle } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; @@ -48,69 +48,9 @@ function Section({ icon, title, children, delay = 0 }: SectionProps) { ); } -/** Inline keyboard hint badge. */ -function Kbd({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -/** Collapsible panel that lists all keyboard shortcuts. */ -function KeyboardShortcutsPanel() { - const [open, setOpen] = useState(false); - - const shortcuts: { keys: React.ReactNode[]; label: string }[] = [ - { keys: [M], label: "Toggle audio mute" }, - { keys: [Ctrl, +, ], label: "Export video" }, - ]; - - return ( -
- - - {open && ( -
    - {shortcuts.map(({ keys, label }) => ( -
  • - {label} - {keys} -
  • - ))} -
- )} -
- ); -} - export default function VideoEditor() { const { - file, duration, recipe, status, progress, + file, duration, recipe, status, progress, result, error, updateRecipe, handleFileSelect, fileError, handleExport, cancelExport, reset, resetSettings, videoRef, @@ -122,17 +62,8 @@ export default function VideoEditor() { recommendedPreset, } = useVideoEditor(); const [copied, setCopied] = useState(false); - const [shareCopied, setShareCopied] = useState(false); const downloadRef = useRef(null); - const handleCopyLink = () => { - if (typeof window === "undefined") return; - navigator.clipboard.writeText(window.location.href).then(() => { - setShareCopied(true); - setTimeout(() => setShareCopied(false), 2000); - }); - }; - useEffect(() => { if (status === "done" && downloadRef.current) { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; @@ -170,10 +101,7 @@ export default function VideoEditor() {
-
+

REFRAME

@@ -187,8 +115,9 @@ export default function VideoEditor() {
-
+
+ {/* LEFT PANEL */}
@@ -196,7 +125,7 @@ export default function VideoEditor() { {!file && (

Upload a video to get started

- +

Supports MP4, MOV, WebM and more

)} @@ -234,7 +163,6 @@ export default function VideoEditor() { recipe={recipe} onChange={updateRecipe} duration={duration} - file={file} />
} title="Rotate" delay={100}> @@ -245,89 +173,6 @@ export default function VideoEditor() {
} title="Audio & Speed" delay={150}>
-
} - title="Adjustments" - delay={175} - > -
- {/* Brightness */} -
-
- - -
- updateRecipe({ brightness: Number(e.target.value) })} - aria-label="Adjust brightness" - className="w-full" - /> -
- {/* Contrast */} -
-
- - -
- updateRecipe({ contrast: Number(e.target.value) })} - aria-label="Adjust contrast" - className="w-full" - /> -
- {/* Saturation */} -
-
- - -
- updateRecipe({ saturation: Number(e.target.value) })} - aria-label="Adjust saturation" - className="w-full" - /> -
-
-
} title="Output format" delay={190}>
@@ -392,11 +237,12 @@ export default function VideoEditor() { )}
+ {/* RIGHT PANEL — sticky so it stays visible while scrolling */}
-
+
} title="Output size"> {recommendedPreset && (
@@ -412,15 +258,36 @@ export default function VideoEditor() {
-
- + {/* Adjustments moved here so they stay visible next to the video */} + {file && ( +
} title="Adjustments" delay={150}> +
+
+
+ + +
+ updateRecipe({ brightness: Number(e.target.value) })} aria-label="Adjust brightness" className="w-full" /> +
+
+
+ + +
+ updateRecipe({ contrast: Number(e.target.value) })} aria-label="Adjust contrast" className="w-full" /> +
+
+
+ + +
+ updateRecipe({ saturation: Number(e.target.value) })} aria-label="Adjust saturation" className="w-full" /> +
+
+
+ )} + +
- -
); -} \ No newline at end of file +} diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index 856ac383..d4b81276 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -1,52 +1,271 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-tabindex, jsx-a11y/no-noninteractive-element-interactions */ "use client"; -import { useEffect, useRef, RefObject } from "react"; +import { useEffect, useRef, useState, useCallback, RefObject } from "react"; import { EditRecipe } from "@/lib/types"; +import { getPresetById } from "@/lib/presets"; +import { cn } from "@/lib/utils"; +import { Camera } from "lucide-react"; +import BeforeAfterSlider from "./BeforeAfterSlider"; interface Props { file: File | null; + recipe?: EditRecipe; videoRef: RefObject; - recipe: EditRecipe; } -export default function VideoPreview({ file, videoRef ,recipe }: Props) { +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 onLoadedRef = useRef<(() => void) | null>(null); + + const handleGrabFrame = useCallback(() => { + const video = videoRef.current; + if (!video || video.readyState < 2) return; + + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + + 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"); + const filename = `frame-${mins}m${secs}s.png`; + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + }, "image/png"); + }, [videoRef]); useEffect(() => { if (!file) return; if (urlRef.current) URL.revokeObjectURL(urlRef.current); + setIsLoading(true); + const id = ++lastId.current; const url = URL.createObjectURL(file); + + if (urlRef.current) { + URL.revokeObjectURL(urlRef.current); + } urlRef.current = url; - if (videoRef.current) videoRef.current.src = url; + + const video = videoRef.current; + if (!video) return; + + video.src = url; + video.load(); + + const handleLoaded = () => { + if (lastId.current !== id) return; + video.play().catch(() => {}); + }; + + onLoadedRef.current = handleLoaded; + video.addEventListener("loadeddata", handleLoaded); return () => { - if (urlRef.current) URL.revokeObjectURL(urlRef.current); + 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; + } }; }, [file, videoRef]); - // sync mute state to video element - useEffect(() => { - if (!videoRef.current || !recipe) return; - videoRef.current.muted = !recipe.keepAudio; - }, [recipe, videoRef]); + // Compute CSS filter string from recipe + const filterStyle = (() => { + if (!recipe) return ""; + const parts: string[] = []; + if (recipe.brightness !== 0) + parts.push(`brightness(${1 + recipe.brightness})`); + if (recipe.contrast !== 0) + parts.push(`contrast(${1 + recipe.contrast})`); + if (recipe.saturation !== 0) + parts.push(`saturate(${1 + recipe.saturation})`); + return parts.join(" "); + })(); + + const hasColorAdjustments = filterStyle.length > 0; + + 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; + + 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}%` }; + } + } + })(); + + if (!file) return null; + + 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; + } + + const video = videoRef.current; + if (video) { + e.preventDefault(); + if (video.paused) { + video.play().catch(() => {}); + } else { + video.pause(); + } + } + } + }; - useEffect(() => { - if (!videoRef.current || !recipe) return; - videoRef.current.playbackRate = recipe.speed; - }, [recipe, videoRef]); return ( -
- +
+ {isLoading && ( +
+ )} + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + ref={videoRef} + controls + style={filterStyle ? { filter: filterStyle } : undefined} + className={cn("w-full h-full object-contain transition-opacity duration-300", isLoading ? "opacity-0" : "opacity-100")} + onLoadedData={() => setIsLoading(false)} + playsInline + /> + + {/* Before/After Slider */} + {!isLoading && hasColorAdjustments && ( + + )} + + {/* Letterbox / Crop overlay */} + {overlay && ( +